From 714d2a985ddff9c0dcb17d3df9afa6e9c16fd6eb Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 01:41:58 +0400 Subject: [PATCH] feat(bot): add conversational DM assistant flow --- apps/bot/src/anonymous-feedback.test.ts | 18 +- apps/bot/src/config.ts | 56 +- apps/bot/src/dm-assistant.test.ts | 504 ++++++++++++ apps/bot/src/dm-assistant.ts | 723 ++++++++++++++++++ apps/bot/src/i18n/locales/en.ts | 26 + apps/bot/src/i18n/locales/ru.ts | 26 + apps/bot/src/i18n/types.ts | 20 + apps/bot/src/index.ts | 47 +- apps/bot/src/miniapp-admin.test.ts | 1 + apps/bot/src/miniapp-admin.ts | 6 +- apps/bot/src/openai-chat-assistant.ts | 121 +++ .../src/telegram-pending-action-repository.ts | 4 + packages/application/src/index.ts | 4 + packages/config/src/env.ts | 7 + .../ports/src/telegram-pending-actions.ts | 5 +- 15 files changed, 1560 insertions(+), 8 deletions(-) create mode 100644 apps/bot/src/dm-assistant.test.ts create mode 100644 apps/bot/src/dm-assistant.ts create mode 100644 apps/bot/src/openai-chat-assistant.ts diff --git a/apps/bot/src/anonymous-feedback.test.ts b/apps/bot/src/anonymous-feedback.test.ts index f304783..2e72b3c 100644 --- a/apps/bot/src/anonymous-feedback.test.ts +++ b/apps/bot/src/anonymous-feedback.test.ts @@ -4,6 +4,7 @@ import type { AnonymousFeedbackService } from '@household/application' import { nowInstant, Temporal, type Instant } from '@household/domain' import type { HouseholdConfigurationRepository, + TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports' @@ -50,7 +51,13 @@ function anonUpdate(params: { } function createPromptRepository(): TelegramPendingActionRepository { - const store = new Map() + const store = new Map< + string, + { + action: TelegramPendingActionRecord['action'] + expiresAt: Instant | null + } + >() return { async upsertPendingAction(input) { @@ -305,7 +312,6 @@ describe('registerAnonymousFeedback', () => { }) }) - test('uses household locale for the posted anonymous note even when member locale differs', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] @@ -383,8 +389,12 @@ describe('registerAnonymousFeedback', () => { .filter((call) => call.method === 'sendMessage') .map((call) => call.payload as { text?: string }) - expect(sendMessagePayloads.some((payload) => payload.text?.startsWith('Анонимное сообщение по дому'))).toBe(true) - expect(sendMessagePayloads.some((payload) => payload.text?.startsWith('Anonymous household note'))).toBe(false) + expect( + sendMessagePayloads.some((payload) => payload.text?.startsWith('Анонимное сообщение по дому')) + ).toBe(true) + expect( + sendMessagePayloads.some((payload) => payload.text?.startsWith('Anonymous household note')) + ).toBe(false) }) test('rejects group usage and keeps feedback private', async () => { diff --git a/apps/bot/src/config.ts b/apps/bot/src/config.ts index d715fe8..0f7dc03 100644 --- a/apps/bot/src/config.ts +++ b/apps/bot/src/config.ts @@ -8,6 +8,7 @@ export interface BotRuntimeConfig { purchaseTopicIngestionEnabled: boolean financeCommandsEnabled: boolean anonymousFeedbackEnabled: boolean + assistantEnabled: boolean miniAppAllowedOrigins: readonly string[] miniAppAuthEnabled: boolean schedulerSharedSecret?: string @@ -16,6 +17,13 @@ export interface BotRuntimeConfig { openaiApiKey?: string parserModel: string purchaseParserModel: string + assistantModel: string + assistantTimeoutMs: number + assistantMemoryMaxTurns: number + assistantRateLimitBurst: number + assistantRateLimitBurstWindowMs: number + assistantRateLimitRolling: number + assistantRateLimitRollingWindowMs: number } function parsePort(raw: string | undefined): number { @@ -76,6 +84,19 @@ function parseOptionalCsv(value: string | undefined): readonly string[] { .filter(Boolean) } +function parsePositiveInteger(raw: string | undefined, fallback: number, key: string): number { + if (raw === undefined) { + return fallback + } + + const parsed = Number(raw) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid ${key} value: ${raw}`) + } + + return parsed +} + export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig { const databaseUrl = parseOptionalValue(env.DATABASE_URL) const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET) @@ -86,6 +107,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu const financeCommandsEnabled = databaseUrl !== undefined const anonymousFeedbackEnabled = databaseUrl !== undefined + const assistantEnabled = databaseUrl !== undefined const miniAppAuthEnabled = databaseUrl !== undefined const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0 const reminderJobsEnabled = @@ -100,13 +122,45 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu purchaseTopicIngestionEnabled, financeCommandsEnabled, anonymousFeedbackEnabled, + assistantEnabled, miniAppAllowedOrigins, miniAppAuthEnabled, schedulerOidcAllowedEmails, reminderJobsEnabled, parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini', purchaseParserModel: - env.PURCHASE_PARSER_MODEL?.trim() || env.PARSER_MODEL?.trim() || 'gpt-5-mini' + env.PURCHASE_PARSER_MODEL?.trim() || env.PARSER_MODEL?.trim() || 'gpt-5-mini', + assistantModel: env.ASSISTANT_MODEL?.trim() || 'gpt-5-mini', + assistantTimeoutMs: parsePositiveInteger( + env.ASSISTANT_TIMEOUT_MS, + 15_000, + 'ASSISTANT_TIMEOUT_MS' + ), + assistantMemoryMaxTurns: parsePositiveInteger( + env.ASSISTANT_MEMORY_MAX_TURNS, + 12, + 'ASSISTANT_MEMORY_MAX_TURNS' + ), + assistantRateLimitBurst: parsePositiveInteger( + env.ASSISTANT_RATE_LIMIT_BURST, + 5, + 'ASSISTANT_RATE_LIMIT_BURST' + ), + assistantRateLimitBurstWindowMs: parsePositiveInteger( + env.ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS, + 60_000, + 'ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS' + ), + assistantRateLimitRolling: parsePositiveInteger( + env.ASSISTANT_RATE_LIMIT_ROLLING, + 50, + 'ASSISTANT_RATE_LIMIT_ROLLING' + ), + assistantRateLimitRollingWindowMs: parsePositiveInteger( + env.ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS, + 86_400_000, + 'ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS' + ) } if (databaseUrl !== undefined) { diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts new file mode 100644 index 0000000..6e1d37d --- /dev/null +++ b/apps/bot/src/dm-assistant.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, test } from 'bun:test' + +import type { FinanceCommandService } from '@household/application' +import type { + HouseholdConfigurationRepository, + TelegramPendingActionRecord, + TelegramPendingActionRepository +} from '@household/ports' + +import { createTelegramBot } from './bot' +import { + createInMemoryAssistantConversationMemoryStore, + createInMemoryAssistantRateLimiter, + createInMemoryAssistantUsageTracker, + registerDmAssistant +} from './dm-assistant' + +function createTestBot() { + const bot = createTelegramBot('000000:test-token') + + bot.botInfo = { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot', + can_join_groups: true, + can_read_all_group_messages: false, + supports_inline_queries: false, + can_connect_to_business: false, + has_main_web_app: false, + has_topics_enabled: true, + allows_users_to_create_topics: false + } + + return bot +} + +function privateMessageUpdate(text: string) { + return { + update_id: 2001, + message: { + message_id: 55, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + text + } + } +} + +function privateCallbackUpdate(data: string) { + return { + update_id: 2002, + callback_query: { + id: 'callback-1', + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + chat_instance: 'instance-1', + data, + message: { + message_id: 77, + date: Math.floor(Date.now() / 1000), + chat: { + id: 123456, + type: 'private' + }, + text: 'placeholder' + } + } + } +} + +function createHouseholdRepository(): HouseholdConfigurationRepository { + const household = { + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-100123', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'en' as const + } + + return { + registerTelegramHouseholdChat: async () => ({ + status: 'existing', + household + }), + getTelegramHouseholdChat: async () => household, + getHouseholdChatByHouseholdId: async () => household, + bindHouseholdTopic: async () => { + throw new Error('not used') + }, + getHouseholdTopicBinding: async () => null, + findHouseholdTopicByTelegramContext: async () => null, + listHouseholdTopicBindings: async () => [], + listReminderTargets: async () => [], + upsertHouseholdJoinToken: async () => { + throw new Error('not used') + }, + getHouseholdJoinToken: async () => null, + getHouseholdByJoinToken: async () => null, + upsertPendingHouseholdMember: async () => { + throw new Error('not used') + }, + getPendingHouseholdMember: async () => null, + findPendingHouseholdMemberByTelegramUserId: async () => null, + ensureHouseholdMember: async () => { + throw new Error('not used') + }, + getHouseholdMember: async () => ({ + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'en', + rentShareWeight: 1, + isAdmin: true + }), + listHouseholdMembers: async () => [], + getHouseholdBillingSettings: async () => ({ + householdId: 'household-1', + settlementCurrency: 'GEL', + rentAmountMinor: 70000n, + rentCurrency: 'USD', + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + updateHouseholdBillingSettings: async () => { + throw new Error('not used') + }, + listHouseholdUtilityCategories: async () => [], + upsertHouseholdUtilityCategory: async () => { + throw new Error('not used') + }, + listHouseholdMembersByTelegramUserId: async () => [ + { + id: 'member-1', + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + preferredLocale: null, + householdDefaultLocale: 'en', + rentShareWeight: 1, + isAdmin: true + } + ], + listPendingHouseholdMembers: async () => [], + approvePendingHouseholdMember: async () => null, + updateHouseholdDefaultLocale: async () => household, + updateMemberPreferredLocale: async () => null, + promoteHouseholdAdmin: async () => null, + updateHouseholdMemberRentShareWeight: async () => null + } +} + +function createFinanceService(): FinanceCommandService { + return { + getMemberByTelegramUserId: async () => ({ + id: 'member-1', + telegramUserId: '123456', + displayName: 'Stan', + rentShareWeight: 1, + isAdmin: true + }), + getOpenCycle: async () => null, + ensureExpectedCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' + }), + getAdminCycleState: async () => ({ + cycle: null, + rentRule: null, + utilityBills: [] + }), + openCycle: async () => ({ + id: 'cycle-1', + period: '2026-03', + currency: 'GEL' + }), + closeCycle: async () => null, + setRent: async () => null, + addUtilityBill: async () => null, + updateUtilityBill: async () => null, + deleteUtilityBill: async () => false, + updatePurchase: async () => null, + deletePurchase: async () => false, + addPayment: async (_memberId, kind, amountArg, currencyArg) => ({ + paymentId: 'payment-1', + amount: { + amountMinor: (BigInt(amountArg.replace('.', '')) * 100n) / 100n, + currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD', + toMajorString: () => amountArg + } as never, + currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD', + period: '2026-03' + }), + updatePayment: async () => null, + deletePayment: async () => false, + generateDashboard: async () => ({ + period: '2026-03', + currency: 'GEL', + totalDue: { + toMajorString: () => '1000.00' + } as never, + totalPaid: { + toMajorString: () => '500.00' + } as never, + totalRemaining: { + toMajorString: () => '500.00' + } as never, + rentSourceAmount: { + currency: 'USD', + toMajorString: () => '700.00' + } as never, + rentDisplayAmount: { + toMajorString: () => '1890.00' + } as never, + rentFxRateMicros: null, + rentFxEffectiveDate: null, + members: [ + { + memberId: 'member-1', + displayName: 'Stan', + rentShare: { + amountMinor: 70000n, + currency: 'GEL', + toMajorString: () => '700.00' + } as never, + utilityShare: { + amountMinor: 10000n, + currency: 'GEL', + toMajorString: () => '100.00' + } as never, + purchaseOffset: { + amountMinor: 5000n, + currency: 'GEL', + toMajorString: () => '50.00', + add: () => ({ + amountMinor: 15000n, + currency: 'GEL', + toMajorString: () => '150.00' + }) + } as never, + netDue: { + toMajorString: () => '850.00' + } as never, + paid: { + toMajorString: () => '500.00' + } as never, + remaining: { + toMajorString: () => '350.00' + } as never, + explanations: [] + } + ], + ledger: [ + { + id: 'purchase-1', + kind: 'purchase' as const, + title: 'Soap', + memberId: 'member-1', + amount: { + toMajorString: () => '30.00' + } as never, + currency: 'GEL' as const, + displayAmount: { + toMajorString: () => '30.00' + } as never, + displayCurrency: 'GEL' as const, + fxRateMicros: null, + fxEffectiveDate: null, + actorDisplayName: 'Stan', + occurredAt: '2026-03-12T11:00:00.000Z', + paymentKind: null + } + ] + }), + generateStatement: async () => null + } +} + +function createPromptRepository(): TelegramPendingActionRepository { + let pending: TelegramPendingActionRecord | null = null + + return { + async upsertPendingAction(input) { + pending = input + return input + }, + async getPendingAction() { + return pending + }, + async clearPendingAction() { + pending = null + } + } +} + +describe('registerDmAssistant', () => { + test('replies with a conversational DM answer and records token usage', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const usageTracker = createInMemoryAssistantUsageTracker() + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond() { + return { + text: 'You still owe 350.00 GEL this cycle.', + usage: { + inputTokens: 100, + outputTokens: 25, + totalTokens: 125 + } + } + } + }, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository: createPromptRepository(), + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker + }) + + await bot.handleUpdate(privateMessageUpdate('How much do I still owe this month?') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 123456, + text: 'You still owe 350.00 GEL this cycle.' + } + }) + expect(usageTracker.listHouseholdUsage('household-1')).toEqual([ + { + householdId: 'household-1', + telegramUserId: '123456', + displayName: 'Stan', + requestCount: 1, + inputTokens: 100, + outputTokens: 25, + totalTokens: 125, + updatedAt: expect.any(String) + } + ]) + }) + + test('creates a payment confirmation proposal in DM', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + householdConfigurationRepository: createHouseholdRepository(), + promptRepository, + 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 paid the rent') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + text: 'I can record this rent payment: 700.00 GEL. Confirm or cancel below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm payment', + callback_data: expect.stringContaining('assistant_payment:confirm:') + }, + { + text: 'Cancel', + callback_data: expect.stringContaining('assistant_payment:cancel:') + } + ] + ] + } + }) + + const pending = await promptRepository.getPendingAction('123456', '123456') + expect(pending?.action).toBe('assistant_payment_confirmation') + expect(pending?.payload).toMatchObject({ + householdId: 'household-1', + memberId: 'member-1', + kind: 'rent', + amountMinor: '70000', + currency: 'GEL' + }) + }) + + test('confirms a pending payment proposal from DM callback', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + const repository = createHouseholdRepository() + + await promptRepository.upsertPendingAction({ + telegramUserId: '123456', + telegramChatId: '123456', + action: 'assistant_payment_confirmation', + payload: { + proposalId: 'proposal-1', + householdId: 'household-1', + memberId: 'member-1', + kind: 'rent', + amountMinor: '70000', + currency: 'GEL' + }, + expiresAt: null + }) + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + householdConfigurationRepository: repository, + promptRepository, + financeServiceForHousehold: () => createFinanceService(), + memoryStore: createInMemoryAssistantConversationMemoryStore(12), + rateLimiter: createInMemoryAssistantRateLimiter({ + burstLimit: 5, + burstWindowMs: 60_000, + rollingLimit: 50, + rollingWindowMs: 86_400_000 + }), + usageTracker: createInMemoryAssistantUsageTracker() + }) + + await bot.handleUpdate(privateCallbackUpdate('assistant_payment:confirm:proposal-1') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Recorded rent payment: 700.00 GEL' + } + }) + expect(calls[1]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: 123456, + message_id: 77, + text: 'Recorded rent payment: 700.00 GEL' + } + }) + expect(await promptRepository.getPendingAction('123456', '123456')).toBeNull() + }) +}) diff --git a/apps/bot/src/dm-assistant.ts b/apps/bot/src/dm-assistant.ts new file mode 100644 index 0000000..4a4815d --- /dev/null +++ b/apps/bot/src/dm-assistant.ts @@ -0,0 +1,723 @@ +import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application' +import { Money } from '@household/domain' +import type { Logger } from '@household/observability' +import type { + HouseholdConfigurationRepository, + 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' + +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 MEMORY_SUMMARY_MAX_CHARS = 1200 + +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[] +} + +interface PaymentProposalPayload { + proposalId: string + householdId: string + memberId: string + kind: 'rent' | 'utilities' + amountMinor: string + currency: 'GEL' | 'USD' +} + +function isPrivateChat(ctx: Context): boolean { + return ctx.chat?.type === 'private' +} + +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) +} + +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 parsePaymentProposalPayload( + payload: Record +): PaymentProposalPayload | null { + if ( + typeof payload.proposalId !== 'string' || + typeof payload.householdId !== 'string' || + typeof payload.memberId !== 'string' || + (payload.kind !== 'rent' && payload.kind !== 'utilities') || + typeof payload.amountMinor !== 'string' || + (payload.currency !== 'USD' && payload.currency !== 'GEL') + ) { + return null + } + + if (!/^[0-9]+$/.test(payload.amountMinor)) { + return null + } + + return { + proposalId: payload.proposalId, + householdId: payload.householdId, + memberId: payload.memberId, + kind: payload.kind, + amountMinor: payload.amountMinor, + currency: payload.currency + } +} + +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 maybeCreatePaymentProposal(input: { + rawText: string + householdId: string + memberId: string + financeService: FinanceCommandService + householdConfigurationRepository: HouseholdConfigurationRepository +}): Promise< + | { + status: 'no_intent' + } + | { + status: 'clarification' + } + | { + status: 'unsupported_currency' + } + | { + status: 'no_balance' + } + | { + status: 'proposal' + payload: PaymentProposalPayload + } +> { + const settings = await input.householdConfigurationRepository.getHouseholdBillingSettings( + input.householdId + ) + const parsed = parsePaymentConfirmationMessage(input.rawText, settings.settlementCurrency) + + if (!parsed.kind && parsed.reviewReason === 'intent_missing') { + return { + status: 'no_intent' + } + } + + if (!parsed.kind || parsed.reviewReason) { + return { + status: 'clarification' + } + } + + const dashboard = await input.financeService.generateDashboard() + if (!dashboard) { + return { + status: 'clarification' + } + } + + const memberLine = dashboard.members.find((line) => line.memberId === input.memberId) + if (!memberLine) { + return { + status: 'clarification' + } + } + + if (parsed.explicitAmount && parsed.explicitAmount.currency !== dashboard.currency) { + return { + status: 'unsupported_currency' + } + } + + const amount = + parsed.explicitAmount ?? + (parsed.kind === 'rent' + ? memberLine.rentShare + : memberLine.utilityShare.add(memberLine.purchaseOffset)) + + if (amount.amountMinor <= 0n) { + return { + status: 'no_balance' + } + } + + return { + status: 'proposal', + payload: { + proposalId: crypto.randomUUID(), + householdId: input.householdId, + memberId: input.memberId, + kind: parsed.kind, + amountMinor: amount.amountMinor.toString(), + currency: amount.currency + } + } +} + +export function registerDmAssistant(options: { + bot: Bot + assistant?: ConversationalAssistant + householdConfigurationRepository: HouseholdConfigurationRepository + 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.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 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 rateLimit = options.rateLimiter.consume(`${member.householdId}:${telegramUserId}`) + if (!rateLimit.allowed) { + await ctx.reply(t.rateLimited(formatRetryDelay(locale, rateLimit.retryAfterMs))) + return + } + + 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(telegramUserId, { + role: 'user', + text: ctx.msg.text + }) + options.memoryStore.appendTurn(telegramUserId, { + role: 'assistant', + text: proposalText + }) + + await ctx.reply(proposalText, { + reply_markup: paymentProposalReplyMarkup(locale, paymentProposal.payload.proposalId) + }) + return + } + + if (!options.assistant) { + await ctx.reply(t.unavailable) + return + } + + const memory = options.memoryStore.get(telegramUserId) + const householdContext = await buildHouseholdContext({ + householdId: member.householdId, + memberId: member.id, + memberDisplayName: member.displayName, + locale, + householdConfigurationRepository: options.householdConfigurationRepository, + financeService + }) + + try { + const reply = await options.assistant.respond({ + locale, + householdContext, + memorySummary: memory.summary, + recentTurns: memory.turns, + userMessage: ctx.msg.text + }) + + options.usageTracker.record({ + householdId: member.householdId, + telegramUserId, + displayName: member.displayName, + usage: reply.usage + }) + options.memoryStore.appendTurn(telegramUserId, { + role: 'user', + text: ctx.msg.text + }) + options.memoryStore.appendTurn(telegramUserId, { + role: 'assistant', + text: reply.text + }) + + options.logger?.info( + { + event: 'assistant.reply', + householdId: member.householdId, + telegramUserId, + inputTokens: reply.usage.inputTokens, + outputTokens: reply.usage.outputTokens, + totalTokens: reply.usage.totalTokens + }, + 'DM assistant reply generated' + ) + + await ctx.reply(reply.text) + } catch (error) { + options.logger?.error( + { + event: 'assistant.reply_failed', + householdId: member.householdId, + telegramUserId, + error + }, + 'DM assistant reply failed' + ) + await ctx.reply(t.unavailable) + } + }) +} diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index bd7a078..dec2813 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -112,6 +112,32 @@ export const enBotTranslations: BotTranslationCatalog = { hour: (count) => `${count} hour${count === 1 ? '' : 's'}`, minute: (count) => `${count} minute${count === 1 ? '' : 's'}` }, + assistant: { + unavailable: 'The assistant is temporarily unavailable. Try again in a moment.', + noHousehold: + 'I can help after your Telegram account is linked to a household. Open the household group and complete the join flow first.', + multipleHouseholds: + 'You belong to multiple households. Open the target household from its group until direct household selection is added.', + rateLimited: (retryDelay) => `Assistant rate limit reached. Try again ${retryDelay}.`, + retryInLessThanMinute: 'in less than a minute', + retryIn: (parts) => `in ${parts}`, + hour: (count) => `${count} hour${count === 1 ? '' : 's'}`, + minute: (count) => `${count} minute${count === 1 ? '' : 's'}`, + paymentProposal: (kind, amount, currency) => + `I can record this ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}. Confirm or cancel below.`, + paymentClarification: + 'I can help record that payment, but I need a clearer message. Mention whether it was rent or utilities, and include the amount if you did not pay the full current balance.', + paymentUnsupportedCurrency: + 'I can only auto-confirm payment proposals in the current household billing currency for now. Use /payment_add if you need a different currency.', + paymentNoBalance: 'There is no payable balance to confirm for that payment type right now.', + paymentConfirmButton: 'Confirm payment', + paymentCancelButton: 'Cancel', + paymentConfirmed: (kind, amount, currency) => + `Recorded ${kind === 'rent' ? 'rent' : 'utilities'} payment: ${amount} ${currency}`, + paymentCancelled: 'Payment proposal cancelled.', + paymentAlreadyHandled: 'That payment proposal was already handled.', + paymentUnavailable: 'That payment proposal is no longer available.' + }, finance: { useInGroup: 'Use this command inside a household group.', householdNotConfigured: 'Household is not configured for this chat yet. Run /setup first.', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index a3c8afb..ae858fe 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -115,6 +115,32 @@ export const ruBotTranslations: BotTranslationCatalog = { hour: (count) => `${count} ${count === 1 ? 'час' : count < 5 ? 'часа' : 'часов'}`, minute: (count) => `${count} ${count === 1 ? 'минуту' : count < 5 ? 'минуты' : 'минут'}` }, + assistant: { + unavailable: 'Ассистент сейчас недоступен. Попробуйте ещё раз чуть позже.', + noHousehold: + 'Я смогу помочь после того, как ваш Telegram-профиль будет привязан к дому. Сначала откройте группу дома и завершите вступление.', + multipleHouseholds: + 'Вы состоите в нескольких домах. Откройте нужный дом из его группы, пока прямой выбор дома ещё не добавлен.', + rateLimited: (retryDelay) => `Лимит сообщений ассистенту исчерпан. Попробуйте ${retryDelay}.`, + retryInLessThanMinute: 'меньше чем через минуту', + retryIn: (parts) => `через ${parts}`, + hour: (count) => `${count} ${count === 1 ? 'час' : count < 5 ? 'часа' : 'часов'}`, + minute: (count) => `${count} ${count === 1 ? 'минуту' : count < 5 ? 'минуты' : 'минут'}`, + paymentProposal: (kind, amount, currency) => + `Я могу записать эту оплату ${kind === 'rent' ? 'аренды' : 'коммуналки'}: ${amount} ${currency}. Подтвердите или отмените ниже.`, + paymentClarification: + 'Я могу помочь записать эту оплату, но сообщение нужно уточнить. Укажите, это аренда или коммуналка, и добавьте сумму, если вы оплатили не весь текущий остаток.', + paymentUnsupportedCurrency: + 'Пока я могу автоматически подтверждать оплаты только в текущей валюте дома. Для другой валюты используйте /payment_add.', + paymentNoBalance: 'Сейчас для этого типа оплаты нет суммы к подтверждению.', + paymentConfirmButton: 'Подтвердить оплату', + paymentCancelButton: 'Отменить', + paymentConfirmed: (kind, amount, currency) => + `Оплата ${kind === 'rent' ? 'аренды' : 'коммуналки'} сохранена: ${amount} ${currency}`, + paymentCancelled: 'Предложение оплаты отменено.', + paymentAlreadyHandled: 'Это предложение оплаты уже было обработано.', + paymentUnavailable: 'Это предложение оплаты уже недоступно.' + }, finance: { useInGroup: 'Используйте эту команду внутри группы дома.', householdNotConfigured: 'Для этого чата дом ещё не настроен. Сначала выполните /setup.', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 499db09..6a57886 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -120,6 +120,26 @@ export interface BotTranslationCatalog { hour: (count: number) => string minute: (count: number) => string } + assistant: { + unavailable: string + noHousehold: string + multipleHouseholds: string + rateLimited: (retryDelay: string) => string + retryInLessThanMinute: string + retryIn: (parts: string) => string + hour: (count: number) => string + minute: (count: number) => string + paymentProposal: (kind: 'rent' | 'utilities', amount: string, currency: string) => string + paymentClarification: string + paymentUnsupportedCurrency: string + paymentNoBalance: string + paymentConfirmButton: string + paymentCancelButton: string + paymentConfirmed: (kind: 'rent' | 'utilities', amount: string, currency: string) => string + paymentCancelled: string + paymentAlreadyHandled: string + paymentUnavailable: string + } finance: { useInGroup: string householdNotConfigured: string diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 1e44fff..7d71fc2 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -21,10 +21,17 @@ import { import { configureLogger, getLogger } from '@household/observability' import { registerAnonymousFeedback } from './anonymous-feedback' +import { + createInMemoryAssistantConversationMemoryStore, + createInMemoryAssistantRateLimiter, + createInMemoryAssistantUsageTracker, + registerDmAssistant +} from './dm-assistant' import { createFinanceCommandsService } from './finance-commands' import { createTelegramBot } from './bot' import { getBotRuntimeConfig } from './config' import { registerHouseholdSetupCommands } from './household-setup' +import { createOpenAiChatAssistant } from './openai-chat-assistant' import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter' import { createPurchaseMessageRepository, @@ -100,9 +107,24 @@ const localePreferenceService = householdConfigurationRepositoryClient ? createLocalePreferenceService(householdConfigurationRepositoryClient.repository) : null const telegramPendingActionRepositoryClient = - runtime.databaseUrl && runtime.anonymousFeedbackEnabled + runtime.databaseUrl && (runtime.anonymousFeedbackEnabled || runtime.assistantEnabled) ? createDbTelegramPendingActionRepository(runtime.databaseUrl!) : null +const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore( + runtime.assistantMemoryMaxTurns +) +const assistantRateLimiter = createInMemoryAssistantRateLimiter({ + burstLimit: runtime.assistantRateLimitBurst, + burstWindowMs: runtime.assistantRateLimitBurstWindowMs, + rollingLimit: runtime.assistantRateLimitRolling, + rollingWindowMs: runtime.assistantRateLimitRollingWindowMs +}) +const assistantUsageTracker = createInMemoryAssistantUsageTracker() +const conversationalAssistant = createOpenAiChatAssistant( + runtime.openaiApiKey, + runtime.assistantModel, + runtime.assistantTimeoutMs +) const anonymousFeedbackRepositoryClients = new Map< string, ReturnType @@ -339,6 +361,28 @@ if ( ) } +if ( + runtime.assistantEnabled && + householdConfigurationRepositoryClient && + telegramPendingActionRepositoryClient +) { + registerDmAssistant({ + bot, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + promptRepository: telegramPendingActionRepositoryClient.repository, + financeServiceForHousehold, + memoryStore: assistantMemoryStore, + rateLimiter: assistantRateLimiter, + usageTracker: assistantUsageTracker, + ...(conversationalAssistant + ? { + assistant: conversationalAssistant + } + : {}), + logger: getLogger('dm-assistant') + }) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, @@ -392,6 +436,7 @@ const server = createBotWebhookServer({ botToken: runtime.telegramBotToken, onboardingService: householdOnboardingService, miniAppAdminService: miniAppAdminService!, + assistantUsageTracker, logger: getLogger('miniapp-admin') }) : undefined, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index e5860c4..29b154e 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -397,6 +397,7 @@ describe('createMiniAppSettingsHandler', () => { } ], categories: [], + assistantUsage: [], members: [ { id: 'member-123456', diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index ddd9c10..218a040 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -2,6 +2,7 @@ import type { HouseholdOnboardingService, MiniAppAdminService } from '@household import type { Logger } from '@household/observability' import type { HouseholdBillingSettingsRecord } from '@household/ports' import type { MiniAppSessionResult } from './miniapp-auth' +import type { AssistantUsageTracker } from './dm-assistant' import { allowedMiniAppOrigin, @@ -342,6 +343,7 @@ export function createMiniAppSettingsHandler(options: { botToken: string onboardingService: HouseholdOnboardingService miniAppAdminService: MiniAppAdminService + assistantUsageTracker?: AssistantUsageTracker logger?: Logger }): { handler: (request: Request) => Promise @@ -386,7 +388,9 @@ export function createMiniAppSettingsHandler(options: { settings: serializeBillingSettings(result.settings), topics: result.topics, categories: result.categories, - members: result.members + members: result.members, + assistantUsage: + options.assistantUsageTracker?.listHouseholdUsage(member.householdId) ?? [] }, 200, origin diff --git a/apps/bot/src/openai-chat-assistant.ts b/apps/bot/src/openai-chat-assistant.ts new file mode 100644 index 0000000..a80c690 --- /dev/null +++ b/apps/bot/src/openai-chat-assistant.ts @@ -0,0 +1,121 @@ +export interface AssistantUsage { + inputTokens: number + outputTokens: number + totalTokens: number +} + +export interface AssistantReply { + text: string + usage: AssistantUsage +} + +export interface ConversationalAssistant { + respond(input: { + locale: 'en' | 'ru' + householdContext: string + memorySummary: string | null + recentTurns: readonly { + role: 'user' | 'assistant' + text: string + }[] + userMessage: string + }): Promise +} + +interface OpenAiResponsePayload { + output_text?: string + usage?: { + input_tokens?: number + output_tokens?: number + total_tokens?: number + } +} + +const ASSISTANT_SYSTEM_PROMPT = [ + 'You are Kojori, a household finance assistant for one specific household.', + 'Stay within the provided household context and recent conversation context.', + 'Do not invent balances, members, billing periods, or completed actions.', + 'If the user asks you to mutate household state, do not claim the action is complete unless the system explicitly says it was confirmed and saved.', + 'For unsupported writes, explain the limitation briefly and suggest the explicit command or confirmation flow.', + 'Prefer concise, practical answers.', + 'Reply in the user language inferred from the latest user message and locale context.' +].join(' ') + +export function createOpenAiChatAssistant( + apiKey: string | undefined, + model: string, + timeoutMs: number +): ConversationalAssistant | undefined { + if (!apiKey) { + return undefined + } + + return { + async respond(input) { + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(), timeoutMs) + + try { + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + signal: abortController.signal, + headers: { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json' + }, + body: JSON.stringify({ + model, + input: [ + { + role: 'system', + content: ASSISTANT_SYSTEM_PROMPT + }, + { + role: 'system', + content: [ + `User locale: ${input.locale}`, + 'Bounded household context:', + input.householdContext, + input.memorySummary ? `Conversation summary:\n${input.memorySummary}` : null, + input.recentTurns.length > 0 + ? [ + 'Recent conversation turns:', + ...input.recentTurns.map((turn) => `${turn.role}: ${turn.text}`) + ].join('\n') + : null + ] + .filter(Boolean) + .join('\n\n') + }, + { + role: 'user', + content: input.userMessage + } + ] + }) + }) + + if (!response.ok) { + throw new Error(`Assistant request failed with status ${response.status}`) + } + + const payload = (await response.json()) as OpenAiResponsePayload + const text = payload.output_text?.trim() + if (!text) { + throw new Error('Assistant response did not contain text') + } + + return { + text, + usage: { + inputTokens: payload.usage?.input_tokens ?? 0, + outputTokens: payload.usage?.output_tokens ?? 0, + totalTokens: payload.usage?.total_tokens ?? 0 + } + } + } finally { + clearTimeout(timeout) + } + } + } +} diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index 9996cb8..faf9fe8 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -13,6 +13,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType { return raw } + if (raw === 'assistant_payment_confirmation') { + return raw + } + throw new Error(`Unexpected telegram pending action type: ${raw}`) } diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index d4f4555..a3ea663 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -36,3 +36,7 @@ export { type PaymentConfirmationService, type PaymentConfirmationSubmitResult } from './payment-confirmation-service' +export { + parsePaymentConfirmationMessage, + type ParsedPaymentConfirmation +} from './payment-confirmation-parser' diff --git a/packages/config/src/env.ts b/packages/config/src/env.ts index 3c34d34..1cc4631 100644 --- a/packages/config/src/env.ts +++ b/packages/config/src/env.ts @@ -33,6 +33,13 @@ const server = { OPENAI_API_KEY: z.string().min(1).optional(), PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'), PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'), + ASSISTANT_MODEL: z.string().min(1).default('gpt-5-mini'), + ASSISTANT_TIMEOUT_MS: z.coerce.number().int().positive().default(15000), + ASSISTANT_MEMORY_MAX_TURNS: z.coerce.number().int().positive().default(12), + ASSISTANT_RATE_LIMIT_BURST: z.coerce.number().int().positive().default(5), + ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS: z.coerce.number().int().positive().default(60000), + ASSISTANT_RATE_LIMIT_ROLLING: z.coerce.number().int().positive().default(50), + ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS: z.coerce.number().int().positive().default(86400000), SCHEDULER_SHARED_SECRET: z.string().min(1).optional() } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 011c05d..90134da 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -1,6 +1,9 @@ import type { Instant } from '@household/domain' -export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const +export const TELEGRAM_PENDING_ACTION_TYPES = [ + 'anonymous_feedback', + 'assistant_payment_confirmation' +] as const export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]