feat(bot): add confirmed purchase proposals in topic ingestion

This commit is contained in:
2026-03-11 01:32:47 +04:00
parent d1a3f0e10c
commit a63c702037
10 changed files with 1312 additions and 234 deletions

View File

@@ -15,6 +15,7 @@ export interface BotRuntimeConfig {
reminderJobsEnabled: boolean reminderJobsEnabled: boolean
openaiApiKey?: string openaiApiKey?: string
parserModel: string parserModel: string
purchaseParserModel: string
} }
function parsePort(raw: string | undefined): number { function parsePort(raw: string | undefined): number {
@@ -103,7 +104,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
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:
env.PURCHASE_PARSER_MODEL?.trim() || env.PARSER_MODEL?.trim() || 'gpt-5-mini'
} }
if (databaseUrl !== undefined) { if (databaseUrl !== undefined) {

View File

@@ -164,9 +164,27 @@ export const enBotTranslations: BotTranslationCatalog = {
}, },
purchase: { purchase: {
sharedPurchaseFallback: 'shared purchase', sharedPurchaseFallback: 'shared purchase',
recorded: (summary) => `Recorded purchase: ${summary}`, proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
savedForReview: (summary) => `Saved for review: ${summary}`, clarification: (question) => question,
parseFailed: "Saved for review: I couldn't parse this purchase yet." clarificationMissingAmountAndCurrency:
'What amount and currency should I record for this shared purchase?',
clarificationMissingAmount: 'What amount should I record for this shared purchase?',
clarificationMissingCurrency: 'Which currency was this purchase in?',
clarificationMissingItem: 'What exactly was purchased?',
clarificationLowConfidence:
'I am not confident I understood this. Please restate the shared purchase with item, amount, and currency.',
confirmButton: 'Confirm',
cancelButton: 'Cancel',
confirmed: (summary) => `Purchase confirmed: ${summary}`,
cancelled: (summary) => `Purchase proposal cancelled: ${summary}`,
confirmedToast: 'Purchase confirmed.',
cancelledToast: 'Purchase cancelled.',
alreadyConfirmed: 'This purchase was already confirmed.',
alreadyCancelled: 'This purchase was already cancelled.',
notYourProposal: 'Only the original sender can confirm or cancel this purchase.',
proposalUnavailable: 'This purchase proposal is no longer available.',
parseFailed:
"I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency."
}, },
payments: { payments: {
topicMissing: topicMissing:

View File

@@ -167,9 +167,27 @@ export const ruBotTranslations: BotTranslationCatalog = {
}, },
purchase: { purchase: {
sharedPurchaseFallback: 'общая покупка', sharedPurchaseFallback: 'общая покупка',
recorded: (summary) => `Покупка сохранена: ${summary}`, proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
savedForReview: (summary) => `Сохранено на проверку: ${summary}`, clarification: (question) => question,
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.' clarificationMissingAmountAndCurrency:
'Какую сумму и валюту нужно записать для этой общей покупки?',
clarificationMissingAmount: 'Какую сумму нужно записать для этой общей покупки?',
clarificationMissingCurrency: 'В какой валюте была эта покупка?',
clarificationMissingItem: 'Что именно было куплено?',
clarificationLowConfidence:
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
confirmButton: 'Подтвердить',
cancelButton: 'Отменить',
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
cancelled: (summary) => `Предложение покупки отменено: ${summary}`,
confirmedToast: 'Покупка подтверждена.',
cancelledToast: 'Покупка отменена.',
alreadyConfirmed: 'Эта покупка уже подтверждена.',
alreadyCancelled: 'Это предложение покупки уже отменено.',
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
proposalUnavailable: 'Это предложение покупки уже недоступно.',
parseFailed:
'Пока не удалось распознать это как общую покупку. Напишите предмет, сумму и валюту явно.'
}, },
payments: { payments: {
topicMissing: topicMissing:

View File

@@ -187,8 +187,23 @@ export interface BotTranslationCatalog {
} }
purchase: { purchase: {
sharedPurchaseFallback: string sharedPurchaseFallback: string
recorded: (summary: string) => string proposal: (summary: string) => string
savedForReview: (summary: string) => string clarification: (question: string) => string
clarificationMissingAmountAndCurrency: string
clarificationMissingAmount: string
clarificationMissingCurrency: string
clarificationMissingItem: string
clarificationLowConfidence: string
confirmButton: string
cancelButton: string
confirmed: (summary: string) => string
cancelled: (summary: string) => string
confirmedToast: string
cancelledToast: string
alreadyConfirmed: string
alreadyCancelled: string
notYourProposal: string
proposalUnavailable: string
parseFailed: string parseFailed: string
} }
payments: { payments: {

View File

@@ -25,7 +25,7 @@ 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 { createOpenAiParserFallback } from './openai-parser-fallback' import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
import { import {
createPurchaseMessageRepository, createPurchaseMessageRepository,
registerConfiguredPurchaseTopicIngestion registerConfiguredPurchaseTopicIngestion
@@ -184,16 +184,19 @@ if (telegramPendingActionRepositoryClient) {
if (runtime.databaseUrl && householdConfigurationRepositoryClient) { if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!) const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
shutdownTasks.push(purchaseRepositoryClient.close) shutdownTasks.push(purchaseRepositoryClient.close)
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel) const purchaseInterpreter = createOpenAiPurchaseInterpreter(
runtime.openaiApiKey,
runtime.purchaseParserModel
)
registerConfiguredPurchaseTopicIngestion( registerConfiguredPurchaseTopicIngestion(
bot, bot,
householdConfigurationRepositoryClient.repository, householdConfigurationRepositoryClient.repository,
purchaseRepositoryClient.repository, purchaseRepositoryClient.repository,
{ {
...(llmFallback ...(purchaseInterpreter
? { ? {
llmFallback interpreter: purchaseInterpreter
} }
: {}), : {}),
logger: getLogger('purchase-ingestion') logger: getLogger('purchase-ingestion')

View File

@@ -0,0 +1,173 @@
export type PurchaseInterpretationDecision = 'purchase' | 'clarification' | 'not_purchase'
export interface PurchaseInterpretation {
decision: PurchaseInterpretationDecision
amountMinor: bigint | null
currency: 'GEL' | 'USD' | null
itemDescription: string | null
confidence: number
parserMode: 'llm'
clarificationQuestion: string | null
}
export type PurchaseMessageInterpreter = (
rawText: string,
options: {
defaultCurrency: 'GEL' | 'USD'
}
) => Promise<PurchaseInterpretation | null>
interface OpenAiStructuredResult {
decision: PurchaseInterpretationDecision
amountMinor: string | null
currency: 'GEL' | 'USD' | null
itemDescription: string | null
confidence: number
clarificationQuestion: string | null
}
function asOptionalBigInt(value: string | null): bigint | null {
if (value === null || !/^[0-9]+$/.test(value)) {
return null
}
const parsed = BigInt(value)
return parsed > 0n ? parsed : null
}
function normalizeOptionalText(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed && trimmed.length > 0 ? trimmed : null
}
function normalizeCurrency(value: string | null): 'GEL' | 'USD' | null {
return value === 'GEL' || value === 'USD' ? value : null
}
export function createOpenAiPurchaseInterpreter(
apiKey: string | undefined,
model: string
): PurchaseMessageInterpreter | undefined {
if (!apiKey) {
return undefined
}
return async (rawText, options) => {
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json'
},
body: JSON.stringify({
model,
input: [
{
role: 'system',
content: [
'You classify a single Telegram message from a household shared-purchases topic.',
'Decide whether the message is a real shared purchase, needs clarification, or is not a shared purchase at all.',
`The household default currency is ${options.defaultCurrency}, but do not assume that omitted currency means ${options.defaultCurrency}.`,
'Use clarification when the amount, currency, item, or overall intent is missing or uncertain.',
'Return a clarification question in the same language as the user message when clarification is needed.',
'Return only JSON that matches the schema.'
].join(' ')
},
{
role: 'user',
content: rawText
}
],
text: {
format: {
type: 'json_schema',
name: 'purchase_interpretation',
schema: {
type: 'object',
additionalProperties: false,
properties: {
decision: {
type: 'string',
enum: ['purchase', 'clarification', 'not_purchase']
},
amountMinor: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
currency: {
anyOf: [
{
type: 'string',
enum: ['GEL', 'USD']
},
{ type: 'null' }
]
},
itemDescription: {
anyOf: [{ type: 'string' }, { type: 'null' }]
},
confidence: {
type: 'number',
minimum: 0,
maximum: 100
},
clarificationQuestion: {
anyOf: [{ type: 'string' }, { type: 'null' }]
}
},
required: [
'decision',
'amountMinor',
'currency',
'itemDescription',
'confidence',
'clarificationQuestion'
]
}
}
}
})
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as {
output_text?: string
}
if (!payload.output_text) {
return null
}
let parsedJson: OpenAiStructuredResult
try {
parsedJson = JSON.parse(payload.output_text) as OpenAiStructuredResult
} catch {
return null
}
if (
parsedJson.decision !== 'purchase' &&
parsedJson.decision !== 'clarification' &&
parsedJson.decision !== 'not_purchase'
) {
return null
}
const clarificationQuestion = normalizeOptionalText(parsedJson.clarificationQuestion)
if (parsedJson.decision === 'clarification' && !clarificationQuestion) {
return null
}
return {
decision: parsedJson.decision,
amountMinor: asOptionalBigInt(parsedJson.amountMinor),
currency: normalizeCurrency(parsedJson.currency),
itemDescription: normalizeOptionalText(parsedJson.itemDescription),
confidence: Math.max(0, Math.min(100, Math.round(parsedJson.confidence))),
parserMode: 'llm',
clarificationQuestion
}
}
}

View File

@@ -64,6 +64,51 @@ function purchaseUpdate(text: string) {
} }
} }
function callbackUpdate(data: string, fromId = 10002) {
return {
update_id: 1002,
callback_query: {
id: 'callback-1',
from: {
id: fromId,
is_bot: false,
first_name: 'Mia'
},
chat_instance: 'instance-1',
data,
message: {
message_id: 77,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: 'placeholder'
}
}
}
}
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
}
describe('extractPurchaseTopicCandidate', () => { describe('extractPurchaseTopicCandidate', () => {
test('returns record when message belongs to configured topic', () => { test('returns record when message belongs to configured topic', () => {
const record = extractPurchaseTopicCandidate(candidate(), config) const record = extractPurchaseTopicCandidate(candidate(), config)
@@ -127,93 +172,103 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
}) })
describe('buildPurchaseAcknowledgement', () => { describe('buildPurchaseAcknowledgement', () => {
test('returns parsed acknowledgement with amount summary', () => { test('returns proposal acknowledgement for a likely purchase', () => {
const result = buildPurchaseAcknowledgement({ const result = buildPurchaseAcknowledgement({
status: 'created', status: 'pending_confirmation',
processingStatus: 'parsed', purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'rules' parserMode: 'llm'
}) })
expect(result).toBe('Recorded purchase: toilet paper - 30.00 GEL') expect(result).toBe(
'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.'
)
}) })
test('returns review acknowledgement when parsing needs review', () => { test('returns explicit clarification text from the interpreter', () => {
const result = buildPurchaseAcknowledgement({ const result = buildPurchaseAcknowledgement({
status: 'created', status: 'clarification_needed',
processingStatus: 'needs_review', purchaseMessageId: 'proposal-2',
clarificationQuestion: 'Which currency was this purchase in?',
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: null,
parsedItemDescription: 'shared purchase', parsedItemDescription: 'toilet paper',
parserConfidence: 78, parserConfidence: 61,
parserMode: 'rules' parserMode: 'llm'
}) })
expect(result).toBe('Saved for review: shared purchase - 30.00 GEL') expect(result).toBe('Which currency was this purchase in?')
}) })
test('returns parse failure acknowledgement without guessed values', () => { test('returns fallback clarification when the interpreter question is missing', () => {
const result = buildPurchaseAcknowledgement({ const result = buildPurchaseAcknowledgement({
status: 'created', status: 'clarification_needed',
processingStatus: 'parse_failed', purchaseMessageId: 'proposal-3',
clarificationQuestion: null,
parsedAmountMinor: null, parsedAmountMinor: null,
parsedCurrency: null, parsedCurrency: null,
parsedItemDescription: null, parsedItemDescription: 'toilet paper',
parserConfidence: null, parserConfidence: 42,
parserMode: null parserMode: 'llm'
}) })
expect(result).toBe("Saved for review: I couldn't parse this purchase yet.") expect(result).toBe('What amount and currency should I record for this shared purchase?')
}) })
test('does not acknowledge duplicates', () => { test('returns parse failure acknowledgement without guessing values', () => {
const result = buildPurchaseAcknowledgement({
status: 'parse_failed',
purchaseMessageId: 'proposal-4'
})
expect(result).toBe(
"I couldn't understand this as a shared purchase yet. Please restate it with item, amount, and currency."
)
})
test('does not acknowledge duplicates or non-purchase chatter', () => {
expect( expect(
buildPurchaseAcknowledgement({ buildPurchaseAcknowledgement({
status: 'duplicate' status: 'duplicate'
}) })
).toBeNull() ).toBeNull()
expect(
buildPurchaseAcknowledgement({
status: 'ignored_not_purchase',
purchaseMessageId: 'proposal-5'
})
).toBeNull()
}) })
test('returns Russian acknowledgement when requested', () => { test('returns Russian proposal text when requested', () => {
const result = buildPurchaseAcknowledgement( const result = buildPurchaseAcknowledgement(
{ {
status: 'created', status: 'pending_confirmation',
processingStatus: 'parsed', purchaseMessageId: 'proposal-6',
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: 'GEL',
parsedItemDescription: 'туалетная бумага', parsedItemDescription: 'туалетная бумага',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'rules' parserMode: 'llm'
}, },
'ru' 'ru'
) )
expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL') expect(result).toBe(
'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.'
)
}) })
}) })
describe('registerPurchaseTopicIngestion', () => { describe('registerPurchaseTopicIngestion', () => {
test('replies in-topic after a parsed purchase is recorded', async () => { test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
const bot = createTelegramBot('000000:test-token') const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] const calls: Array<{ method: string; payload: unknown }> = []
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
}
bot.api.config.use(async (_prev, method, payload) => { bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload }) calls.push({ method, payload })
@@ -234,14 +289,20 @@ describe('registerPurchaseTopicIngestion', () => {
const repository: PurchaseMessageIngestionRepository = { const repository: PurchaseMessageIngestionRepository = {
async save() { async save() {
return { return {
status: 'created', status: 'pending_confirmation',
processingStatus: 'parsed', purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n, parsedAmountMinor: 3000n,
parsedCurrency: 'GEL', parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper', parsedItemDescription: 'toilet paper',
parserConfidence: 92, parserConfidence: 92,
parserMode: 'rules' parserMode: 'llm'
} }
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
} }
} }
@@ -255,27 +316,27 @@ describe('registerPurchaseTopicIngestion', () => {
reply_parameters: { reply_parameters: {
message_id: 55 message_id: 55
}, },
text: 'Recorded purchase: toilet paper - 30.00 GEL' text: 'I think this shared purchase was: toilet paper - 30.00 GEL. Confirm or cancel below.',
}) reply_markup: {
}) inline_keyboard: [
[
test('does not reply for duplicate deliveries', async () => { {
const bot = createTelegramBot('000000:test-token') text: 'Confirm',
const calls: Array<{ method: string; payload: unknown }> = [] callback_data: 'purchase:confirm:proposal-1'
},
bot.botInfo = { {
id: 999000, text: 'Cancel',
is_bot: true, callback_data: 'purchase:cancel:proposal-1'
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
} }
]
]
}
})
})
test('replies with a clarification question for ambiguous purchases', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => { bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload }) calls.push({ method, payload })
@@ -297,14 +358,240 @@ describe('registerPurchaseTopicIngestion', () => {
const repository: PurchaseMessageIngestionRepository = { const repository: PurchaseMessageIngestionRepository = {
async save() { async save() {
return { return {
status: 'duplicate' status: 'clarification_needed',
purchaseMessageId: 'proposal-1',
clarificationQuestion: 'Which currency was this purchase in?',
parsedAmountMinor: 3000n,
parsedCurrency: null,
parsedItemDescription: 'toilet paper',
parserConfidence: 52,
parserMode: 'llm'
} }
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper for 30') as never)
expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({
text: 'Which currency was this purchase in?'
})
})
test('does not reply for duplicate deliveries or non-purchase chatter', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
let saveCall = 0
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
saveCall += 1
return saveCall === 1
? {
status: 'duplicate' as const
}
: {
status: 'ignored_not_purchase' as const,
purchaseMessageId: 'proposal-1'
}
},
async confirm() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
} }
} }
registerPurchaseTopicIngestion(bot, config, repository) registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never) await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
await bot.handleUpdate(purchaseUpdate('This is not a purchase') as never)
expect(calls).toHaveLength(0) expect(calls).toHaveLength(0)
}) })
test('confirms a pending proposal and edits the bot message', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
return {
status: 'pending_confirmation',
purchaseMessageId: 'proposal-1',
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL',
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm'
}
},
async confirm() {
return {
status: 'confirmed' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
}
},
async cancel() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Purchase confirmed.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: Number(config.householdChatId),
message_id: 77,
text: 'Purchase confirmed: toilet paper - 30.00 GEL',
reply_markup: {
inline_keyboard: []
}
}
})
})
test('handles duplicate confirm callbacks idempotently', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
throw new Error('not used')
},
async confirm() {
return {
status: 'already_confirmed' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
}
},
async cancel() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:confirm:proposal-1') as never)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'This purchase was already confirmed.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Purchase confirmed: toilet paper - 30.00 GEL'
}
})
})
test('cancels a pending proposal and edits the bot message', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
return {
ok: true,
result: true
} as never
})
const repository: PurchaseMessageIngestionRepository = {
async save() {
throw new Error('not used')
},
async confirm() {
throw new Error('not used')
},
async cancel() {
return {
status: 'cancelled' as const,
purchaseMessageId: 'proposal-1',
householdId: config.householdId,
parsedAmountMinor: 3000n,
parsedCurrency: 'GEL' as const,
parsedItemDescription: 'toilet paper',
parserConfidence: 92,
parserMode: 'llm' as const
}
}
}
registerPurchaseTopicIngestion(bot, config, repository)
await bot.handleUpdate(callbackUpdate('purchase:cancel:proposal-1') as never)
expect(calls[0]).toMatchObject({
method: 'answerCallbackQuery',
payload: {
callback_query_id: 'callback-1',
text: 'Purchase cancelled.'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
text: 'Purchase proposal cancelled: toilet paper - 30.00 GEL'
}
})
})
}) })

View File

@@ -1,4 +1,3 @@
import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household/application'
import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain' import { instantFromEpochSeconds, instantToDate, Money, type Instant } from '@household/domain'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy' import type { Bot, Context } from 'grammy'
@@ -10,6 +9,60 @@ import type {
import { createDbClient, schema } from '@household/db' import { createDbClient, schema } from '@household/db'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import type {
PurchaseInterpretation,
PurchaseMessageInterpreter
} from './openai-purchase-interpreter'
const PURCHASE_CONFIRM_CALLBACK_PREFIX = 'purchase:confirm:'
const PURCHASE_CANCEL_CALLBACK_PREFIX = 'purchase:cancel:'
const MIN_PROPOSAL_CONFIDENCE = 70
type StoredPurchaseProcessingStatus =
| 'pending_confirmation'
| 'clarification_needed'
| 'ignored_not_purchase'
| 'parse_failed'
| 'confirmed'
| 'cancelled'
| 'parsed'
| 'needs_review'
interface StoredPurchaseMessageRow {
id: string
householdId: string
senderTelegramUserId: string
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: 'llm' | null
processingStatus: StoredPurchaseProcessingStatus
}
interface PurchaseProposalFields {
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: 'llm' | null
}
interface PurchaseClarificationResult extends PurchaseProposalFields {
status: 'clarification_needed'
purchaseMessageId: string
clarificationQuestion: string | null
}
interface PurchasePendingConfirmationResult extends PurchaseProposalFields {
status: 'pending_confirmation'
purchaseMessageId: string
parsedAmountMinor: bigint
parsedCurrency: 'GEL' | 'USD'
parsedItemDescription: string
parserConfidence: number
parserMode: 'llm'
}
export interface PurchaseTopicIngestionConfig { export interface PurchaseTopicIngestionConfig {
householdId: string householdId: string
@@ -32,28 +85,247 @@ export interface PurchaseTopicRecord extends PurchaseTopicCandidate {
householdId: string householdId: string
} }
export type PurchaseMessageProcessingStatus = 'parsed' | 'needs_review' | 'parse_failed'
export type PurchaseMessageIngestionResult = export type PurchaseMessageIngestionResult =
| { | {
status: 'duplicate' status: 'duplicate'
} }
| { | {
status: 'created' status: 'ignored_not_purchase'
processingStatus: PurchaseMessageProcessingStatus purchaseMessageId: string
parsedAmountMinor: bigint | null }
parsedCurrency: 'GEL' | 'USD' | null | PurchaseClarificationResult
parsedItemDescription: string | null | PurchasePendingConfirmationResult
parserConfidence: number | null | {
parserMode: 'rules' | 'llm' | null status: 'parse_failed'
purchaseMessageId: string
}
export type PurchaseProposalActionResult =
| ({
status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled'
purchaseMessageId: string
householdId: string
} & PurchaseProposalFields)
| {
status: 'forbidden'
householdId: string
}
| {
status: 'not_pending'
householdId: string
}
| {
status: 'not_found'
} }
export interface PurchaseMessageIngestionRepository { export interface PurchaseMessageIngestionRepository {
save( save(
record: PurchaseTopicRecord, record: PurchaseTopicRecord,
llmFallback?: PurchaseParserLlmFallback, interpreter?: PurchaseMessageInterpreter,
defaultCurrency?: 'GEL' | 'USD' defaultCurrency?: 'GEL' | 'USD'
): Promise<PurchaseMessageIngestionResult> ): Promise<PurchaseMessageIngestionResult>
confirm(
purchaseMessageId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalActionResult>
cancel(
purchaseMessageId: string,
actorTelegramUserId: string
): Promise<PurchaseProposalActionResult>
}
interface PurchasePersistenceDecision {
status: 'pending_confirmation' | 'clarification_needed' | 'ignored_not_purchase' | 'parse_failed'
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: 'llm' | null
clarificationQuestion: string | null
parserError: string | null
needsReview: boolean
}
function normalizeInterpretation(
interpretation: PurchaseInterpretation | null,
parserError: string | null
): PurchasePersistenceDecision {
if (parserError !== null || interpretation === null) {
return {
status: 'parse_failed',
parsedAmountMinor: null,
parsedCurrency: null,
parsedItemDescription: null,
parserConfidence: null,
parserMode: null,
clarificationQuestion: null,
parserError: parserError ?? 'Purchase interpreter returned no result',
needsReview: true
}
}
if (interpretation.decision === 'not_purchase') {
return {
status: 'ignored_not_purchase',
parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: null,
parserError: null,
needsReview: false
}
}
const missingRequiredFields =
interpretation.amountMinor === null ||
interpretation.currency === null ||
interpretation.itemDescription === null
if (
interpretation.decision === 'clarification' ||
missingRequiredFields ||
interpretation.confidence < MIN_PROPOSAL_CONFIDENCE
) {
return {
status: 'clarification_needed',
parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: interpretation.clarificationQuestion,
parserError: null,
needsReview: true
}
}
return {
status: 'pending_confirmation',
parsedAmountMinor: interpretation.amountMinor,
parsedCurrency: interpretation.currency,
parsedItemDescription: interpretation.itemDescription,
parserConfidence: interpretation.confidence,
parserMode: interpretation.parserMode,
clarificationQuestion: null,
parserError: null,
needsReview: false
}
}
function needsReviewAsInt(value: boolean): number {
return value ? 1 : 0
}
function toStoredPurchaseRow(row: {
id: string
householdId: string
senderTelegramUserId: string
parsedAmountMinor: bigint | null
parsedCurrency: string | null
parsedItemDescription: string | null
parserConfidence: number | null
parserMode: string | null
processingStatus: string
}): StoredPurchaseMessageRow {
return {
id: row.id,
householdId: row.householdId,
senderTelegramUserId: row.senderTelegramUserId,
parsedAmountMinor: row.parsedAmountMinor,
parsedCurrency:
row.parsedCurrency === 'USD' || row.parsedCurrency === 'GEL' ? row.parsedCurrency : null,
parsedItemDescription: row.parsedItemDescription,
parserConfidence: row.parserConfidence,
parserMode: row.parserMode === 'llm' ? 'llm' : null,
processingStatus:
row.processingStatus === 'pending_confirmation' ||
row.processingStatus === 'clarification_needed' ||
row.processingStatus === 'ignored_not_purchase' ||
row.processingStatus === 'parse_failed' ||
row.processingStatus === 'confirmed' ||
row.processingStatus === 'cancelled' ||
row.processingStatus === 'parsed' ||
row.processingStatus === 'needs_review'
? row.processingStatus
: 'parse_failed'
}
}
function toProposalFields(row: StoredPurchaseMessageRow): PurchaseProposalFields {
return {
parsedAmountMinor: row.parsedAmountMinor,
parsedCurrency: row.parsedCurrency,
parsedItemDescription: row.parsedItemDescription,
parserConfidence: row.parserConfidence,
parserMode: row.parserMode
}
}
async function replyToPurchaseMessage(
ctx: Context,
text: string,
replyMarkup?: {
inline_keyboard: Array<
Array<{
text: string
callback_data: string
}>
>
}
): Promise<void> {
const message = ctx.msg
if (!message) {
return
}
await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
},
...(replyMarkup
? {
reply_markup: replyMarkup
}
: {})
})
}
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
const message = ctx.message
if (!message || !('text' in message)) {
return null
}
if (!message.is_topic_message || message.message_thread_id === undefined) {
return null
}
const senderTelegramUserId = ctx.from?.id?.toString()
if (!senderTelegramUserId) {
return null
}
const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name]
.filter((part) => !!part && part.trim().length > 0)
.join(' ')
const candidate: PurchaseTopicCandidate = {
updateId: ctx.update.update_id,
chatId: message.chat.id.toString(),
messageId: message.message_id.toString(),
threadId: message.message_thread_id.toString(),
senderTelegramUserId,
rawText: message.text,
messageSentAt: instantFromEpochSeconds(message.date)
}
if (senderDisplayName.length > 0) {
candidate.senderDisplayName = senderDisplayName
}
return candidate
} }
export function extractPurchaseTopicCandidate( export function extractPurchaseTopicCandidate(
@@ -108,10 +380,6 @@ export function resolveConfiguredPurchaseTopicRecord(
} }
} }
function needsReviewAsInt(value: boolean): number {
return value ? 1 : 0
}
export function createPurchaseMessageRepository(databaseUrl: string): { export function createPurchaseMessageRepository(databaseUrl: string): {
repository: PurchaseMessageIngestionRepository repository: PurchaseMessageIngestionRepository
close: () => Promise<void> close: () => Promise<void>
@@ -121,8 +389,129 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
prepare: false prepare: false
}) })
async function getStoredMessage(
purchaseMessageId: string
): Promise<StoredPurchaseMessageRow | null> {
const rows = await db
.select({
id: schema.purchaseMessages.id,
householdId: schema.purchaseMessages.householdId,
senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId,
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor,
parsedCurrency: schema.purchaseMessages.parsedCurrency,
parsedItemDescription: schema.purchaseMessages.parsedItemDescription,
parserConfidence: schema.purchaseMessages.parserConfidence,
parserMode: schema.purchaseMessages.parserMode,
processingStatus: schema.purchaseMessages.processingStatus
})
.from(schema.purchaseMessages)
.where(eq(schema.purchaseMessages.id, purchaseMessageId))
.limit(1)
const row = rows[0]
return row ? toStoredPurchaseRow(row) : null
}
async function mutateProposalStatus(
purchaseMessageId: string,
actorTelegramUserId: string,
targetStatus: 'confirmed' | 'cancelled'
): Promise<PurchaseProposalActionResult> {
const existing = await getStoredMessage(purchaseMessageId)
if (!existing) {
return {
status: 'not_found'
}
}
if (existing.senderTelegramUserId !== actorTelegramUserId) {
return {
status: 'forbidden',
householdId: existing.householdId
}
}
if (existing.processingStatus === targetStatus) {
return {
status: targetStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
purchaseMessageId: existing.id,
householdId: existing.householdId,
...toProposalFields(existing)
}
}
if (existing.processingStatus !== 'pending_confirmation') {
return {
status: 'not_pending',
householdId: existing.householdId
}
}
const rows = await db
.update(schema.purchaseMessages)
.set({
processingStatus: targetStatus,
...(targetStatus === 'confirmed'
? {
needsReview: 0
}
: {})
})
.where(
and(
eq(schema.purchaseMessages.id, purchaseMessageId),
eq(schema.purchaseMessages.senderTelegramUserId, actorTelegramUserId),
eq(schema.purchaseMessages.processingStatus, 'pending_confirmation')
)
)
.returning({
id: schema.purchaseMessages.id,
householdId: schema.purchaseMessages.householdId,
senderTelegramUserId: schema.purchaseMessages.senderTelegramUserId,
parsedAmountMinor: schema.purchaseMessages.parsedAmountMinor,
parsedCurrency: schema.purchaseMessages.parsedCurrency,
parsedItemDescription: schema.purchaseMessages.parsedItemDescription,
parserConfidence: schema.purchaseMessages.parserConfidence,
parserMode: schema.purchaseMessages.parserMode,
processingStatus: schema.purchaseMessages.processingStatus
})
const updated = rows[0]
if (!updated) {
const reloaded = await getStoredMessage(purchaseMessageId)
if (!reloaded) {
return {
status: 'not_found'
}
}
if (reloaded.processingStatus === 'confirmed' || reloaded.processingStatus === 'cancelled') {
return {
status:
reloaded.processingStatus === 'confirmed' ? 'already_confirmed' : 'already_cancelled',
purchaseMessageId: reloaded.id,
householdId: reloaded.householdId,
...toProposalFields(reloaded)
}
}
return {
status: 'not_pending',
householdId: reloaded.householdId
}
}
const stored = toStoredPurchaseRow(updated)
return {
status: targetStatus,
purchaseMessageId: stored.id,
householdId: stored.householdId,
...toProposalFields(stored)
}
}
const repository: PurchaseMessageIngestionRepository = { const repository: PurchaseMessageIngestionRepository = {
async save(record, llmFallback, defaultCurrency) { async save(record, interpreter, defaultCurrency) {
const matchedMember = await db const matchedMember = await db
.select({ id: schema.members.id }) .select({ id: schema.members.id })
.from(schema.members) .from(schema.members)
@@ -137,35 +526,19 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
const senderMemberId = matchedMember[0]?.id ?? null const senderMemberId = matchedMember[0]?.id ?? null
let parserError: string | null = null let parserError: string | null = null
const parsed = await parsePurchaseMessage( const interpretation = interpreter
{ ? await interpreter(record.rawText, {
rawText: record.rawText defaultCurrency: defaultCurrency ?? 'GEL'
}, }).catch((error) => {
{ parserError = error instanceof Error ? error.message : 'Unknown interpreter error'
...(llmFallback
? {
llmFallback
}
: {}),
...(defaultCurrency
? {
defaultCurrency
}
: {})
}
).catch((error) => {
parserError = error instanceof Error ? error.message : 'Unknown parser error'
return null return null
}) })
: null
const processingStatus = const decision = normalizeInterpretation(
parserError !== null interpretation,
? 'parse_failed' parserError ?? (interpreter ? null : 'Purchase interpreter is unavailable')
: parsed === null )
? 'needs_review'
: parsed.needsReview
? 'needs_review'
: 'parsed'
const inserted = await db const inserted = await db
.insert(schema.purchaseMessages) .insert(schema.purchaseMessages)
@@ -180,14 +553,14 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
telegramThreadId: record.threadId, telegramThreadId: record.threadId,
telegramUpdateId: String(record.updateId), telegramUpdateId: String(record.updateId),
messageSentAt: instantToDate(record.messageSentAt), messageSentAt: instantToDate(record.messageSentAt),
parsedAmountMinor: parsed?.amountMinor, parsedAmountMinor: decision.parsedAmountMinor,
parsedCurrency: parsed?.currency, parsedCurrency: decision.parsedCurrency,
parsedItemDescription: parsed?.itemDescription, parsedItemDescription: decision.parsedItemDescription,
parserMode: parsed?.parserMode, parserMode: decision.parserMode,
parserConfidence: parsed?.confidence, parserConfidence: decision.parserConfidence,
needsReview: needsReviewAsInt(parsed?.needsReview ?? true), needsReview: needsReviewAsInt(decision.needsReview),
parserError, parserError: decision.parserError,
processingStatus processingStatus: decision.status
}) })
.onConflictDoNothing({ .onConflictDoNothing({
target: [ target: [
@@ -198,21 +571,54 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
}) })
.returning({ id: schema.purchaseMessages.id }) .returning({ id: schema.purchaseMessages.id })
if (inserted.length === 0) { const insertedRow = inserted[0]
if (!insertedRow) {
return { return {
status: 'duplicate' status: 'duplicate'
} }
} }
switch (decision.status) {
case 'ignored_not_purchase':
return { return {
status: 'created', status: 'ignored_not_purchase',
processingStatus, purchaseMessageId: insertedRow.id
parsedAmountMinor: parsed?.amountMinor ?? null,
parsedCurrency: parsed?.currency ?? null,
parsedItemDescription: parsed?.itemDescription ?? null,
parserConfidence: parsed?.confidence ?? null,
parserMode: parsed?.parserMode ?? null
} }
case 'clarification_needed':
return {
status: 'clarification_needed',
purchaseMessageId: insertedRow.id,
clarificationQuestion: decision.clarificationQuestion,
parsedAmountMinor: decision.parsedAmountMinor,
parsedCurrency: decision.parsedCurrency,
parsedItemDescription: decision.parsedItemDescription,
parserConfidence: decision.parserConfidence,
parserMode: decision.parserMode
}
case 'pending_confirmation':
return {
status: 'pending_confirmation',
purchaseMessageId: insertedRow.id,
parsedAmountMinor: decision.parsedAmountMinor!,
parsedCurrency: decision.parsedCurrency!,
parsedItemDescription: decision.parsedItemDescription!,
parserConfidence: decision.parserConfidence ?? MIN_PROPOSAL_CONFIDENCE,
parserMode: decision.parserMode ?? 'llm'
}
case 'parse_failed':
return {
status: 'parse_failed',
purchaseMessageId: insertedRow.id
}
}
},
async confirm(purchaseMessageId, actorTelegramUserId) {
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'confirmed')
},
async cancel(purchaseMessageId, actorTelegramUserId) {
return mutateProposalStatus(purchaseMessageId, actorTelegramUserId, 'cancelled')
} }
} }
@@ -226,7 +632,11 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
function formatPurchaseSummary( function formatPurchaseSummary(
locale: BotLocale, locale: BotLocale,
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }> result: {
parsedAmountMinor: bigint | null
parsedCurrency: 'GEL' | 'USD' | null
parsedItemDescription: string | null
}
): string { ): string {
if ( if (
result.parsedAmountMinor === null || result.parsedAmountMinor === null ||
@@ -240,73 +650,250 @@ function formatPurchaseSummary(
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}` return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
} }
function clarificationFallback(locale: BotLocale, result: PurchaseClarificationResult): string {
const t = getBotTranslations(locale).purchase
if (result.parsedAmountMinor === null && result.parsedCurrency === null) {
return t.clarificationMissingAmountAndCurrency
}
if (result.parsedAmountMinor === null) {
return t.clarificationMissingAmount
}
if (result.parsedCurrency === null) {
return t.clarificationMissingCurrency
}
if (result.parsedItemDescription === null) {
return t.clarificationMissingItem
}
return t.clarificationLowConfidence
}
export function buildPurchaseAcknowledgement( export function buildPurchaseAcknowledgement(
result: PurchaseMessageIngestionResult, result: PurchaseMessageIngestionResult,
locale: BotLocale = 'en' locale: BotLocale = 'en'
): string | null { ): string | null {
if (result.status === 'duplicate') {
return null
}
const t = getBotTranslations(locale).purchase const t = getBotTranslations(locale).purchase
switch (result.processingStatus) { switch (result.status) {
case 'parsed': case 'duplicate':
return t.recorded(formatPurchaseSummary(locale, result)) case 'ignored_not_purchase':
case 'needs_review': return null
return t.savedForReview(formatPurchaseSummary(locale, result)) case 'pending_confirmation':
return t.proposal(formatPurchaseSummary(locale, result))
case 'clarification_needed':
return t.clarification(result.clarificationQuestion ?? clarificationFallback(locale, result))
case 'parse_failed': case 'parse_failed':
return t.parseFailed return t.parseFailed
} }
} }
async function replyToPurchaseMessage(ctx: Context, text: string): Promise<void> { function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) {
const message = ctx.msg const t = getBotTranslations(locale).purchase
if (!message) {
return {
inline_keyboard: [
[
{
text: t.confirmButton,
callback_data: `${PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}`
},
{
text: t.cancelButton,
callback_data: `${PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
}
]
]
}
}
async function resolveHouseholdLocale(
householdConfigurationRepository: HouseholdConfigurationRepository | undefined,
householdId: string
): Promise<BotLocale> {
if (!householdConfigurationRepository) {
return 'en'
}
const householdChat =
await householdConfigurationRepository.getHouseholdChatByHouseholdId(householdId)
return householdChat?.defaultLocale ?? 'en'
}
async function handlePurchaseMessageResult(
ctx: Context,
record: PurchaseTopicRecord,
result: PurchaseMessageIngestionResult,
locale: BotLocale,
logger: Logger | undefined
): Promise<void> {
if (result.status !== 'duplicate') {
logger?.info(
{
event: 'purchase.ingested',
householdId: record.householdId,
status: result.status,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
senderTelegramUserId: record.senderTelegramUserId
},
'Purchase topic message processed'
)
}
const acknowledgement = buildPurchaseAcknowledgement(result, locale)
if (!acknowledgement) {
return return
} }
await ctx.reply(text, { await replyToPurchaseMessage(
reply_parameters: { ctx,
message_id: message.message_id acknowledgement,
} result.status === 'pending_confirmation'
}) ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
: undefined
)
} }
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null { function emptyInlineKeyboard() {
const message = ctx.message return {
if (!message || !('text' in message)) { inline_keyboard: []
return null }
}
function buildPurchaseActionMessage(
locale: BotLocale,
result: Extract<
PurchaseProposalActionResult,
{ status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' }
>
): string {
const t = getBotTranslations(locale).purchase
const summary = formatPurchaseSummary(locale, result)
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
return t.confirmed(summary)
} }
if (!message.is_topic_message || message.message_thread_id === undefined) { return t.cancelled(summary)
return null }
function registerPurchaseProposalCallbacks(
bot: Bot,
repository: PurchaseMessageIngestionRepository,
resolveLocale: (householdId: string) => Promise<BotLocale>,
logger?: Logger
): void {
bot.callbackQuery(new RegExp(`^${PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !purchaseMessageId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
} }
const senderTelegramUserId = ctx.from?.id?.toString() const result = await repository.confirm(purchaseMessageId, actorTelegramUserId)
if (!senderTelegramUserId) { const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en'
return null const t = getBotTranslations(locale).purchase
if (result.status === 'not_found' || result.status === 'not_pending') {
await ctx.answerCallbackQuery({
text: t.proposalUnavailable,
show_alert: true
})
return
} }
const senderDisplayName = [ctx.from?.first_name, ctx.from?.last_name] if (result.status === 'forbidden') {
.filter((part) => !!part && part.trim().length > 0) await ctx.answerCallbackQuery({
.join(' ') text: t.notYourProposal,
show_alert: true
const candidate: PurchaseTopicCandidate = { })
updateId: ctx.update.update_id, return
chatId: message.chat.id.toString(),
messageId: message.message_id.toString(),
threadId: message.message_thread_id.toString(),
senderTelegramUserId,
rawText: message.text,
messageSentAt: instantFromEpochSeconds(message.date)
} }
if (senderDisplayName.length > 0) { await ctx.answerCallbackQuery({
candidate.senderDisplayName = senderDisplayName text: result.status === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed
})
if (ctx.msg) {
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
reply_markup: emptyInlineKeyboard()
})
} }
return candidate logger?.info(
{
event: 'purchase.confirmation',
purchaseMessageId,
actorTelegramUserId,
status: result.status
},
'Purchase proposal confirmation handled'
)
})
bot.callbackQuery(new RegExp(`^${PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`), async (ctx) => {
const purchaseMessageId = ctx.match[1]
const actorTelegramUserId = ctx.from?.id?.toString()
if (!actorTelegramUserId || !purchaseMessageId) {
await ctx.answerCallbackQuery({
text: getBotTranslations('en').purchase.proposalUnavailable,
show_alert: true
})
return
}
const result = await repository.cancel(purchaseMessageId, actorTelegramUserId)
const locale = 'householdId' in result ? await resolveLocale(result.householdId) : 'en'
const t = getBotTranslations(locale).purchase
if (result.status === 'not_found' || result.status === 'not_pending') {
await ctx.answerCallbackQuery({
text: t.proposalUnavailable,
show_alert: true
})
return
}
if (result.status === 'forbidden') {
await ctx.answerCallbackQuery({
text: t.notYourProposal,
show_alert: true
})
return
}
await ctx.answerCallbackQuery({
text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled
})
if (ctx.msg) {
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
reply_markup: emptyInlineKeyboard()
})
}
logger?.info(
{
event: 'purchase.cancellation',
purchaseMessageId,
actorTelegramUserId,
status: result.status
},
'Purchase proposal cancellation handled'
)
})
} }
export function registerPurchaseTopicIngestion( export function registerPurchaseTopicIngestion(
@@ -314,10 +901,12 @@ export function registerPurchaseTopicIngestion(
config: PurchaseTopicIngestionConfig, config: PurchaseTopicIngestionConfig,
repository: PurchaseMessageIngestionRepository, repository: PurchaseMessageIngestionRepository,
options: { options: {
llmFallback?: PurchaseParserLlmFallback interpreter?: PurchaseMessageInterpreter
logger?: Logger logger?: Logger
} = {} } = {}
): void { ): void {
void registerPurchaseProposalCallbacks(bot, repository, async () => 'en', options.logger)
bot.on('message:text', async (ctx, next) => { bot.on('message:text', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx) const candidate = toCandidateFromContext(ctx)
if (!candidate) { if (!candidate) {
@@ -332,27 +921,8 @@ export function registerPurchaseTopicIngestion(
} }
try { try {
const status = await repository.save(record, options.llmFallback, 'GEL') const result = await repository.save(record, options.interpreter, 'GEL')
const acknowledgement = buildPurchaseAcknowledgement(status, 'en') await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger)
if (status.status === 'created') {
options.logger?.info(
{
event: 'purchase.ingested',
processingStatus: status.processingStatus,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
senderTelegramUserId: record.senderTelegramUserId
},
'Purchase topic message ingested'
)
}
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {
@@ -374,10 +944,17 @@ export function registerConfiguredPurchaseTopicIngestion(
householdConfigurationRepository: HouseholdConfigurationRepository, householdConfigurationRepository: HouseholdConfigurationRepository,
repository: PurchaseMessageIngestionRepository, repository: PurchaseMessageIngestionRepository,
options: { options: {
llmFallback?: PurchaseParserLlmFallback interpreter?: PurchaseMessageInterpreter
logger?: Logger logger?: Logger
} = {} } = {}
): void { ): void {
void registerPurchaseProposalCallbacks(
bot,
repository,
async (householdId) => resolveHouseholdLocale(householdConfigurationRepository, householdId),
options.logger
)
bot.on('message:text', async (ctx, next) => { bot.on('message:text', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx) const candidate = toCandidateFromContext(ctx)
if (!candidate) { if (!candidate) {
@@ -405,38 +982,17 @@ export function registerConfiguredPurchaseTopicIngestion(
const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings( const billingSettings = await householdConfigurationRepository.getHouseholdBillingSettings(
record.householdId record.householdId
) )
const status = await repository.save( const locale = await resolveHouseholdLocale(
record, householdConfigurationRepository,
options.llmFallback,
billingSettings.settlementCurrency
)
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
record.householdId record.householdId
) )
const acknowledgement = buildPurchaseAcknowledgement( const result = await repository.save(
status, record,
householdChat?.defaultLocale ?? 'en' options.interpreter,
billingSettings.settlementCurrency
) )
if (status.status === 'created') { await handlePurchaseMessageResult(ctx, record, result, locale, options.logger)
options.logger?.info(
{
event: 'purchase.ingested',
householdId: record.householdId,
processingStatus: status.processingStatus,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
senderTelegramUserId: record.senderTelegramUserId
},
'Purchase topic message ingested'
)
}
if (acknowledgement) {
await replyToPurchaseMessage(ctx, acknowledgement)
}
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {

View File

@@ -304,7 +304,7 @@ export function createDbFinanceRepository(
parsedCurrency: input.currency, parsedCurrency: input.currency,
parsedItemDescription: input.description, parsedItemDescription: input.description,
needsReview: 0, needsReview: 0,
processingStatus: 'parsed', processingStatus: 'confirmed',
parserError: null parserError: null
}) })
.where( .where(
@@ -597,6 +597,10 @@ export function createDbFinanceRepository(
isNotNull(schema.purchaseMessages.senderMemberId), isNotNull(schema.purchaseMessages.senderMemberId),
isNotNull(schema.purchaseMessages.parsedAmountMinor), isNotNull(schema.purchaseMessages.parsedAmountMinor),
isNotNull(schema.purchaseMessages.parsedCurrency), isNotNull(schema.purchaseMessages.parsedCurrency),
or(
eq(schema.purchaseMessages.processingStatus, 'parsed'),
eq(schema.purchaseMessages.processingStatus, 'confirmed')
),
gte(schema.purchaseMessages.messageSentAt, instantToDate(start)), gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
lt(schema.purchaseMessages.messageSentAt, instantToDate(end)) lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
) )

View File

@@ -32,6 +32,7 @@ const server = {
.transform((value) => parseOptionalCsv(value)), .transform((value) => parseOptionalCsv(value)),
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'),
SCHEDULER_SHARED_SECRET: z.string().min(1).optional() SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
} }