diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 6e1d37d..e929180 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -321,6 +321,22 @@ describe('registerDmAssistant', () => { bot.api.config.use(async (_prev, method, payload) => { calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + return { ok: true, result: true @@ -356,11 +372,19 @@ describe('registerDmAssistant', () => { await bot.handleUpdate(privateMessageUpdate('How much do I still owe this month?') as never) - expect(calls).toHaveLength(1) + expect(calls).toHaveLength(2) expect(calls[0]).toMatchObject({ method: 'sendMessage', payload: { chat_id: 123456, + text: 'Working on it...' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: 123456, + message_id: 1, text: 'You still owe 350.00 GEL this cycle.' } }) diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index 4a4815d..2e3d7e1 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -234,6 +234,57 @@ function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) { } } +interface PendingAssistantReply { + chatId: number + messageId: number +} + +async function sendAssistantProcessingReply( + ctx: Context, + text: string +): Promise { + const message = await ctx.reply(text) + + if (!message?.chat?.id || typeof message.message_id !== 'number') { + return null + } + + return { + chatId: message.chat.id, + messageId: message.message_id + } +} + +async function finalizeAssistantReply( + ctx: Context, + pendingReply: PendingAssistantReply | null, + text: string, + replyMarkup?: { + inline_keyboard: Array< + Array<{ + text: string + callback_data: string + }> + > + } +): Promise { + if (!pendingReply) { + await ctx.reply(text, replyMarkup ? { reply_markup: replyMarkup } : undefined) + return + } + + try { + await ctx.api.editMessageText( + pendingReply.chatId, + pendingReply.messageId, + text, + replyMarkup ? { reply_markup: replyMarkup } : {} + ) + } catch { + await ctx.reply(text, replyMarkup ? { reply_markup: replyMarkup } : undefined) + } +} + function parsePaymentProposalPayload( payload: Record ): PaymentProposalPayload | null { @@ -669,6 +720,7 @@ export function registerDmAssistant(options: { householdConfigurationRepository: options.householdConfigurationRepository, financeService }) + const pendingReply = await sendAssistantProcessingReply(ctx, t.processing) try { const reply = await options.assistant.respond({ @@ -706,7 +758,7 @@ export function registerDmAssistant(options: { 'DM assistant reply generated' ) - await ctx.reply(reply.text) + await finalizeAssistantReply(ctx, pendingReply, reply.text) } catch (error) { options.logger?.error( { @@ -717,7 +769,7 @@ export function registerDmAssistant(options: { }, 'DM assistant reply failed' ) - await ctx.reply(t.unavailable) + await finalizeAssistantReply(ctx, pendingReply, t.unavailable) } }) } diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index dec2813..2757a30 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -114,6 +114,7 @@ export const enBotTranslations: BotTranslationCatalog = { }, assistant: { unavailable: 'The assistant is temporarily unavailable. Try again in a moment.', + processing: 'Working on it...', noHousehold: 'I can help after your Telegram account is linked to a household. Open the household group and complete the join flow first.', multipleHouseholds: @@ -190,6 +191,7 @@ export const enBotTranslations: BotTranslationCatalog = { }, purchase: { sharedPurchaseFallback: 'shared purchase', + processing: 'Checking that purchase...', proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`, clarification: (question) => question, clarificationMissingAmountAndCurrency: diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index ae858fe..0626702 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -117,6 +117,7 @@ export const ruBotTranslations: BotTranslationCatalog = { }, assistant: { unavailable: 'Ассистент сейчас недоступен. Попробуйте ещё раз чуть позже.', + processing: 'Сейчас разберусь...', noHousehold: 'Я смогу помочь после того, как ваш Telegram-профиль будет привязан к дому. Сначала откройте группу дома и завершите вступление.', multipleHouseholds: @@ -193,6 +194,7 @@ export const ruBotTranslations: BotTranslationCatalog = { }, purchase: { sharedPurchaseFallback: 'общая покупка', + processing: 'Проверяю покупку...', proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`, clarification: (question) => question, clarificationMissingAmountAndCurrency: diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 6a57886..743b8a6 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -122,6 +122,7 @@ export interface BotTranslationCatalog { } assistant: { unavailable: string + processing: string noHousehold: string multipleHouseholds: string rateLimited: (retryDelay: string) => string @@ -207,6 +208,7 @@ export interface BotTranslationCatalog { } purchase: { sharedPurchaseFallback: string + processing: string proposal: (summary: string) => string clarification: (question: string) => string clarificationMissingAmountAndCurrency: string diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 7d32cff..ad30bd4 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -385,6 +385,103 @@ describe('registerPurchaseTopicIngestion', () => { }) }) + test('sends a processing reply and edits it when an interpreter is configured', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async save() { + return { + status: 'pending_confirmation', + purchaseMessageId: 'proposal-1', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'toilet paper', + parserConfidence: 92, + parserMode: 'llm' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'purchase', + amountMinor: 3000n, + currency: 'GEL', + itemDescription: 'toilet paper', + confidence: 92, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: Number(config.householdChatId), + text: 'Checking that purchase...', + reply_parameters: { + message_id: 55 + } + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: Number(config.householdChatId), + message_id: 1, + text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm', + callback_data: 'purchase:confirm:proposal-1' + }, + { + text: 'Cancel', + callback_data: 'purchase:cancel:proposal-1' + } + ] + ] + } + } + }) + }) + test('does not reply for duplicate deliveries or non-purchase chatter', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 170b7c4..0734e35 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -292,6 +292,76 @@ async function replyToPurchaseMessage( }) } +interface PendingPurchaseReply { + chatId: number + messageId: number +} + +async function sendPurchaseProcessingReply( + ctx: Context, + text: string +): Promise { + const message = ctx.msg + if (!message) { + return null + } + + const reply = await ctx.reply(text, { + reply_parameters: { + message_id: message.message_id + } + }) + + if (!reply?.chat?.id || typeof reply.message_id !== 'number') { + return null + } + + return { + chatId: reply.chat.id, + messageId: reply.message_id + } +} + +async function finalizePurchaseReply( + ctx: Context, + pendingReply: PendingPurchaseReply | null, + text: string | null, + replyMarkup?: { + inline_keyboard: Array< + Array<{ + text: string + callback_data: string + }> + > + } +): Promise { + if (!text) { + if (pendingReply) { + try { + await ctx.api.deleteMessage(pendingReply.chatId, pendingReply.messageId) + } catch {} + } + + return + } + + if (!pendingReply) { + await replyToPurchaseMessage(ctx, text, replyMarkup) + return + } + + try { + await ctx.api.editMessageText( + pendingReply.chatId, + pendingReply.messageId, + text, + replyMarkup ? { reply_markup: replyMarkup } : {} + ) + } catch { + await replyToPurchaseMessage(ctx, text, replyMarkup) + } +} + function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { const message = ctx.message if (!message || !('text' in message)) { @@ -728,7 +798,8 @@ async function handlePurchaseMessageResult( record: PurchaseTopicRecord, result: PurchaseMessageIngestionResult, locale: BotLocale, - logger: Logger | undefined + logger: Logger | undefined, + pendingReply: PendingPurchaseReply | null = null ): Promise { if (result.status !== 'duplicate') { logger?.info( @@ -747,12 +818,9 @@ async function handlePurchaseMessageResult( } const acknowledgement = buildPurchaseAcknowledgement(result, locale) - if (!acknowledgement) { - return - } - - await replyToPurchaseMessage( + await finalizePurchaseReply( ctx, + pendingReply, acknowledgement, result.status === 'pending_confirmation' ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId) @@ -921,8 +989,11 @@ export function registerPurchaseTopicIngestion( } try { + const pendingReply = options.interpreter + ? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing) + : null const result = await repository.save(record, options.interpreter, 'GEL') - await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger) + await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply) } catch (error) { options.logger?.error( { @@ -986,13 +1057,16 @@ export function registerConfiguredPurchaseTopicIngestion( householdConfigurationRepository, record.householdId ) + const pendingReply = options.interpreter + ? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing) + : null const result = await repository.save( record, options.interpreter, billingSettings.settlementCurrency ) - await handlePurchaseMessageResult(ctx, record, result, locale, options.logger) + await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply) } catch (error) { options.logger?.error( {