mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:54:02 +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 { nowInstant, Temporal, type Instant } from '@household/domain'
|
||||||
import type {
|
import type {
|
||||||
HouseholdConfigurationRepository,
|
HouseholdConfigurationRepository,
|
||||||
|
TelegramPendingActionRecord,
|
||||||
TelegramPendingActionRepository
|
TelegramPendingActionRepository
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
|
||||||
@@ -50,7 +51,13 @@ function anonUpdate(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPromptRepository(): TelegramPendingActionRepository {
|
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 {
|
return {
|
||||||
async upsertPendingAction(input) {
|
async upsertPendingAction(input) {
|
||||||
@@ -305,7 +312,6 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
test('uses household locale for the posted anonymous note even when member locale differs', async () => {
|
test('uses household locale for the posted anonymous note even when member locale differs', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
@@ -383,8 +389,12 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
.filter((call) => call.method === 'sendMessage')
|
.filter((call) => call.method === 'sendMessage')
|
||||||
.map((call) => call.payload as { text?: string })
|
.map((call) => call.payload as { text?: string })
|
||||||
|
|
||||||
expect(sendMessagePayloads.some((payload) => payload.text?.startsWith('Анонимное сообщение по дому'))).toBe(true)
|
expect(
|
||||||
expect(sendMessagePayloads.some((payload) => payload.text?.startsWith('Anonymous household note'))).toBe(false)
|
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 () => {
|
test('rejects group usage and keeps feedback private', async () => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface BotRuntimeConfig {
|
|||||||
purchaseTopicIngestionEnabled: boolean
|
purchaseTopicIngestionEnabled: boolean
|
||||||
financeCommandsEnabled: boolean
|
financeCommandsEnabled: boolean
|
||||||
anonymousFeedbackEnabled: boolean
|
anonymousFeedbackEnabled: boolean
|
||||||
|
assistantEnabled: boolean
|
||||||
miniAppAllowedOrigins: readonly string[]
|
miniAppAllowedOrigins: readonly string[]
|
||||||
miniAppAuthEnabled: boolean
|
miniAppAuthEnabled: boolean
|
||||||
schedulerSharedSecret?: string
|
schedulerSharedSecret?: string
|
||||||
@@ -16,6 +17,13 @@ export interface BotRuntimeConfig {
|
|||||||
openaiApiKey?: string
|
openaiApiKey?: string
|
||||||
parserModel: string
|
parserModel: string
|
||||||
purchaseParserModel: string
|
purchaseParserModel: string
|
||||||
|
assistantModel: string
|
||||||
|
assistantTimeoutMs: number
|
||||||
|
assistantMemoryMaxTurns: number
|
||||||
|
assistantRateLimitBurst: number
|
||||||
|
assistantRateLimitBurstWindowMs: number
|
||||||
|
assistantRateLimitRolling: number
|
||||||
|
assistantRateLimitRollingWindowMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePort(raw: string | undefined): number {
|
function parsePort(raw: string | undefined): number {
|
||||||
@@ -76,6 +84,19 @@ function parseOptionalCsv(value: string | undefined): readonly string[] {
|
|||||||
.filter(Boolean)
|
.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 {
|
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||||
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
||||||
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
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 financeCommandsEnabled = databaseUrl !== undefined
|
||||||
const anonymousFeedbackEnabled = databaseUrl !== undefined
|
const anonymousFeedbackEnabled = databaseUrl !== undefined
|
||||||
|
const assistantEnabled = databaseUrl !== undefined
|
||||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
@@ -100,13 +122,45 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
purchaseTopicIngestionEnabled,
|
purchaseTopicIngestionEnabled,
|
||||||
financeCommandsEnabled,
|
financeCommandsEnabled,
|
||||||
anonymousFeedbackEnabled,
|
anonymousFeedbackEnabled,
|
||||||
|
assistantEnabled,
|
||||||
miniAppAllowedOrigins,
|
miniAppAllowedOrigins,
|
||||||
miniAppAuthEnabled,
|
miniAppAuthEnabled,
|
||||||
schedulerOidcAllowedEmails,
|
schedulerOidcAllowedEmails,
|
||||||
reminderJobsEnabled,
|
reminderJobsEnabled,
|
||||||
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini',
|
parserModel: env.PARSER_MODEL?.trim() || 'gpt-4.1-mini',
|
||||||
purchaseParserModel:
|
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) {
|
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'}`,
|
hour: (count) => `${count} hour${count === 1 ? '' : 's'}`,
|
||||||
minute: (count) => `${count} minute${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: {
|
finance: {
|
||||||
useInGroup: 'Use this command inside a household group.',
|
useInGroup: 'Use this command inside a household group.',
|
||||||
householdNotConfigured: 'Household is not configured for this chat yet. Run /setup first.',
|
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 ? 'часа' : 'часов'}`,
|
hour: (count) => `${count} ${count === 1 ? 'час' : count < 5 ? 'часа' : 'часов'}`,
|
||||||
minute: (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: {
|
finance: {
|
||||||
useInGroup: 'Используйте эту команду внутри группы дома.',
|
useInGroup: 'Используйте эту команду внутри группы дома.',
|
||||||
householdNotConfigured: 'Для этого чата дом ещё не настроен. Сначала выполните /setup.',
|
householdNotConfigured: 'Для этого чата дом ещё не настроен. Сначала выполните /setup.',
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ export interface BotTranslationCatalog {
|
|||||||
hour: (count: number) => string
|
hour: (count: number) => string
|
||||||
minute: (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: {
|
finance: {
|
||||||
useInGroup: string
|
useInGroup: string
|
||||||
householdNotConfigured: string
|
householdNotConfigured: string
|
||||||
|
|||||||
@@ -21,10 +21,17 @@ import {
|
|||||||
import { configureLogger, getLogger } from '@household/observability'
|
import { configureLogger, getLogger } from '@household/observability'
|
||||||
|
|
||||||
import { registerAnonymousFeedback } from './anonymous-feedback'
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
|
import {
|
||||||
|
createInMemoryAssistantConversationMemoryStore,
|
||||||
|
createInMemoryAssistantRateLimiter,
|
||||||
|
createInMemoryAssistantUsageTracker,
|
||||||
|
registerDmAssistant
|
||||||
|
} from './dm-assistant'
|
||||||
import { createFinanceCommandsService } from './finance-commands'
|
import { createFinanceCommandsService } from './finance-commands'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
import { registerHouseholdSetupCommands } from './household-setup'
|
import { registerHouseholdSetupCommands } from './household-setup'
|
||||||
|
import { createOpenAiChatAssistant } from './openai-chat-assistant'
|
||||||
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
||||||
import {
|
import {
|
||||||
createPurchaseMessageRepository,
|
createPurchaseMessageRepository,
|
||||||
@@ -100,9 +107,24 @@ const localePreferenceService = householdConfigurationRepositoryClient
|
|||||||
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
||||||
: null
|
: null
|
||||||
const telegramPendingActionRepositoryClient =
|
const telegramPendingActionRepositoryClient =
|
||||||
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
runtime.databaseUrl && (runtime.anonymousFeedbackEnabled || runtime.assistantEnabled)
|
||||||
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
||||||
: null
|
: 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<
|
const anonymousFeedbackRepositoryClients = new Map<
|
||||||
string,
|
string,
|
||||||
ReturnType<typeof createDbAnonymousFeedbackRepository>
|
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
@@ -392,6 +436,7 @@ const server = createBotWebhookServer({
|
|||||||
botToken: runtime.telegramBotToken,
|
botToken: runtime.telegramBotToken,
|
||||||
onboardingService: householdOnboardingService,
|
onboardingService: householdOnboardingService,
|
||||||
miniAppAdminService: miniAppAdminService!,
|
miniAppAdminService: miniAppAdminService!,
|
||||||
|
assistantUsageTracker,
|
||||||
logger: getLogger('miniapp-admin')
|
logger: getLogger('miniapp-admin')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ describe('createMiniAppSettingsHandler', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
categories: [],
|
categories: [],
|
||||||
|
assistantUsage: [],
|
||||||
members: [
|
members: [
|
||||||
{
|
{
|
||||||
id: 'member-123456',
|
id: 'member-123456',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { HouseholdOnboardingService, MiniAppAdminService } from '@household
|
|||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type { HouseholdBillingSettingsRecord } from '@household/ports'
|
import type { HouseholdBillingSettingsRecord } from '@household/ports'
|
||||||
import type { MiniAppSessionResult } from './miniapp-auth'
|
import type { MiniAppSessionResult } from './miniapp-auth'
|
||||||
|
import type { AssistantUsageTracker } from './dm-assistant'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
allowedMiniAppOrigin,
|
allowedMiniAppOrigin,
|
||||||
@@ -342,6 +343,7 @@ export function createMiniAppSettingsHandler(options: {
|
|||||||
botToken: string
|
botToken: string
|
||||||
onboardingService: HouseholdOnboardingService
|
onboardingService: HouseholdOnboardingService
|
||||||
miniAppAdminService: MiniAppAdminService
|
miniAppAdminService: MiniAppAdminService
|
||||||
|
assistantUsageTracker?: AssistantUsageTracker
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
@@ -386,7 +388,9 @@ export function createMiniAppSettingsHandler(options: {
|
|||||||
settings: serializeBillingSettings(result.settings),
|
settings: serializeBillingSettings(result.settings),
|
||||||
topics: result.topics,
|
topics: result.topics,
|
||||||
categories: result.categories,
|
categories: result.categories,
|
||||||
members: result.members
|
members: result.members,
|
||||||
|
assistantUsage:
|
||||||
|
options.assistantUsageTracker?.listHouseholdUsage(member.householdId) ?? []
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
origin
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw === 'assistant_payment_confirmation') {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unexpected telegram pending action type: ${raw}`)
|
throw new Error(`Unexpected telegram pending action type: ${raw}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,7 @@ export {
|
|||||||
type PaymentConfirmationService,
|
type PaymentConfirmationService,
|
||||||
type PaymentConfirmationSubmitResult
|
type PaymentConfirmationSubmitResult
|
||||||
} from './payment-confirmation-service'
|
} from './payment-confirmation-service'
|
||||||
|
export {
|
||||||
|
parsePaymentConfirmationMessage,
|
||||||
|
type ParsedPaymentConfirmation
|
||||||
|
} from './payment-confirmation-parser'
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ const server = {
|
|||||||
OPENAI_API_KEY: z.string().min(1).optional(),
|
OPENAI_API_KEY: z.string().min(1).optional(),
|
||||||
PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'),
|
PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'),
|
||||||
PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'),
|
PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'),
|
||||||
|
ASSISTANT_MODEL: z.string().min(1).default('gpt-5-mini'),
|
||||||
|
ASSISTANT_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
|
||||||
|
ASSISTANT_MEMORY_MAX_TURNS: z.coerce.number().int().positive().default(12),
|
||||||
|
ASSISTANT_RATE_LIMIT_BURST: z.coerce.number().int().positive().default(5),
|
||||||
|
ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS: z.coerce.number().int().positive().default(60000),
|
||||||
|
ASSISTANT_RATE_LIMIT_ROLLING: z.coerce.number().int().positive().default(50),
|
||||||
|
ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS: z.coerce.number().int().positive().default(86400000),
|
||||||
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
|
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Instant } from '@household/domain'
|
import type { Instant } from '@household/domain'
|
||||||
|
|
||||||
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
|
export const TELEGRAM_PENDING_ACTION_TYPES = [
|
||||||
|
'anonymous_feedback',
|
||||||
|
'assistant_payment_confirmation'
|
||||||
|
] as const
|
||||||
|
|
||||||
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user