From ca610851793055d4becb33bdf392d62f720c81dc Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 12 Mar 2026 14:44:55 +0400 Subject: [PATCH] Fix purchase topic engagement gating --- apps/bot/src/purchase-topic-ingestion.test.ts | 429 +++++++++++++++++- apps/bot/src/purchase-topic-ingestion.ts | 113 ++++- 2 files changed, 531 insertions(+), 11 deletions(-) diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index dc2ba93..16c5bf5 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -48,7 +48,12 @@ function participants() { ] as const } -function purchaseUpdate(text: string) { +function purchaseUpdate( + text: string, + options: { + replyToBot?: boolean + } = {} +) { const commandToken = text.split(' ')[0] ?? text return { @@ -67,6 +72,25 @@ function purchaseUpdate(text: string) { is_bot: false, first_name: 'Mia' }, + ...(options.replyToBot + ? { + reply_to_message: { + message_id: 12, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + from: { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot' + }, + text: 'Which amount was that purchase?' + } + } + : {}), text, entities: text.startsWith('/') ? [ @@ -440,6 +464,94 @@ Confirm or cancel below.`, }) }) + test('keeps bare-amount purchase reports on the ingestion path', 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 hasClarificationContext() { + return false + }, + async save(record) { + expect(record.rawText).toBe('Bought toilet paper 30') + return { + status: 'clarification_needed', + purchaseMessageId: 'proposal-amount-only', + clarificationQuestion: 'Which currency was this purchase in?', + parsedAmountMinor: 3000n, + parsedCurrency: null, + parsedItemDescription: 'toilet paper', + parserConfidence: 58, + parserMode: 'llm' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'clarification', + amountMinor: 3000n, + currency: null, + itemDescription: 'toilet paper', + confidence: 58, + parserMode: 'llm', + clarificationQuestion: 'Which currency was this purchase in?' + }) + }) + + await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30') as never) + + expect(calls).toHaveLength(3) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction' + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'Checking that purchase...' + } + }) + expect(calls[2]).toMatchObject({ + method: 'editMessageText', + payload: { + text: 'Which currency was this purchase in?' + } + }) + }) + test('sends a processing reply and edits it when an interpreter is configured', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] @@ -569,6 +681,112 @@ Confirm or cancel below.`, }) }) + test('stays silent for planning chatter even when an interpreter is configured', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let saveCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save() { + saveCalls += 1 + return { + status: 'ignored_not_purchase', + purchaseMessageId: 'ignored-1' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'not_purchase', + amountMinor: null, + currency: null, + itemDescription: null, + confidence: 12, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate(purchaseUpdate('We should buy toilet paper for 30 gel') as never) + + expect(saveCalls).toBe(0) + expect(calls).toHaveLength(0) + }) + + test('stays silent for stray amount chatter in the purchase topic', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let saveCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save() { + saveCalls += 1 + return { + status: 'ignored_not_purchase', + purchaseMessageId: 'ignored-2' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'not_purchase', + amountMinor: null, + currency: null, + itemDescription: null, + confidence: 17, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate(purchaseUpdate('This machine costs 300 gel, scary') as never) + + expect(saveCalls).toBe(0) + expect(calls).toHaveLength(0) + }) + test('does not reply for duplicate deliveries or non-purchase chatter', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] @@ -716,6 +934,215 @@ Confirm or cancel below.` }) }) + test('does not send the purchase handoff for tagged non-purchase conversation', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let saveCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save() { + saveCalls += 1 + return { + status: 'ignored_not_purchase', + purchaseMessageId: 'ignored-3' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'not_purchase', + amountMinor: null, + currency: null, + itemDescription: null, + confidence: 19, + parserMode: 'llm', + clarificationQuestion: null + }) + }) + + await bot.handleUpdate(purchaseUpdate('@household_test_bot please ignore me today') as never) + + expect(saveCalls).toBe(1) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction', + payload: { + chat_id: Number(config.householdChatId), + action: 'typing', + message_thread_id: config.purchaseTopicId + } + }) + }) + + test('continues purchase handling for replies to bot messages without a fresh mention', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, + async save(record) { + expect(record.rawText).toBe('Actually it was 32 gel') + return { + status: 'clarification_needed', + purchaseMessageId: 'proposal-2', + clarificationQuestion: 'Was that for toilet paper?', + parsedAmountMinor: 3200n, + parsedCurrency: 'GEL', + parsedItemDescription: null, + parserConfidence: 61, + parserMode: 'llm' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'clarification', + amountMinor: 3200n, + currency: 'GEL', + itemDescription: null, + confidence: 61, + parserMode: 'llm', + clarificationQuestion: 'Was that for toilet paper?' + }) + }) + + await bot.handleUpdate(purchaseUpdate('Actually it was 32 gel', { replyToBot: true }) as never) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction' + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'Was that for toilet paper?' + } + }) + }) + + test('continues purchase handling for active clarification context without a fresh mention', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: Number(config.householdChatId), + type: 'supergroup' + }, + text: 'ok' + } + } as never + }) + + const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return true + }, + async save(record) { + expect(record.rawText).toBe('32 gel') + return { + status: 'clarification_needed', + purchaseMessageId: 'proposal-3', + clarificationQuestion: 'What item was that for?', + parsedAmountMinor: 3200n, + parsedCurrency: 'GEL', + parsedItemDescription: null, + parserConfidence: 58, + parserMode: 'llm' + } + }, + async confirm() { + throw new Error('not used') + }, + async cancel() { + throw new Error('not used') + }, + async toggleParticipant() { + throw new Error('not used') + } + } + + registerPurchaseTopicIngestion(bot, config, repository, { + interpreter: async () => ({ + decision: 'clarification', + amountMinor: 3200n, + currency: 'GEL', + itemDescription: null, + confidence: 58, + parserMode: 'llm', + clarificationQuestion: 'What item was that for?' + }) + }) + + await bot.handleUpdate(purchaseUpdate('32 gel') as never) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction' + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: 'What item was that for?' + } + }) + }) + test('toggles purchase participants before confirmation', 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 5c6c080..5278337 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -20,6 +20,27 @@ const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:' const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:' const PURCHASE_PARTICIPANT_CALLBACK_PREFIX = 'purchase:participant:' const MIN_PROPOSAL_CONFIDENCE = 70 +const LIKELY_PURCHASE_VERB_PATTERN = + /\b(?:bought|purchased|paid|spent|ordered|picked up|grabbed|got)\b|\b(?:купил(?:а|и)?|куплено|заказал(?:а|и)?|оплатил(?:а|и)?|потратил(?:а|и)?|взял(?:а|и)?)\b/iu +const PLANNING_PURCHASE_PATTERN = + /\b(?:should buy|should get|need to buy|need to get|want to buy|want to get|let'?s buy|let'?s get|going to buy|gonna buy|plan to buy|planning to buy|thinking about buying|thinking of buying|should we buy|should we get|can buy)\b|\b(?:надо|нужно|хочу|хотим|давай(?:те)?|будем|планирую|планируем|может|стоит)\s+(?:купить|взять|заказать|оплатить)\b|\b(?:купим|возьмем|возьмём|закажем|оплатим)\b/iu +const MONEY_SIGNAL_PATTERN = + /\b\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|лари|tetri|тетри|usd|\$|доллар(?:а|ов)?)\b|\b(?:for|за|на)\s+\d+(?:[.,]\d{1,2})?\b|\b(?:paid|spent|заплатил(?:а|и)?|потратил(?:а|и)?|отдал(?:а|и)?|выложил(?:а|и)?)\s+\d+(?:[.,]\d{1,2})?\b/iu +const STANDALONE_NUMBER_PATTERN = /\b\d+(?:[.,]\d{1,2})?\b/gu + +type PurchaseTopicEngagement = + | { + kind: 'direct' + showProcessingReply: boolean + } + | { + kind: 'clarification' + showProcessingReply: boolean + } + | { + kind: 'likely_purchase' + showProcessingReply: true + } type StoredPurchaseProcessingStatus = | 'pending_confirmation' @@ -206,6 +227,61 @@ function periodFromInstant(instant: Instant, timezone: string): string { return `${localDate.year}-${String(localDate.month).padStart(2, '0')}` } +function isReplyToCurrentBot(ctx: Pick): boolean { + const replyAuthor = ctx.msg?.reply_to_message?.from + if (!replyAuthor?.is_bot) { + return false + } + + return replyAuthor.id === ctx.me.id +} + +function looksLikeLikelyCompletedPurchase(rawText: string): boolean { + if (PLANNING_PURCHASE_PATTERN.test(rawText)) { + return false + } + + if (!LIKELY_PURCHASE_VERB_PATTERN.test(rawText)) { + return false + } + + if (MONEY_SIGNAL_PATTERN.test(rawText)) { + return true + } + + return Array.from(rawText.matchAll(STANDALONE_NUMBER_PATTERN)).length === 1 +} + +async function resolvePurchaseTopicEngagement( + ctx: Pick, + record: PurchaseTopicRecord, + repository: Pick +): Promise { + const hasExplicitMention = stripExplicitBotMention(ctx) !== null + if (hasExplicitMention || isReplyToCurrentBot(ctx)) { + return { + kind: 'direct', + showProcessingReply: looksLikeLikelyCompletedPurchase(record.rawText) + } + } + + if (await repository.hasClarificationContext(record)) { + return { + kind: 'clarification', + showProcessingReply: false + } + } + + if (looksLikeLikelyCompletedPurchase(record.rawText)) { + return { + kind: 'likely_purchase', + showProcessingReply: true + } + } + + return null +} + function normalizeInterpretation( interpretation: PurchaseInterpretation | null, parserError: string | null @@ -1481,14 +1557,23 @@ export function registerPurchaseTopicIngestion( return } - const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null + let typingIndicator: ReturnType | null = null try { - const pendingReply = options.interpreter - ? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing) - : null + const engagement = await resolvePurchaseTopicEngagement(ctx, record, repository) + if (!engagement) { + await next() + return + } + + typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null + const pendingReply = + options.interpreter && engagement.showProcessingReply + ? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing) + : null const result = await repository.save(record, options.interpreter, 'GEL') - if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') { + + if (engagement.kind === 'direct' && result.status === 'ignored_not_purchase') { return await next() } await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply) @@ -1549,9 +1634,16 @@ export function registerConfiguredPurchaseTopicIngestion( return } - const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null + let typingIndicator: ReturnType | null = null try { + const engagement = await resolvePurchaseTopicEngagement(ctx, record, repository) + if (!engagement) { + await next() + return + } + + typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null const [billingSettings, assistantConfig] = await Promise.all([ householdConfigurationRepository.getHouseholdBillingSettings(record.householdId), resolveAssistantConfig(householdConfigurationRepository, record.householdId) @@ -1560,9 +1652,10 @@ export function registerConfiguredPurchaseTopicIngestion( householdConfigurationRepository, record.householdId ) - const pendingReply = options.interpreter - ? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing) - : null + const pendingReply = + options.interpreter && engagement.showProcessingReply + ? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing) + : null const result = await repository.save( record, options.interpreter, @@ -1572,7 +1665,7 @@ export function registerConfiguredPurchaseTopicIngestion( assistantTone: assistantConfig.assistantTone } ) - if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') { + if (engagement.kind === 'direct' && result.status === 'ignored_not_purchase') { return await next() }