import type { FinanceCommandService } from '@household/application' import { instantFromEpochSeconds, Money } from '@household/domain' import type { Logger } from '@household/observability' import type { HouseholdConfigurationRepository, ProcessedBotMessageRepository, TelegramPendingActionRepository } from '@household/ports' 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 { maybeCreatePaymentProposal, parsePaymentProposalPayload } from './payment-proposals' import type { PurchaseMessageIngestionRepository, PurchaseProposalActionResult, PurchaseTopicRecord } from './purchase-topic-ingestion' import { startTypingIndicator } from './telegram-chat-action' import { stripExplicitBotMention } from './telegram-mentions' 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 GROUP_ASSISTANT_MESSAGE_SOURCE = 'telegram-group-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' text: string } interface AssistantConversationState { summary: string | null turns: AssistantConversationTurn[] } export interface AssistantConversationMemoryStore { get(key: string): AssistantConversationState appendTurn(key: string, turn: AssistantConversationTurn): AssistantConversationState } export interface AssistantRateLimitResult { allowed: boolean retryAfterMs: number } export interface AssistantRateLimiter { consume(key: string): AssistantRateLimitResult } export interface AssistantUsageSnapshot { householdId: string telegramUserId: string displayName: string requestCount: number inputTokens: number outputTokens: number totalTokens: number updatedAt: string } export interface AssistantUsageTracker { record(input: { householdId: string telegramUserId: string displayName: string usage: AssistantReply['usage'] }): void listHouseholdUsage(householdId: string): readonly AssistantUsageSnapshot[] } type PurchaseActionResult = Extract< PurchaseProposalActionResult, { status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' } > function describeError(error: unknown): { errorMessage?: string errorName?: string } { if (error instanceof Error) { return { errorMessage: error.message, errorName: error.name } } if (typeof error === 'string') { return { errorMessage: error } } return {} } function isPrivateChat(ctx: Context): boolean { return ctx.chat?.type === 'private' } function isGroupChat(ctx: Context): boolean { return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup' } function isCommandMessage(ctx: Context): boolean { return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/') } function summarizeTurns( summary: string | null, turns: readonly AssistantConversationTurn[] ): string { const next = [summary, ...turns.map((turn) => `${turn.role}: ${turn.text}`)] .filter(Boolean) .join('\n') return next.length <= MEMORY_SUMMARY_MAX_CHARS ? next : next.slice(next.length - MEMORY_SUMMARY_MAX_CHARS) } function conversationMemoryKey(input: { telegramUserId: string telegramChatId: string isPrivateChat: boolean }): string { return input.isPrivateChat ? input.telegramUserId : `group:${input.telegramChatId}:${input.telegramUserId}` } export function createInMemoryAssistantConversationMemoryStore( maxTurns: number ): AssistantConversationMemoryStore { const memory = new Map() return { get(key) { return memory.get(key) ?? { summary: null, turns: [] } }, appendTurn(key, turn) { const current = memory.get(key) ?? { summary: null, turns: [] } const nextTurns = [...current.turns, turn] if (nextTurns.length <= maxTurns) { const nextState = { summary: current.summary, turns: nextTurns } memory.set(key, nextState) return nextState } const overflowCount = nextTurns.length - maxTurns const overflow = nextTurns.slice(0, overflowCount) const retained = nextTurns.slice(overflowCount) const nextState = { summary: summarizeTurns(current.summary, overflow), turns: retained } memory.set(key, nextState) return nextState } } } export function createInMemoryAssistantRateLimiter(config: { burstLimit: number burstWindowMs: number rollingLimit: number rollingWindowMs: number }): AssistantRateLimiter { const timestamps = new Map() return { consume(key) { const now = Date.now() const events = (timestamps.get(key) ?? []).filter( (timestamp) => now - timestamp < config.rollingWindowMs ) const burstEvents = events.filter((timestamp) => now - timestamp < config.burstWindowMs) if (burstEvents.length >= config.burstLimit) { const oldestBurstEvent = burstEvents[0] ?? now return { allowed: false, retryAfterMs: Math.max(1, config.burstWindowMs - (now - oldestBurstEvent)) } } if (events.length >= config.rollingLimit) { const oldestEvent = events[0] ?? now return { allowed: false, retryAfterMs: Math.max(1, config.rollingWindowMs - (now - oldestEvent)) } } events.push(now) timestamps.set(key, events) return { allowed: true, retryAfterMs: 0 } } } } export function createInMemoryAssistantUsageTracker(): AssistantUsageTracker { const usage = new Map() return { record(input) { const key = `${input.householdId}:${input.telegramUserId}` const current = usage.get(key) usage.set(key, { householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.displayName, requestCount: (current?.requestCount ?? 0) + 1, inputTokens: (current?.inputTokens ?? 0) + input.usage.inputTokens, outputTokens: (current?.outputTokens ?? 0) + input.usage.outputTokens, totalTokens: (current?.totalTokens ?? 0) + input.usage.totalTokens, updatedAt: new Date().toISOString() }) }, listHouseholdUsage(householdId) { return [...usage.values()] .filter((entry) => entry.householdId === householdId) .sort((left, right) => right.totalTokens - left.totalTokens) } } } function formatRetryDelay(locale: BotLocale, retryAfterMs: number): string { const t = getBotTranslations(locale).assistant const roundedMinutes = Math.ceil(retryAfterMs / 60_000) if (roundedMinutes <= 1) { return t.retryInLessThanMinute } const hours = Math.floor(roundedMinutes / 60) const minutes = roundedMinutes % 60 const parts = [hours > 0 ? t.hour(hours) : null, minutes > 0 ? t.minute(minutes) : null].filter( Boolean ) return t.retryIn(parts.join(' ')) } function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) { const t = getBotTranslations(locale).assistant return { inline_keyboard: [ [ { text: t.paymentConfirmButton, callback_data: `${ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX}${proposalId}` }, { text: t.paymentCancelButton, callback_data: `${ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX}${proposalId}` } ] ] } } 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 formatAssistantLedger( dashboard: NonNullable>> ) { const recentLedger = dashboard.ledger.slice(-5) if (recentLedger.length === 0) { return 'No recent ledger activity.' } return recentLedger .map( (entry) => `- ${entry.kind}: ${entry.title} ${entry.displayAmount.toMajorString()} ${entry.displayCurrency} by ${entry.actorDisplayName ?? 'unknown'} on ${entry.occurredAt ?? 'unknown date'}` ) .join('\n') } async function buildHouseholdContext(input: { householdId: string memberId: string memberDisplayName: string locale: BotLocale householdConfigurationRepository: HouseholdConfigurationRepository financeService: FinanceCommandService }): Promise { const [household, settings, dashboard] = await Promise.all([ input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId), input.financeService.generateDashboard() ]) const lines = [ `Household: ${household?.householdName ?? input.householdId}`, `User display name: ${input.memberDisplayName}`, `Locale: ${input.locale}`, `Settlement currency: ${settings.settlementCurrency}`, `Timezone: ${settings.timezone}`, `Current billing cycle: ${dashboard?.period ?? 'not available'}` ] if (!dashboard) { lines.push('No current dashboard data is available yet.') return lines.join('\n') } const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) if (memberLine) { lines.push( `Member balance: due ${memberLine.netDue.toMajorString()} ${dashboard.currency}, paid ${memberLine.paid.toMajorString()} ${dashboard.currency}, remaining ${memberLine.remaining.toMajorString()} ${dashboard.currency}` ) lines.push( `Rent share: ${memberLine.rentShare.toMajorString()} ${dashboard.currency}; utility share: ${memberLine.utilityShare.toMajorString()} ${dashboard.currency}; purchase offset: ${memberLine.purchaseOffset.toMajorString()} ${dashboard.currency}` ) } lines.push( `Household total remaining: ${dashboard.totalRemaining.toMajorString()} ${dashboard.currency}` ) lines.push(`Recent ledger activity:\n${formatAssistantLedger(dashboard)}`) return lines.join('\n') } async function replyWithAssistant(input: { ctx: Context assistant: ConversationalAssistant | undefined householdId: string memberId: string memberDisplayName: string telegramUserId: string telegramChatId: string locale: BotLocale userMessage: string householdConfigurationRepository: HouseholdConfigurationRepository financeService: FinanceCommandService memoryStore: AssistantConversationMemoryStore usageTracker: AssistantUsageTracker logger: Logger | undefined }): Promise { const t = getBotTranslations(input.locale).assistant if (!input.assistant) { await input.ctx.reply(t.unavailable) return } const memoryKey = conversationMemoryKey({ telegramUserId: input.telegramUserId, telegramChatId: input.telegramChatId, isPrivateChat: isPrivateChat(input.ctx) }) const memory = input.memoryStore.get(memoryKey) const typingIndicator = startTypingIndicator(input.ctx) const assistantStartedAt = Date.now() let stage: 'household_context' | 'assistant_response' = 'household_context' let contextBuildMs: number | null = null let assistantResponseMs: number | null = null try { const contextStartedAt = Date.now() const householdContext = await buildHouseholdContext({ householdId: input.householdId, memberId: input.memberId, memberDisplayName: input.memberDisplayName, locale: input.locale, householdConfigurationRepository: input.householdConfigurationRepository, financeService: input.financeService }) contextBuildMs = Date.now() - contextStartedAt stage = 'assistant_response' const assistantResponseStartedAt = Date.now() const reply = await input.assistant.respond({ locale: input.locale, householdContext, memorySummary: memory.summary, recentTurns: memory.turns, userMessage: input.userMessage }) assistantResponseMs = Date.now() - assistantResponseStartedAt input.usageTracker.record({ householdId: input.householdId, telegramUserId: input.telegramUserId, displayName: input.memberDisplayName, usage: reply.usage }) input.memoryStore.appendTurn(memoryKey, { role: 'user', text: input.userMessage }) input.memoryStore.appendTurn(memoryKey, { role: 'assistant', text: reply.text }) input.logger?.info( { event: 'assistant.reply', householdId: input.householdId, telegramUserId: input.telegramUserId, contextBuildMs, assistantResponseMs, totalDurationMs: Date.now() - assistantStartedAt, householdContextChars: householdContext.length, recentTurnsCount: memory.turns.length, memorySummaryChars: memory.summary?.length ?? 0, inputTokens: reply.usage.inputTokens, outputTokens: reply.usage.outputTokens, totalTokens: reply.usage.totalTokens }, 'Assistant reply generated' ) await input.ctx.reply(reply.text) } catch (error) { input.logger?.error( { event: 'assistant.reply_failed', householdId: input.householdId, telegramUserId: input.telegramUserId, stage, contextBuildMs, assistantResponseMs, totalDurationMs: Date.now() - assistantStartedAt, ...describeError(error), error }, 'Assistant reply failed' ) await input.ctx.reply(t.unavailable) } finally { typingIndicator.stop() } } export function registerDmAssistant(options: { bot: Bot assistant?: ConversationalAssistant purchaseRepository?: PurchaseMessageIngestionRepository purchaseInterpreter?: PurchaseMessageInterpreter householdConfigurationRepository: HouseholdConfigurationRepository messageProcessingRepository?: ProcessedBotMessageRepository promptRepository: TelegramPendingActionRepository financeServiceForHousehold: (householdId: string) => FinanceCommandService memoryStore: AssistantConversationMemoryStore rateLimiter: AssistantRateLimiter usageTracker: AssistantUsageTracker logger?: Logger }): void { options.bot.callbackQuery( new RegExp(`^${ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { if (!isPrivateChat(ctx)) { await ctx.answerCallbackQuery({ text: getBotTranslations('en').assistant.paymentUnavailable, show_alert: true }) return } const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() const proposalId = ctx.match[1] if (!telegramUserId || !telegramChatId || !proposalId) { await ctx.answerCallbackQuery({ text: getBotTranslations('en').assistant.paymentUnavailable, show_alert: true }) return } const pending = await options.promptRepository.getPendingAction( telegramChatId, telegramUserId ) const locale = await resolveReplyLocale({ ctx, repository: options.householdConfigurationRepository }) const t = getBotTranslations(locale).assistant const payload = pending?.action === ASSISTANT_PAYMENT_ACTION ? parsePaymentProposalPayload(pending.payload) : null if (!payload || payload.proposalId !== proposalId) { await ctx.answerCallbackQuery({ text: t.paymentUnavailable, show_alert: true }) return } const amount = Money.fromMinor(BigInt(payload.amountMinor), payload.currency) const result = await options .financeServiceForHousehold(payload.householdId) .addPayment(payload.memberId, payload.kind, amount.toMajorString(), amount.currency) await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) if (!result) { await ctx.answerCallbackQuery({ text: t.paymentNoBalance, show_alert: true }) return } await ctx.answerCallbackQuery({ text: t.paymentConfirmed(payload.kind, result.amount.toMajorString(), result.currency) }) if (ctx.msg) { await ctx.editMessageText( t.paymentConfirmed(payload.kind, result.amount.toMajorString(), result.currency), { reply_markup: { inline_keyboard: [] } } ) } } ) options.bot.callbackQuery( new RegExp(`^${ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => { if (!isPrivateChat(ctx)) { await ctx.answerCallbackQuery({ text: getBotTranslations('en').assistant.paymentUnavailable, show_alert: true }) return } const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() const proposalId = ctx.match[1] if (!telegramUserId || !telegramChatId || !proposalId) { await ctx.answerCallbackQuery({ text: getBotTranslations('en').assistant.paymentUnavailable, show_alert: true }) return } const pending = await options.promptRepository.getPendingAction( telegramChatId, telegramUserId ) const locale = await resolveReplyLocale({ ctx, repository: options.householdConfigurationRepository }) const t = getBotTranslations(locale).assistant const payload = pending?.action === ASSISTANT_PAYMENT_ACTION ? parsePaymentProposalPayload(pending.payload) : null if (!payload || payload.proposalId !== proposalId) { await ctx.answerCallbackQuery({ text: t.paymentAlreadyHandled, show_alert: true }) return } await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId) await ctx.answerCallbackQuery({ text: t.paymentCancelled }) if (ctx.msg) { await ctx.editMessageText(t.paymentCancelled, { reply_markup: { inline_keyboard: [] } }) } } ) 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() return } const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() if (!telegramUserId || !telegramChatId) { await next() return } const memoryKey = conversationMemoryKey({ telegramUserId, telegramChatId, isPrivateChat: true }) const memberships = await options.householdConfigurationRepository.listHouseholdMembersByTelegramUserId( telegramUserId ) const locale = await resolveReplyLocale({ ctx, repository: options.householdConfigurationRepository }) const t = getBotTranslations(locale).assistant if (memberships.length === 0) { await ctx.reply(t.noHousehold) return } if (memberships.length > 1) { await ctx.reply(t.multipleHouseholds) return } const member = memberships[0]! const updateId = ctx.update.update_id?.toString() const dedupeClaim = options.messageProcessingRepository && typeof updateId === 'string' ? { repository: options.messageProcessingRepository, updateId } : null if (dedupeClaim) { const claim = await dedupeClaim.repository.claimMessage({ householdId: member.householdId, source: DM_ASSISTANT_MESSAGE_SOURCE, sourceMessageKey: dedupeClaim.updateId }) if (!claim.claimed) { options.logger?.info( { event: 'assistant.duplicate_update', householdId: member.householdId, telegramUserId, updateId: dedupeClaim.updateId }, 'Duplicate DM assistant update ignored' ) return } } try { const rateLimit = options.rateLimiter.consume(`${member.householdId}:${telegramUserId}`) if (!rateLimit.allowed) { await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs))) 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), null ) : purchaseResult.status === 'clarification_needed' ? buildPurchaseClarificationText(locale, purchaseResult) : getBotTranslations(locale).purchase.parseFailed options.memoryStore.appendTurn(memoryKey, { role: 'user', text: ctx.msg.text }) options.memoryStore.appendTurn(memoryKey, { 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, householdId: member.householdId, memberId: member.id, financeService, householdConfigurationRepository: options.householdConfigurationRepository }) if (paymentProposal.status === 'clarification') { await ctx.reply(t.paymentClarification) return } if (paymentProposal.status === 'unsupported_currency') { await ctx.reply(t.paymentUnsupportedCurrency) return } if (paymentProposal.status === 'no_balance') { await ctx.reply(t.paymentNoBalance) return } if (paymentProposal.status === 'proposal') { await options.promptRepository.upsertPendingAction({ telegramUserId, telegramChatId, action: ASSISTANT_PAYMENT_ACTION, payload: { ...paymentProposal.payload }, expiresAt: null }) const amount = Money.fromMinor( BigInt(paymentProposal.payload.amountMinor), paymentProposal.payload.currency ) const proposalText = t.paymentProposal( paymentProposal.payload.kind, amount.toMajorString(), amount.currency ) options.memoryStore.appendTurn(memoryKey, { role: 'user', text: ctx.msg.text }) options.memoryStore.appendTurn(memoryKey, { role: 'assistant', text: proposalText }) await ctx.reply(proposalText, { reply_markup: paymentProposalReplyMarkup(locale, paymentProposal.payload.proposalId) }) return } await replyWithAssistant({ ctx, assistant: options.assistant, householdId: member.householdId, memberId: member.id, memberDisplayName: member.displayName, telegramUserId, telegramChatId, locale, userMessage: ctx.msg.text, householdConfigurationRepository: options.householdConfigurationRepository, financeService, memoryStore: options.memoryStore, usageTracker: options.usageTracker, logger: options.logger }) } catch (error) { if (dedupeClaim) { await dedupeClaim.repository.releaseMessage({ householdId: member.householdId, source: DM_ASSISTANT_MESSAGE_SOURCE, sourceMessageKey: dedupeClaim.updateId }) } throw error } }) options.bot.on('message:text', async (ctx, next) => { if (!isGroupChat(ctx) || isCommandMessage(ctx)) { await next() return } const mention = stripExplicitBotMention(ctx) if (!mention || mention.strippedText.length === 0) { await next() return } const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() if (!telegramUserId || !telegramChatId) { await next() return } const household = await options.householdConfigurationRepository.getTelegramHouseholdChat(telegramChatId) if (!household) { await next() return } const member = await options.householdConfigurationRepository.getHouseholdMember( household.householdId, telegramUserId ) if (!member) { await next() return } const locale = member.preferredLocale ?? household.defaultLocale ?? 'en' const rateLimit = options.rateLimiter.consume(`${household.householdId}:${telegramUserId}`) const t = getBotTranslations(locale).assistant if (!rateLimit.allowed) { await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs))) return } const updateId = ctx.update.update_id?.toString() const dedupeClaim = options.messageProcessingRepository && typeof updateId === 'string' ? { repository: options.messageProcessingRepository, updateId } : null if (dedupeClaim) { const claim = await dedupeClaim.repository.claimMessage({ householdId: household.householdId, source: GROUP_ASSISTANT_MESSAGE_SOURCE, sourceMessageKey: dedupeClaim.updateId }) if (!claim.claimed) { options.logger?.info( { event: 'assistant.duplicate_update', householdId: household.householdId, telegramUserId, updateId: dedupeClaim.updateId }, 'Duplicate group assistant mention ignored' ) return } } try { await replyWithAssistant({ ctx, assistant: options.assistant, householdId: household.householdId, memberId: member.id, memberDisplayName: member.displayName, telegramUserId, telegramChatId, locale, userMessage: mention.strippedText, householdConfigurationRepository: options.householdConfigurationRepository, financeService: options.financeServiceForHousehold(household.householdId), memoryStore: options.memoryStore, usageTracker: options.usageTracker, logger: options.logger }) } catch (error) { if (dedupeClaim) { await dedupeClaim.repository.releaseMessage({ householdId: household.householdId, source: GROUP_ASSISTANT_MESSAGE_SOURCE, sourceMessageKey: dedupeClaim.updateId }) } throw error } }) }