mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:44:03 +00:00
feat(bot): add conversational DM assistant flow
This commit is contained in:
@@ -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<string, { action: 'anonymous_feedback'; expiresAt: Instant | null }>()
|
||||
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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
504
apps/bot/src/dm-assistant.test.ts
Normal file
504
apps/bot/src/dm-assistant.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
723
apps/bot/src/dm-assistant.ts
Normal file
723
apps/bot/src/dm-assistant.ts
Normal file
@@ -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<string, AssistantConversationState>()
|
||||
|
||||
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<string, number[]>()
|
||||
|
||||
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<string, AssistantUsageSnapshot>()
|
||||
|
||||
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<string, unknown>
|
||||
): 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<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||
) {
|
||||
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<string> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof createDbAnonymousFeedbackRepository>
|
||||
@@ -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,
|
||||
|
||||
@@ -397,6 +397,7 @@ describe('createMiniAppSettingsHandler', () => {
|
||||
}
|
||||
],
|
||||
categories: [],
|
||||
assistantUsage: [],
|
||||
members: [
|
||||
{
|
||||
id: 'member-123456',
|
||||
|
||||
@@ -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<Response>
|
||||
@@ -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
|
||||
|
||||
121
apps/bot/src/openai-chat-assistant.ts
Normal file
121
apps/bot/src/openai-chat-assistant.ts
Normal file
@@ -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<AssistantReply>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user