diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 2ea3c5e..6649b67 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -15,6 +15,7 @@ import { createInMemoryAssistantUsageTracker, registerDmAssistant } from './dm-assistant' +import type { PurchaseMessageIngestionRepository } from './purchase-topic-ingestion' function createTestBot() { const bot = createTelegramBot('000000:test-token') @@ -326,6 +327,190 @@ function createPromptRepository(): TelegramPendingActionRepository { } } +function createPurchaseRepository(): PurchaseMessageIngestionRepository { + const clarificationKeys = new Set() + const proposals = new Map< + string, + { + householdId: string + senderTelegramUserId: string + parsedAmountMinor: bigint + parsedCurrency: 'GEL' | 'USD' + parsedItemDescription: string + status: 'pending_confirmation' | 'confirmed' | 'cancelled' + } + >() + + function key(input: { householdId: string; senderTelegramUserId: string; threadId: string }) { + return `${input.householdId}:${input.senderTelegramUserId}:${input.threadId}` + } + + return { + async hasClarificationContext(record) { + return clarificationKeys.has(key(record)) + }, + async save(record) { + const threadKey = key(record) + + if (record.rawText === 'I bought a door handle for 30 lari') { + proposals.set('purchase-1', { + householdId: record.householdId, + senderTelegramUserId: record.senderTelegramUserId, + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL', + parsedItemDescription: 'door handle', + status: 'pending_confirmation' + }) + + return { + status: 'pending_confirmation' as const, + purchaseMessageId: 'purchase-1', + parsedAmountMinor: 3000n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'door handle', + parserConfidence: 92, + parserMode: 'llm' as const + } + } + + if (record.rawText === 'I bought sausages, paid 45') { + clarificationKeys.add(threadKey) + return { + status: 'clarification_needed' as const, + purchaseMessageId: 'purchase-clarification-1', + clarificationQuestion: 'Which currency was this purchase in?', + parsedAmountMinor: 4500n, + parsedCurrency: null, + parsedItemDescription: 'sausages', + parserConfidence: 61, + parserMode: 'llm' as const + } + } + + if (record.rawText === 'lari' && clarificationKeys.has(threadKey)) { + clarificationKeys.delete(threadKey) + proposals.set('purchase-2', { + householdId: record.householdId, + senderTelegramUserId: record.senderTelegramUserId, + parsedAmountMinor: 4500n, + parsedCurrency: 'GEL', + parsedItemDescription: 'sausages', + status: 'pending_confirmation' + }) + + return { + status: 'pending_confirmation' as const, + purchaseMessageId: 'purchase-2', + parsedAmountMinor: 4500n, + parsedCurrency: 'GEL' as const, + parsedItemDescription: 'sausages', + parserConfidence: 88, + parserMode: 'llm' as const + } + } + + return { + status: 'ignored_not_purchase' as const, + purchaseMessageId: `ignored-${record.messageId}` + } + }, + async confirm(purchaseMessageId, actorTelegramUserId) { + const proposal = proposals.get(purchaseMessageId) + if (!proposal) { + return { + status: 'not_found' as const + } + } + + if (proposal.senderTelegramUserId !== actorTelegramUserId) { + return { + status: 'forbidden' as const, + householdId: proposal.householdId + } + } + + if (proposal.status === 'confirmed') { + return { + status: 'already_confirmed' as const, + purchaseMessageId, + householdId: proposal.householdId, + parsedAmountMinor: proposal.parsedAmountMinor, + parsedCurrency: proposal.parsedCurrency, + parsedItemDescription: proposal.parsedItemDescription, + parserConfidence: 92, + parserMode: 'llm' as const + } + } + + if (proposal.status !== 'pending_confirmation') { + return { + status: 'not_pending' as const, + householdId: proposal.householdId + } + } + + proposal.status = 'confirmed' + return { + status: 'confirmed' as const, + purchaseMessageId, + householdId: proposal.householdId, + parsedAmountMinor: proposal.parsedAmountMinor, + parsedCurrency: proposal.parsedCurrency, + parsedItemDescription: proposal.parsedItemDescription, + parserConfidence: 92, + parserMode: 'llm' as const + } + }, + async cancel(purchaseMessageId, actorTelegramUserId) { + const proposal = proposals.get(purchaseMessageId) + if (!proposal) { + return { + status: 'not_found' as const + } + } + + if (proposal.senderTelegramUserId !== actorTelegramUserId) { + return { + status: 'forbidden' as const, + householdId: proposal.householdId + } + } + + if (proposal.status === 'cancelled') { + return { + status: 'already_cancelled' as const, + purchaseMessageId, + householdId: proposal.householdId, + parsedAmountMinor: proposal.parsedAmountMinor, + parsedCurrency: proposal.parsedCurrency, + parsedItemDescription: proposal.parsedItemDescription, + parserConfidence: 92, + parserMode: 'llm' as const + } + } + + if (proposal.status !== 'pending_confirmation') { + return { + status: 'not_pending' as const, + householdId: proposal.householdId + } + } + + proposal.status = 'cancelled' + return { + status: 'cancelled' as const, + purchaseMessageId, + householdId: proposal.householdId, + parsedAmountMinor: proposal.parsedAmountMinor, + parsedCurrency: proposal.parsedCurrency, + parsedItemDescription: proposal.parsedItemDescription, + parserConfidence: 92, + parserMode: 'llm' as const + } + } + } +} + function createProcessedBotMessageRepository(): ProcessedBotMessageRepository { const claims = new Set() @@ -498,6 +683,266 @@ describe('registerDmAssistant', () => { }) }) + test('routes obvious purchase-like DMs into purchase confirmation flow', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let assistantCalls = 0 + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond() { + assistantCalls += 1 + return { + text: 'fallback assistant reply', + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15 + } + } + } + }, + purchaseRepository: createPurchaseRepository(), + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('I bought a door handle for 30 lari') as never) + + expect(assistantCalls).toBe(0) + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction', + payload: { + chat_id: 123456, + action: 'typing' + } + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 123456, + text: 'I think this shared purchase was: door handle - 30.00 GEL. Confirm or cancel below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm', + callback_data: 'assistant_purchase:confirm:purchase-1' + }, + { + text: 'Cancel', + callback_data: 'assistant_purchase:cancel:purchase-1' + } + ] + ] + } + } + }) + }) + + test('uses clarification context for follow-up purchase replies in DM', 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: true + } as never + }) + + registerDmAssistant({ + bot, + purchaseRepository: createPurchaseRepository(), + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('I bought sausages, paid 45') as never) + await bot.handleUpdate(privateMessageUpdate('lari') as never) + + expect(calls).toHaveLength(4) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 123456, + text: 'Which currency was this purchase in?' + } + }) + expect(calls[3]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 123456, + text: 'I think this shared purchase was: sausages - 45.00 GEL. Confirm or cancel below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm', + callback_data: 'assistant_purchase:confirm:purchase-2' + }, + { + text: 'Cancel', + callback_data: 'assistant_purchase:cancel:purchase-2' + } + ] + ] + } + } + }) + }) + + test('confirms a pending purchase proposal from DM callback', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const purchaseRepository = createPurchaseRepository() + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + purchaseRepository, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('I bought a door handle for 30 lari') as never) + calls.length = 0 + + await bot.handleUpdate(privateCallbackUpdate('assistant_purchase:confirm:purchase-1') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Purchase confirmed.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: 123456, + message_id: 77, + text: 'Purchase confirmed: door handle - 30.00 GEL' + } + }) + }) + + test('falls back to the generic assistant for non-purchase chatter', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + let assistantCalls = 0 + + 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 + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond() { + assistantCalls += 1 + return { + text: 'general fallback reply', + usage: { + inputTokens: 22, + outputTokens: 7, + totalTokens: 29 + } + } + } + }, + purchaseRepository: createPurchaseRepository(), + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateMessageUpdate('How are you?') as never) + + expect(assistantCalls).toBe(1) + expect(calls).toHaveLength(2) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 123456, + text: 'general fallback reply' + } + }) + }) + test('ignores duplicate deliveries of the same DM update', async () => { const bot = createTestBot() const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts index fe9d238..43d7496 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -1,5 +1,5 @@ import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' -import { Money } from '@household/domain' +import { instantFromEpochSeconds, Money } from '@household/domain' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, @@ -11,13 +11,25 @@ import type { Bot, Context } from 'grammy' import { resolveReplyLocale } from './bot-locale' import { getBotTranslations, type BotLocale } from './i18n' import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant' +import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter' +import type { + PurchaseMessageIngestionRepository, + PurchaseProposalActionResult, + PurchaseTopicRecord +} from './purchase-topic-ingestion' import { startTypingIndicator } from './telegram-chat-action' const ASSISTANT_PAYMENT_ACTION = 'assistant_payment_confirmation' as const const ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX = 'assistant_payment:confirm:' const ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX = 'assistant_payment:cancel:' +const ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX = 'assistant_purchase:confirm:' +const ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX = 'assistant_purchase:cancel:' const DM_ASSISTANT_MESSAGE_SOURCE = 'telegram-dm-assistant' const MEMORY_SUMMARY_MAX_CHARS = 1200 +const PURCHASE_VERB_PATTERN = + /\b(?:bought|buy|got|picked up|spent|купил(?:а|и)?|взял(?:а|и)?|выложил(?:а|и)?|отдал(?:а|и)?|потратил(?:а|и)?)\b/iu +const PURCHASE_MONEY_PATTERN = + /(?:\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|лари|usd|\$|доллар(?:а|ов)?|кровн\p{L}*)|\b\d+(?:[.,]\d{1,2})\b)/iu interface AssistantConversationTurn { role: 'user' | 'assistant' @@ -73,6 +85,11 @@ interface PaymentProposalPayload { currency: 'GEL' | 'USD' } +type PurchaseActionResult = Extract< + PurchaseProposalActionResult, + { status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' } +> + function describeError(error: unknown): { errorMessage?: string errorName?: string @@ -257,6 +274,133 @@ function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) { } } +function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) { + const t = getBotTranslations(locale).purchase + + return { + inline_keyboard: [ + [ + { + text: t.confirmButton, + callback_data: `${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}` + }, + { + text: t.cancelButton, + callback_data: `${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}` + } + ] + ] + } +} + +function formatPurchaseSummary( + locale: BotLocale, + result: { + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + } +): string { + if ( + result.parsedAmountMinor === null || + result.parsedCurrency === null || + result.parsedItemDescription === null + ) { + return getBotTranslations(locale).purchase.sharedPurchaseFallback + } + + const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency) + return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}` +} + +function buildPurchaseActionMessage(locale: BotLocale, result: PurchaseActionResult): string { + const t = getBotTranslations(locale).purchase + const summary = formatPurchaseSummary(locale, result) + + if (result.status === 'confirmed' || result.status === 'already_confirmed') { + return t.confirmed(summary) + } + + return t.cancelled(summary) +} + +function buildPurchaseClarificationText( + locale: BotLocale, + result: { + clarificationQuestion: string | null + parsedAmountMinor: bigint | null + parsedCurrency: 'GEL' | 'USD' | null + parsedItemDescription: string | null + } +): string { + const t = getBotTranslations(locale).purchase + if (result.clarificationQuestion) { + return t.clarification(result.clarificationQuestion) + } + + if (result.parsedAmountMinor === null && result.parsedCurrency === null) { + return t.clarificationMissingAmountAndCurrency + } + + if (result.parsedAmountMinor === null) { + return t.clarificationMissingAmount + } + + if (result.parsedCurrency === null) { + return t.clarificationMissingCurrency + } + + if (result.parsedItemDescription === null) { + return t.clarificationMissingItem + } + + return t.clarificationLowConfidence +} + +function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopicRecord | null { + if (!isPrivateChat(ctx) || !ctx.msg || !('text' in ctx.msg) || !ctx.from) { + return null + } + + const chat = ctx.chat + if (!chat) { + return null + } + + const senderDisplayName = [ctx.from.first_name, ctx.from.last_name] + .filter((part) => !!part && part.trim().length > 0) + .join(' ') + + return { + updateId: ctx.update.update_id, + householdId, + chatId: chat.id.toString(), + messageId: ctx.msg.message_id.toString(), + threadId: chat.id.toString(), + senderTelegramUserId: ctx.from.id.toString(), + rawText: ctx.msg.text.trim(), + messageSentAt: instantFromEpochSeconds(ctx.msg.date), + ...(senderDisplayName.length > 0 + ? { + senderDisplayName + } + : {}) + } +} + +function looksLikePurchaseIntent(rawText: string): boolean { + const normalized = rawText.trim() + if (normalized.length === 0) { + return false + } + + if (PURCHASE_VERB_PATTERN.test(normalized)) { + return true + } + + return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized) +} + function parsePaymentProposalPayload( payload: Record ): PaymentProposalPayload | null { @@ -436,6 +580,8 @@ async function maybeCreatePaymentProposal(input: { export function registerDmAssistant(options: { bot: Bot assistant?: ConversationalAssistant + purchaseRepository?: PurchaseMessageIngestionRepository + purchaseInterpreter?: PurchaseMessageInterpreter householdConfigurationRepository: HouseholdConfigurationRepository messageProcessingRepository?: ProcessedBotMessageRepository promptRepository: TelegramPendingActionRepository @@ -580,6 +726,131 @@ export function registerDmAssistant(options: { } ) + options.bot.callbackQuery( + new RegExp(`^${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), + async (ctx) => { + if (!isPrivateChat(ctx) || !options.purchaseRepository) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const purchaseMessageId = ctx.match[1] + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId || !purchaseMessageId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await options.purchaseRepository.confirm( + purchaseMessageId, + actorTelegramUserId + ) + const locale = + 'householdId' in result + ? await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + : 'en' + const t = getBotTranslations(locale).purchase + + if (result.status === 'not_found' || result.status === 'not_pending') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + if (result.status === 'forbidden') { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: result.status === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed + }) + + if (ctx.msg) { + await ctx.editMessageText(buildPurchaseActionMessage(locale, result), { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + ) + + options.bot.callbackQuery( + new RegExp(`^${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), + async (ctx) => { + if (!isPrivateChat(ctx) || !options.purchaseRepository) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const purchaseMessageId = ctx.match[1] + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId || !purchaseMessageId) { + await ctx.answerCallbackQuery({ + text: getBotTranslations('en').purchase.proposalUnavailable, + show_alert: true + }) + return + } + + const result = await options.purchaseRepository.cancel(purchaseMessageId, actorTelegramUserId) + const locale = + 'householdId' in result + ? await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + : 'en' + const t = getBotTranslations(locale).purchase + + if (result.status === 'not_found' || result.status === 'not_pending') { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + if (result.status === 'forbidden') { + await ctx.answerCallbackQuery({ + text: t.notYourProposal, + show_alert: true + }) + return + } + + await ctx.answerCallbackQuery({ + text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled + }) + + if (ctx.msg) { + await ctx.editMessageText(buildPurchaseActionMessage(locale, result), { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + ) + options.bot.on('message:text', async (ctx, next) => { if (!isPrivateChat(ctx) || isCommandMessage(ctx)) { await next() @@ -651,6 +922,64 @@ export function registerDmAssistant(options: { return } + const purchaseRecord = createDmPurchaseRecord(ctx, member.householdId) + const shouldAttemptPurchase = + purchaseRecord && + options.purchaseRepository && + (looksLikePurchaseIntent(purchaseRecord.rawText) || + (await options.purchaseRepository.hasClarificationContext(purchaseRecord))) + + if (purchaseRecord && options.purchaseRepository && shouldAttemptPurchase) { + const typingIndicator = startTypingIndicator(ctx) + + try { + const settings = + await options.householdConfigurationRepository.getHouseholdBillingSettings( + member.householdId + ) + const purchaseResult = await options.purchaseRepository.save( + purchaseRecord, + options.purchaseInterpreter, + settings.settlementCurrency + ) + + if (purchaseResult.status !== 'ignored_not_purchase') { + const purchaseText = + purchaseResult.status === 'pending_confirmation' + ? getBotTranslations(locale).purchase.proposal( + formatPurchaseSummary(locale, purchaseResult) + ) + : purchaseResult.status === 'clarification_needed' + ? buildPurchaseClarificationText(locale, purchaseResult) + : getBotTranslations(locale).purchase.parseFailed + + options.memoryStore.appendTurn(telegramUserId, { + role: 'user', + text: ctx.msg.text + }) + options.memoryStore.appendTurn(telegramUserId, { + role: 'assistant', + text: purchaseText + }) + + const replyOptions = + purchaseResult.status === 'pending_confirmation' + ? { + reply_markup: purchaseProposalReplyMarkup( + locale, + purchaseResult.purchaseMessageId + ) + } + : undefined + + await ctx.reply(purchaseText, replyOptions) + return + } + } finally { + typingIndicator.stop() + } + } + const financeService = options.financeServiceForHousehold(member.householdId) const paymentProposal = await maybeCreatePaymentProposal({ rawText: ctx.msg.text, diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 96c56f3..c55ae0a 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -117,6 +117,13 @@ const processedBotMessageRepositoryClient = runtime.databaseUrl && runtime.assistantEnabled ? createDbProcessedBotMessageRepository(runtime.databaseUrl!) : null +const purchaseRepositoryClient = runtime.databaseUrl + ? createPurchaseMessageRepository(runtime.databaseUrl!) + : null +const purchaseInterpreter = createOpenAiPurchaseInterpreter( + runtime.openaiApiKey, + runtime.purchaseParserModel +) const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore( runtime.assistantMemoryMaxTurns ) @@ -214,14 +221,11 @@ if (processedBotMessageRepositoryClient) { shutdownTasks.push(processedBotMessageRepositoryClient.close) } -if (runtime.databaseUrl && householdConfigurationRepositoryClient) { - const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) +if (purchaseRepositoryClient) { shutdownTasks.push(purchaseRepositoryClient.close) - const purchaseInterpreter = createOpenAiPurchaseInterpreter( - runtime.openaiApiKey, - runtime.purchaseParserModel - ) +} +if (purchaseRepositoryClient && householdConfigurationRepositoryClient) { registerConfiguredPurchaseTopicIngestion( bot, householdConfigurationRepositoryClient.repository, @@ -392,6 +396,16 @@ if ( memoryStore: assistantMemoryStore, rateLimiter: assistantRateLimiter, usageTracker: assistantUsageTracker, + ...(purchaseRepositoryClient + ? { + purchaseRepository: purchaseRepositoryClient.repository + } + : {}), + ...(purchaseInterpreter + ? { + purchaseInterpreter + } + : {}), ...(conversationalAssistant ? { assistant: conversationalAssistant @@ -408,6 +422,16 @@ if ( memoryStore: assistantMemoryStore, rateLimiter: assistantRateLimiter, usageTracker: assistantUsageTracker, + ...(purchaseRepositoryClient + ? { + purchaseRepository: purchaseRepositoryClient.repository + } + : {}), + ...(purchaseInterpreter + ? { + purchaseInterpreter + } + : {}), ...(conversationalAssistant ? { assistant: conversationalAssistant diff --git a/apps/bot/src/purchase-topic-ingestion.test.ts b/apps/bot/src/purchase-topic-ingestion.test.ts index 06797be..8730980 100644 --- a/apps/bot/src/purchase-topic-ingestion.test.ts +++ b/apps/bot/src/purchase-topic-ingestion.test.ts @@ -287,6 +287,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { return { status: 'pending_confirmation', @@ -356,6 +359,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { return { status: 'clarification_needed', @@ -414,6 +420,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { return { status: 'pending_confirmation', @@ -504,6 +513,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { saveCall += 1 return saveCall === 1 @@ -544,6 +556,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { return { status: 'pending_confirmation', @@ -610,6 +625,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { throw new Error('not used') }, @@ -662,6 +680,9 @@ describe('registerPurchaseTopicIngestion', () => { }) const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext() { + return false + }, async save() { throw new Error('not used') }, diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 073e70a..fab4259 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -120,6 +120,7 @@ export type PurchaseProposalActionResult = } export interface PurchaseMessageIngestionRepository { + hasClarificationContext(record: PurchaseTopicRecord): Promise save( record: PurchaseTopicRecord, interpreter?: PurchaseMessageInterpreter, @@ -626,6 +627,11 @@ export function createPurchaseMessageRepository(databaseUrl: string): { } const repository: PurchaseMessageIngestionRepository = { + async hasClarificationContext(record) { + const clarificationContext = await getClarificationContext(record) + return Boolean(clarificationContext && clarificationContext.length > 0) + }, + async save(record, interpreter, defaultCurrency) { const matchedMember = await db .select({ id: schema.members.id })