feat(bot): add conversational DM assistant flow

This commit is contained in:
2026-03-11 01:41:58 +04:00
parent a63c702037
commit 714d2a985d
15 changed files with 1560 additions and 8 deletions

View File

@@ -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 () => {

View File

@@ -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) {

View 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()
})
})

View 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)
}
})
}

View File

@@ -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.',

View File

@@ -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.',

View File

@@ -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

View File

@@ -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,

View File

@@ -397,6 +397,7 @@ describe('createMiniAppSettingsHandler', () => {
}
],
categories: [],
assistantUsage: [],
members: [
{
id: 'member-123456',

View File

@@ -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

View 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)
}
}
}
}

View File

@@ -13,6 +13,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType {
return raw
}
if (raw === 'assistant_payment_confirmation') {
return raw
}
throw new Error(`Unexpected telegram pending action type: ${raw}`)
}

View File

@@ -36,3 +36,7 @@ export {
type PaymentConfirmationService,
type PaymentConfirmationSubmitResult
} from './payment-confirmation-service'
export {
parsePaymentConfirmationMessage,
type ParsedPaymentConfirmation
} from './payment-confirmation-parser'

View File

@@ -33,6 +33,13 @@ const server = {
OPENAI_API_KEY: z.string().min(1).optional(),
PARSER_MODEL: z.string().min(1).default('gpt-4.1-mini'),
PURCHASE_PARSER_MODEL: z.string().min(1).default('gpt-5-mini'),
ASSISTANT_MODEL: z.string().min(1).default('gpt-5-mini'),
ASSISTANT_TIMEOUT_MS: z.coerce.number().int().positive().default(15000),
ASSISTANT_MEMORY_MAX_TURNS: z.coerce.number().int().positive().default(12),
ASSISTANT_RATE_LIMIT_BURST: z.coerce.number().int().positive().default(5),
ASSISTANT_RATE_LIMIT_BURST_WINDOW_MS: z.coerce.number().int().positive().default(60000),
ASSISTANT_RATE_LIMIT_ROLLING: z.coerce.number().int().positive().default(50),
ASSISTANT_RATE_LIMIT_ROLLING_WINDOW_MS: z.coerce.number().int().positive().default(86400000),
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
}

View File

@@ -1,6 +1,9 @@
import type { Instant } from '@household/domain'
export const TELEGRAM_PENDING_ACTION_TYPES = ['anonymous_feedback'] as const
export const TELEGRAM_PENDING_ACTION_TYPES = [
'anonymous_feedback',
'assistant_payment_confirmation'
] as const
export type TelegramPendingActionType = (typeof TELEGRAM_PENDING_ACTION_TYPES)[number]