From 4e7400e90826de01e255375db5b71595f3f795e6 Mon Sep 17 00:00:00 2001 From: whekin Date: Thu, 12 Mar 2026 03:22:43 +0400 Subject: [PATCH] feat(bot): add configurable household assistant behavior --- apps/bot/src/dm-assistant.test.ts | 267 +- apps/bot/src/dm-assistant.ts | 187 +- apps/bot/src/miniapp-admin.test.ts | 20 + apps/bot/src/miniapp-admin.ts | 42 +- apps/bot/src/openai-purchase-interpreter.ts | 15 +- apps/bot/src/purchase-topic-ingestion.ts | 40 +- apps/bot/src/server.test.ts | 26 +- apps/miniapp/src/App.tsx | 32 +- apps/miniapp/src/demo/miniapp-demo.ts | 5 + apps/miniapp/src/i18n.ts | 19 + apps/miniapp/src/miniapp-api.ts | 25 +- apps/miniapp/src/screens/house-screen.tsx | 48 +- .../src/household-config-repository.ts | 62 + .../src/miniapp-admin-service.test.ts | 20 + .../application/src/miniapp-admin-service.ts | 154 +- packages/db/drizzle-checksums.json | 3 +- packages/db/drizzle/0018_nimble_kojori.sql | 2 + packages/db/drizzle/meta/0018_snapshot.json | 3234 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 2 + packages/ports/src/household-config.ts | 12 + packages/ports/src/index.ts | 1 + 22 files changed, 4127 insertions(+), 96 deletions(-) create mode 100644 packages/db/drizzle/0018_nimble_kojori.sql create mode 100644 packages/db/drizzle/meta/0018_snapshot.json diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index 33ac94a..06d5c3e 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -59,7 +59,12 @@ function privateMessageUpdate(text: string) { } } -function topicMentionUpdate(text: string) { +function topicMessageUpdate( + text: string, + options?: { + replyToBot?: boolean + } +) { return { update_id: 3001, message: { @@ -77,11 +82,34 @@ function topicMentionUpdate(text: string) { first_name: 'Stan', language_code: 'en' }, - text + text, + ...(options?.replyToBot + ? { + reply_to_message: { + message_id: 87, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + from: { + id: 999000, + is_bot: true, + first_name: 'Household Test Bot', + username: 'household_test_bot' + }, + text: 'previous bot reply' + } + } + : {}) } } } +function topicMentionUpdate(text: string) { + return topicMessageUpdate(text) +} + function privateCallbackUpdate(data: string) { return { update_id: 2002, @@ -1212,6 +1240,241 @@ Confirm or cancel below.`, }) }) + test('stays silent for regular group chatter when the bot is not addressed', 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: -100123, + type: 'supergroup' + }, + 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: 'I should not speak here.', + usage: { + inputTokens: 12, + outputTokens: 5, + totalTokens: 17 + } + } + } + }, + purchaseRepository: createPurchaseRepository(), + purchaseInterpreter: async () => null, + 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(topicMessageUpdate('Dima is joking with Stas again') as never) + + expect(assistantCalls).toBe(0) + expect(calls).toHaveLength(0) + }) + + test('creates a purchase proposal in a household topic without an explicit mention', async () => { + const bot = createTestBot() + const calls: Array<{ method: string; payload: unknown }> = [] + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: calls.length, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond() { + return { + text: 'fallback', + usage: { + inputTokens: 10, + outputTokens: 2, + totalTokens: 12 + } + } + } + }, + purchaseRepository: createPurchaseRepository(), + purchaseInterpreter: async () => null, + 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(topicMessageUpdate('I bought a door handle for 30 lari') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123, + message_thread_id: 777, + text: expect.stringContaining('door handle - 30.00 GEL'), + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Confirm', + callback_data: 'assistant_purchase:confirm:purchase-1' + }, + { + text: 'Cancel', + callback_data: 'assistant_purchase:cancel:purchase-1' + } + ] + ] + } + } + }) + }) + + test('replies when a household member answers the bot message in a topic', 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: -100123, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerDmAssistant({ + bot, + assistant: { + async respond(input) { + assistantCalls += 1 + expect(input.userMessage).toBe('tell me a joke') + return { + text: 'Rent is still due on the 20th.', + usage: { + inputTokens: 17, + outputTokens: 8, + totalTokens: 25 + } + } + } + }, + purchaseRepository: createPurchaseRepository(), + purchaseInterpreter: async () => null, + 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( + topicMessageUpdate('tell me a joke', { + replyToBot: true + }) as never + ) + + expect(assistantCalls).toBe(1) + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + method: 'sendChatAction', + payload: { + chat_id: -100123, + action: 'typing', + message_thread_id: 777 + } + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123, + message_thread_id: 777, + text: 'Rent is still due on the 20th.' + } + }) + }) + 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 fc77ea9..544d1d1 100644 --- a/apps/bot/src/dm-assistant.ts +++ b/apps/bot/src/dm-assistant.ts @@ -123,6 +123,15 @@ function isCommandMessage(ctx: Context): boolean { return typeof ctx.msg?.text === 'string' && ctx.msg.text.trim().startsWith('/') } +function isReplyToBotMessage(ctx: Context): boolean { + const replyAuthor = ctx.msg?.reply_to_message?.from + if (!replyAuthor) { + return false + } + + return replyAuthor.id === ctx.me.id +} + function summarizeTurns( summary: string | null, turns: readonly AssistantConversationTurn[] @@ -403,6 +412,44 @@ function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopi } } +function createGroupPurchaseRecord( + ctx: Context, + householdId: string, + rawText: string +): PurchaseTopicRecord | null { + if (!isGroupChat(ctx) || !ctx.msg || !ctx.from) { + return null + } + + const normalized = rawText.trim() + if (normalized.length === 0) { + 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: ctx.chat!.id.toString(), + messageId: ctx.msg.message_id.toString(), + threadId: + 'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined + ? ctx.msg.message_thread_id.toString() + : ctx.chat!.id.toString(), + senderTelegramUserId: ctx.from.id.toString(), + rawText: normalized, + messageSentAt: instantFromEpochSeconds(ctx.msg.date), + ...(senderDisplayName.length > 0 + ? { + senderDisplayName + } + : {}) + } +} + function looksLikePurchaseIntent(rawText: string): boolean { const normalized = rawText.trim() if (normalized.length === 0) { @@ -416,6 +463,19 @@ function looksLikePurchaseIntent(rawText: string): boolean { return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized) } +async function resolveAssistantConfig( + householdConfigurationRepository: HouseholdConfigurationRepository, + householdId: string +) { + return householdConfigurationRepository.getHouseholdAssistantConfig + ? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId) + : { + householdId, + assistantContext: null, + assistantTone: null + } +} + function formatAssistantLedger( dashboard: NonNullable>> ) { @@ -440,9 +500,10 @@ async function buildHouseholdContext(input: { householdConfigurationRepository: HouseholdConfigurationRepository financeService: FinanceCommandService }): Promise { - const [household, settings, dashboard, members] = await Promise.all([ + const [household, settings, assistantConfig, dashboard, members] = await Promise.all([ input.householdConfigurationRepository.getHouseholdChatByHouseholdId(input.householdId), input.householdConfigurationRepository.getHouseholdBillingSettings(input.householdId), + resolveAssistantConfig(input.householdConfigurationRepository, input.householdId), input.financeService.generateDashboard(), input.householdConfigurationRepository.listHouseholdMembers(input.householdId) ]) @@ -456,6 +517,14 @@ async function buildHouseholdContext(input: { `Current billing cycle: ${dashboard?.period ?? 'not available'}` ] + if (assistantConfig.assistantTone) { + lines.push(`Preferred assistant tone: ${assistantConfig.assistantTone}`) + } + + if (assistantConfig.assistantContext) { + lines.push(`Household narrative context: ${assistantConfig.assistantContext}`) + } + if (!dashboard) { lines.push('No current dashboard data is available yet.') return lines.join('\n') @@ -988,14 +1057,20 @@ export function registerDmAssistant(options: { const typingIndicator = startTypingIndicator(ctx) try { - const settings = - await options.householdConfigurationRepository.getHouseholdBillingSettings( + const [settings, assistantConfig] = await Promise.all([ + options.householdConfigurationRepository.getHouseholdBillingSettings( member.householdId - ) + ), + resolveAssistantConfig(options.householdConfigurationRepository, member.householdId) + ]) const purchaseResult = await options.purchaseRepository.save( purchaseRecord, options.purchaseInterpreter, - settings.settlementCurrency + settings.settlementCurrency, + { + householdContext: assistantConfig.assistantContext, + assistantTone: assistantConfig.assistantTone + } ) if (purchaseResult.status !== 'ignored_not_purchase') { @@ -1174,10 +1249,9 @@ export function registerDmAssistant(options: { } const mention = stripExplicitBotMention(ctx) - if (!mention || mention.strippedText.length === 0) { - await next() - return - } + const isAddressed = Boolean( + (mention && mention.strippedText.length > 0) || isReplyToBotMessage(ctx) + ) const telegramUserId = ctx.from?.id?.toString() const telegramChatId = ctx.chat?.id?.toString() @@ -1193,6 +1267,26 @@ export function registerDmAssistant(options: { return } + if ( + !isAddressed && + ctx.msg && + 'is_topic_message' in ctx.msg && + ctx.msg.is_topic_message === true && + 'message_thread_id' in ctx.msg && + ctx.msg.message_thread_id !== undefined + ) { + const binding = + await options.householdConfigurationRepository.findHouseholdTopicByTelegramContext({ + telegramChatId, + telegramThreadId: ctx.msg.message_thread_id.toString() + }) + + if (binding) { + await next() + return + } + } + const member = await options.householdConfigurationRepository.getHouseholdMember( household.householdId, telegramUserId @@ -1203,13 +1297,6 @@ export function registerDmAssistant(options: { } 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 = @@ -1243,13 +1330,73 @@ export function registerDmAssistant(options: { try { const financeService = options.financeServiceForHousehold(household.householdId) + const [settings, assistantConfig] = await Promise.all([ + options.householdConfigurationRepository.getHouseholdBillingSettings(household.householdId), + resolveAssistantConfig(options.householdConfigurationRepository, household.householdId) + ]) const memoryKey = conversationMemoryKey({ telegramUserId, telegramChatId, isPrivateChat: false }) + const messageText = mention?.strippedText ?? ctx.msg.text.trim() + + if (options.purchaseRepository && options.purchaseInterpreter) { + const purchaseRecord = createGroupPurchaseRecord(ctx, household.householdId, messageText) + + if (purchaseRecord) { + const purchaseResult = await options.purchaseRepository.save( + purchaseRecord, + options.purchaseInterpreter, + settings.settlementCurrency, + { + householdContext: assistantConfig.assistantContext, + assistantTone: assistantConfig.assistantTone + } + ) + + if (purchaseResult.status === 'pending_confirmation') { + const purchaseText = getBotTranslations(locale).purchase.proposal( + formatPurchaseSummary(locale, purchaseResult), + null + ) + + await ctx.reply(purchaseText, { + reply_markup: purchaseProposalReplyMarkup(locale, purchaseResult.purchaseMessageId) + }) + return + } + + if (purchaseResult.status === 'clarification_needed') { + await ctx.reply(buildPurchaseClarificationText(locale, purchaseResult)) + return + } + + if (!isAddressed) { + await next() + return + } + } + } else if (!isAddressed) { + await next() + return + } + + if (!isAddressed || messageText.length === 0) { + await next() + return + } + + 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 paymentBalanceReply = await maybeCreatePaymentBalanceReply({ - rawText: mention.strippedText, + rawText: messageText, householdId: household.householdId, memberId: member.id, financeService, @@ -1262,7 +1409,7 @@ export function registerDmAssistant(options: { } const memberInsightReply = await maybeCreateMemberInsightReply({ - rawText: mention.strippedText, + rawText: messageText, locale, householdId: household.householdId, currentMemberId: member.id, @@ -1274,7 +1421,7 @@ export function registerDmAssistant(options: { if (memberInsightReply) { options.memoryStore.appendTurn(memoryKey, { role: 'user', - text: mention.strippedText + text: messageText }) options.memoryStore.appendTurn(memoryKey, { role: 'assistant', @@ -1294,7 +1441,7 @@ export function registerDmAssistant(options: { telegramUserId, telegramChatId, locale, - userMessage: mention.strippedText, + userMessage: messageText, householdConfigurationRepository: options.householdConfigurationRepository, financeService, memoryStore: options.memoryStore, diff --git a/apps/bot/src/miniapp-admin.test.ts b/apps/bot/src/miniapp-admin.test.ts index 8f61015..c405d5e 100644 --- a/apps/bot/src/miniapp-admin.test.ts +++ b/apps/bot/src/miniapp-admin.test.ts @@ -185,6 +185,16 @@ function onboardingRepository(): HouseholdConfigurationRepository { utilitiesReminderDay: input.utilitiesReminderDay ?? 3, timezone: input.timezone ?? 'Asia/Tbilisi' }), + getHouseholdAssistantConfig: async (householdId) => ({ + householdId, + assistantContext: 'House in Kojori', + assistantTone: 'Playful' + }), + updateHouseholdAssistantConfig: async (input) => ({ + householdId: input.householdId, + assistantContext: input.assistantContext ?? 'House in Kojori', + assistantTone: input.assistantTone ?? 'Playful' + }), listHouseholdUtilityCategories: async () => [], upsertHouseholdUtilityCategory: async (input) => ({ id: input.slug ?? 'utility-category-1', @@ -471,6 +481,11 @@ describe('createMiniAppSettingsHandler', () => { timezone: 'Asia/Tbilisi', paymentBalanceAdjustmentPolicy: 'utilities' }, + assistantConfig: { + householdId: 'household-1', + assistantContext: 'House in Kojori', + assistantTone: 'Playful' + }, topics: [ { householdId: 'household-1', @@ -566,6 +581,11 @@ describe('createMiniAppUpdateSettingsHandler', () => { utilitiesReminderDay: 5, timezone: 'Asia/Tbilisi', paymentBalanceAdjustmentPolicy: 'utilities' + }, + assistantConfig: { + householdId: 'household-1', + assistantContext: 'House in Kojori', + assistantTone: 'Playful' } }) }) diff --git a/apps/bot/src/miniapp-admin.ts b/apps/bot/src/miniapp-admin.ts index 68df990..074cae6 100644 --- a/apps/bot/src/miniapp-admin.ts +++ b/apps/bot/src/miniapp-admin.ts @@ -58,6 +58,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ utilitiesDueDay: number utilitiesReminderDay: number timezone: string + assistantContext?: string + assistantTone?: string }> { const clonedRequest = request.clone() const payload = await readMiniAppRequestPayload(request) @@ -76,6 +78,8 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ utilitiesDueDay?: number utilitiesReminderDay?: number timezone?: string + assistantContext?: string + assistantTone?: string } try { parsed = JSON.parse(text) @@ -115,6 +119,16 @@ async function readSettingsUpdatePayload(request: Request): Promise<{ rentCurrency: parsed.rentCurrency } : {}), + ...(typeof parsed.assistantContext === 'string' + ? { + assistantContext: parsed.assistantContext + } + : {}), + ...(typeof parsed.assistantTone === 'string' + ? { + assistantTone: parsed.assistantTone + } + : {}), rentDueDay: parsed.rentDueDay, rentWarningDay: parsed.rentWarningDay, utilitiesDueDay: parsed.utilitiesDueDay, @@ -352,6 +366,18 @@ function serializeBillingSettings(settings: HouseholdBillingSettingsRecord) { } } +function serializeAssistantConfig(config: { + householdId: string + assistantContext: string | null + assistantTone: string | null +}) { + return { + householdId: config.householdId, + assistantContext: config.assistantContext, + assistantTone: config.assistantTone + } +} + async function authenticateAdminSession( request: Request, sessionService: ReturnType, @@ -520,6 +546,7 @@ export function createMiniAppSettingsHandler(options: { ok: true, authorized: true, settings: serializeBillingSettings(result.settings), + assistantConfig: serializeAssistantConfig(result.assistantConfig), topics: result.topics, categories: result.categories, members: result.members, @@ -617,7 +644,17 @@ export function createMiniAppUpdateSettingsHandler(options: { rentWarningDay: payload.rentWarningDay, utilitiesDueDay: payload.utilitiesDueDay, utilitiesReminderDay: payload.utilitiesReminderDay, - timezone: payload.timezone + timezone: payload.timezone, + ...(payload.assistantContext !== undefined + ? { + assistantContext: payload.assistantContext + } + : {}), + ...(payload.assistantTone !== undefined + ? { + assistantTone: payload.assistantTone + } + : {}) }) if (result.status === 'rejected') { @@ -638,7 +675,8 @@ export function createMiniAppUpdateSettingsHandler(options: { { ok: true, authorized: true, - settings: serializeBillingSettings(result.settings) + settings: serializeBillingSettings(result.settings), + assistantConfig: serializeAssistantConfig(result.assistantConfig) }, 200, origin diff --git a/apps/bot/src/openai-purchase-interpreter.ts b/apps/bot/src/openai-purchase-interpreter.ts index 186a66b..c0a1922 100644 --- a/apps/bot/src/openai-purchase-interpreter.ts +++ b/apps/bot/src/openai-purchase-interpreter.ts @@ -21,6 +21,8 @@ export type PurchaseMessageInterpreter = ( options: { defaultCurrency: 'GEL' | 'USD' clarificationContext?: PurchaseClarificationContext + householdContext?: string | null + assistantTone?: string | null } ) => Promise @@ -186,9 +188,18 @@ export function createOpenAiPurchaseInterpreter( 'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.', 'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.', 'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.', - 'Return a clarification question in the same language as the user message when clarification is needed.', + 'Return a short, natural clarification question in the same language as the user message when clarification is needed.', + 'The clarification should sound like a conversational household bot, not a form validator.', + options.assistantTone + ? `Use this tone lightly when asking clarification questions: ${options.assistantTone}.` + : null, + options.householdContext + ? `Household flavor context: ${options.householdContext}` + : null, 'Return only JSON that matches the schema.' - ].join(' ') + ] + .filter(Boolean) + .join(' ') }, { role: 'user', diff --git a/apps/bot/src/purchase-topic-ingestion.ts b/apps/bot/src/purchase-topic-ingestion.ts index 38e6b9c..5c6c080 100644 --- a/apps/bot/src/purchase-topic-ingestion.ts +++ b/apps/bot/src/purchase-topic-ingestion.ts @@ -157,7 +157,11 @@ export interface PurchaseMessageIngestionRepository { save( record: PurchaseTopicRecord, interpreter?: PurchaseMessageInterpreter, - defaultCurrency?: 'GEL' | 'USD' + defaultCurrency?: 'GEL' | 'USD', + options?: { + householdContext?: string | null + assistantTone?: string | null + } ): Promise confirm( purchaseMessageId: string, @@ -820,7 +824,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): { return Boolean(clarificationContext && clarificationContext.length > 0) }, - async save(record, interpreter, defaultCurrency) { + async save(record, interpreter, defaultCurrency, options) { const matchedMember = await db .select({ id: schema.members.id }) .from(schema.members) @@ -839,6 +843,8 @@ export function createPurchaseMessageRepository(databaseUrl: string): { const interpretation = interpreter ? await interpreter(record.rawText, { defaultCurrency: defaultCurrency ?? 'GEL', + householdContext: options?.householdContext ?? null, + assistantTone: options?.assistantTone ?? null, ...(clarificationContext ? { clarificationContext: { @@ -1190,6 +1196,23 @@ async function resolveHouseholdLocale( return householdChat?.defaultLocale ?? 'en' } +async function resolveAssistantConfig( + householdConfigurationRepository: HouseholdConfigurationRepository, + householdId: string +): Promise<{ + householdId: string + assistantContext: string | null + assistantTone: string | null +}> { + return householdConfigurationRepository.getHouseholdAssistantConfig + ? await householdConfigurationRepository.getHouseholdAssistantConfig(householdId) + : { + householdId, + assistantContext: null, + assistantTone: null + } +} + async function handlePurchaseMessageResult( ctx: Context, record: PurchaseTopicRecord, @@ -1529,9 +1552,10 @@ export function registerConfiguredPurchaseTopicIngestion( const typingIndicator = options.interpreter ? startTypingIndicator(ctx) : null try { - const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings( - record.householdId - ) + const [billingSettings, assistantConfig] = await Promise.all([ + householdConfigurationRepository.getHouseholdBillingSettings(record.householdId), + resolveAssistantConfig(householdConfigurationRepository, record.householdId) + ]) const locale = await resolveHouseholdLocale( householdConfigurationRepository, record.householdId @@ -1542,7 +1566,11 @@ export function registerConfiguredPurchaseTopicIngestion( const result = await repository.save( record, options.interpreter, - billingSettings.settlementCurrency + billingSettings.settlementCurrency, + { + householdContext: assistantConfig.assistantContext, + assistantTone: assistantConfig.assistantTone + } ) if (stripExplicitBotMention(ctx) && result.status === 'ignored_not_purchase') { return await next() diff --git a/apps/bot/src/server.test.ts b/apps/bot/src/server.test.ts index 19505f1..8ab2ee1 100644 --- a/apps/bot/src/server.test.ts +++ b/apps/bot/src/server.test.ts @@ -37,7 +37,14 @@ describe('createBotWebhookServer', () => { miniAppSettings: { handler: async () => new Response( - JSON.stringify({ ok: true, authorized: true, settings: {}, categories: [], members: [] }), + JSON.stringify({ + ok: true, + authorized: true, + settings: {}, + assistantConfig: {}, + categories: [], + members: [] + }), { status: 200, headers: { @@ -48,12 +55,15 @@ describe('createBotWebhookServer', () => { }, miniAppUpdateSettings: { handler: async () => - new Response(JSON.stringify({ ok: true, authorized: true, settings: {} }), { - status: 200, - headers: { - 'content-type': 'application/json; charset=utf-8' + new Response( + JSON.stringify({ ok: true, authorized: true, settings: {}, assistantConfig: {} }), + { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8' + } } - }) + ) }, miniAppUpsertUtilityCategory: { handler: async () => @@ -305,6 +315,7 @@ describe('createBotWebhookServer', () => { ok: true, authorized: true, settings: {}, + assistantConfig: {}, categories: [], members: [] }) @@ -322,7 +333,8 @@ describe('createBotWebhookServer', () => { expect(await response.json()).toEqual({ ok: true, authorized: true, - settings: {} + settings: {}, + assistantConfig: {} }) }) diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 00c2087..7464662 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -376,7 +376,9 @@ function App() { rentWarningDay: 17, utilitiesDueDay: 4, utilitiesReminderDay: 3, - timezone: 'Asia/Tbilisi' + timezone: 'Asia/Tbilisi', + assistantContext: '', + assistantTone: '' }) const [newCategoryName, setNewCategoryName] = createSignal('') const [cycleForm, setCycleForm] = createSignal({ @@ -917,7 +919,9 @@ function App() { rentWarningDay: payload.settings.rentWarningDay, utilitiesDueDay: payload.settings.utilitiesDueDay, utilitiesReminderDay: payload.settings.utilitiesReminderDay, - timezone: payload.settings.timezone + timezone: payload.settings.timezone, + assistantContext: payload.assistantConfig.assistantContext ?? '', + assistantTone: payload.assistantConfig.assistantTone ?? '' }) setPaymentForm((current) => ({ ...current, @@ -1033,7 +1037,9 @@ function App() { rentWarningDay: demoAdminSettings.settings.rentWarningDay, utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay, utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay, - timezone: demoAdminSettings.settings.timezone + timezone: demoAdminSettings.settings.timezone, + assistantContext: demoAdminSettings.assistantConfig.assistantContext ?? '', + assistantTone: demoAdminSettings.assistantConfig.assistantTone ?? '' }) setCycleForm((current) => ({ ...current, @@ -1338,12 +1344,16 @@ function App() { setSavingBillingSettings(true) try { - const settings = await updateMiniAppBillingSettings(initData, billingForm()) + const { settings, assistantConfig } = await updateMiniAppBillingSettings( + initData, + billingForm() + ) setAdminSettings((current) => current ? { ...current, - settings + settings, + assistantConfig } : current ) @@ -2230,6 +2240,18 @@ function App() { timezone: value })) } + onBillingAssistantContextChange={(value) => + setBillingForm((current) => ({ + ...current, + assistantContext: value + })) + } + onBillingAssistantToneChange={(value) => + setBillingForm((current) => ({ + ...current, + assistantTone: value + })) + } onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)} onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)} onAddUtilityBill={handleAddUtilityBill} diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index 826f41f..91813c5 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -203,6 +203,11 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = { utilitiesReminderDay: 3, timezone: 'Asia/Tbilisi' }, + assistantConfig: { + householdId: 'demo-household', + assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.', + assistantTone: 'Playful but concise' + }, topics: [ { role: 'purchase', telegramThreadId: '101', topicName: 'Purchases' }, { role: 'feedback', telegramThreadId: '102', topicName: 'Anonymous feedback' }, diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index c76fa6d..5dd0cb9 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -147,6 +147,16 @@ export const dictionary = { topicBound: 'Bound', topicUnbound: 'Unbound', billingSettingsTitle: 'Billing settings', + assistantSettingsTitle: 'Bot personality', + assistantSettingsBody: + 'Give the bot household context and a tone so replies feel grounded without getting intrusive.', + assistantToneLabel: 'Bot mood', + assistantTonePlaceholder: 'Playful, dry, concise, slightly sarcastic', + assistantToneDefault: 'Default', + assistantContextLabel: 'Household context', + assistantContextPlaceholder: + 'The household is a house in Kojori with a backyard and pine forest nearby.', + assistantContextEmpty: 'No custom context', settlementCurrency: 'Settlement currency', paymentBalanceAdjustmentPolicy: 'Purchase balance adjustment', paymentBalanceAdjustmentUtilities: 'Adjust through utilities', @@ -392,6 +402,15 @@ export const dictionary = { topicBound: 'Привязан', topicUnbound: 'Не привязан', billingSettingsTitle: 'Настройки биллинга', + assistantSettingsTitle: 'Характер бота', + assistantSettingsBody: + 'Задай бытовой контекст и тон, чтобы ответы бота звучали уместно и не лезли в разговор без повода.', + assistantToneLabel: 'Настроение бота', + assistantTonePlaceholder: 'Игривый, сухой, короткий, слегка саркастичный', + assistantToneDefault: 'По умолчанию', + assistantContextLabel: 'Контекст дома', + assistantContextPlaceholder: 'Это дом в Коджори, рядом двор и сосновый лес.', + assistantContextEmpty: 'Контекст не задан', settlementCurrency: 'Валюта расчёта', paymentBalanceAdjustmentPolicy: 'Зачёт баланса по покупкам', paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку', diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 70a40ca..30fff98 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -70,6 +70,12 @@ export interface MiniAppBillingSettings { timezone: string } +export interface MiniAppAssistantConfig { + householdId: string + assistantContext: string | null + assistantTone: string | null +} + export interface MiniAppUtilityCategory { id: string householdId: string @@ -133,6 +139,7 @@ export interface MiniAppDashboard { export interface MiniAppAdminSettingsPayload { settings: MiniAppBillingSettings + assistantConfig: MiniAppAssistantConfig topics: readonly MiniAppTopicBinding[] categories: readonly MiniAppUtilityCategory[] members: readonly MiniAppMember[] @@ -380,6 +387,7 @@ export async function fetchMiniAppAdminSettings( ok: boolean authorized?: boolean settings?: MiniAppBillingSettings + assistantConfig?: MiniAppAssistantConfig topics?: MiniAppTopicBinding[] categories?: MiniAppUtilityCategory[] members?: MiniAppMember[] @@ -391,6 +399,7 @@ export async function fetchMiniAppAdminSettings( !response.ok || !payload.authorized || !payload.settings || + !payload.assistantConfig || !payload.topics || !payload.categories || !payload.members || @@ -401,6 +410,7 @@ export async function fetchMiniAppAdminSettings( return { settings: payload.settings, + assistantConfig: payload.assistantConfig, topics: payload.topics, categories: payload.categories, members: payload.members, @@ -420,8 +430,13 @@ export async function updateMiniAppBillingSettings( utilitiesDueDay: number utilitiesReminderDay: number timezone: string + assistantContext?: string + assistantTone?: string } -): Promise { +): Promise<{ + settings: MiniAppBillingSettings + assistantConfig: MiniAppAssistantConfig +}> { const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/settings/update`, { method: 'POST', headers: { @@ -437,14 +452,18 @@ export async function updateMiniAppBillingSettings( ok: boolean authorized?: boolean settings?: MiniAppBillingSettings + assistantConfig?: MiniAppAssistantConfig error?: string } - if (!response.ok || !payload.authorized || !payload.settings) { + if (!response.ok || !payload.authorized || !payload.settings || !payload.assistantConfig) { throw new Error(payload.error ?? 'Failed to update billing settings') } - return payload.settings + return { + settings: payload.settings, + assistantConfig: payload.assistantConfig + } } export async function upsertMiniAppUtilityCategory( diff --git a/apps/miniapp/src/screens/house-screen.tsx b/apps/miniapp/src/screens/house-screen.tsx index eddcb8f..82fade9 100644 --- a/apps/miniapp/src/screens/house-screen.tsx +++ b/apps/miniapp/src/screens/house-screen.tsx @@ -37,6 +37,8 @@ type BillingForm = { utilitiesDueDay: number utilitiesReminderDay: number timezone: string + assistantContext: string + assistantTone: string } type CycleForm = { @@ -121,6 +123,8 @@ type Props = { onBillingUtilitiesDueDayChange: (value: number | null) => void onBillingUtilitiesReminderDayChange: (value: number | null) => void onBillingTimezoneChange: (value: string) => void + onBillingAssistantContextChange: (value: string) => void + onBillingAssistantToneChange: (value: string) => void onOpenAddUtilityBill: () => void onCloseAddUtilityBill: () => void onAddUtilityBill: () => Promise @@ -260,23 +264,22 @@ export function HouseScreen(props: Props) {
- {props.copy.billingSettingsTitle ?? ''} - {props.billingForm.settlementCurrency} + {props.copy.assistantSettingsTitle ?? ''} + + {props.billingForm.assistantTone || (props.copy.assistantToneDefault ?? '')} +
-

- {props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities' - ? props.copy.paymentBalanceAdjustmentUtilities - : props.billingForm.paymentBalanceAdjustmentPolicy === 'rent' - ? props.copy.paymentBalanceAdjustmentRent - : props.copy.paymentBalanceAdjustmentSeparate} -

+

{props.copy.assistantSettingsBody ?? ''}

- {props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '} - {props.billingForm.rentCurrency} + {props.copy.assistantToneLabel ?? ''}:{' '} + {props.billingForm.assistantTone || props.copy.assistantToneDefault || '—'} - {props.copy.timezone ?? ''}: {props.billingForm.timezone} + {props.copy.assistantContextLabel ?? ''}:{' '} + {props.billingForm.assistantContext.trim().length > 0 + ? props.billingForm.assistantContext.trim().slice(0, 80) + : (props.copy.assistantContextEmpty ?? '')}
@@ -514,6 +517,27 @@ export function HouseScreen(props: Props) { onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)} /> + + + props.onBillingAssistantToneChange(event.currentTarget.value) + } + /> + + +