mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(bot): add confirmed purchase proposals in topic ingestion
This commit is contained in:
@@ -15,6 +15,7 @@ export interface BotRuntimeConfig {
|
||||
reminderJobsEnabled: boolean
|
||||
openaiApiKey?: string
|
||||
parserModel: string
|
||||
purchaseParserModel: string
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number {
|
||||
@@ -103,7 +104,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
miniAppAuthEnabled,
|
||||
schedulerOidcAllowedEmails,
|
||||
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) {
|
||||
|
||||
@@ -164,9 +164,27 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
},
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'shared purchase',
|
||||
recorded: (summary) => `Recorded purchase: ${summary}`,
|
||||
savedForReview: (summary) => `Saved for review: ${summary}`,
|
||||
parseFailed: "Saved for review: I couldn't parse this purchase yet."
|
||||
proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
|
||||
clarification: (question) => question,
|
||||
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: {
|
||||
topicMissing:
|
||||
|
||||
@@ -167,9 +167,27 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
},
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'общая покупка',
|
||||
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
||||
savedForReview: (summary) => `Сохранено на проверку: ${summary}`,
|
||||
parseFailed: 'Сохранено на проверку: пока не удалось распознать эту покупку.'
|
||||
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
|
||||
clarification: (question) => question,
|
||||
clarificationMissingAmountAndCurrency:
|
||||
'Какую сумму и валюту нужно записать для этой общей покупки?',
|
||||
clarificationMissingAmount: 'Какую сумму нужно записать для этой общей покупки?',
|
||||
clarificationMissingCurrency: 'В какой валюте была эта покупка?',
|
||||
clarificationMissingItem: 'Что именно было куплено?',
|
||||
clarificationLowConfidence:
|
||||
'Я не уверен, что правильно понял сообщение. Переформулируйте покупку с предметом, суммой и валютой.',
|
||||
confirmButton: 'Подтвердить',
|
||||
cancelButton: 'Отменить',
|
||||
confirmed: (summary) => `Покупка подтверждена: ${summary}`,
|
||||
cancelled: (summary) => `Предложение покупки отменено: ${summary}`,
|
||||
confirmedToast: 'Покупка подтверждена.',
|
||||
cancelledToast: 'Покупка отменена.',
|
||||
alreadyConfirmed: 'Эта покупка уже подтверждена.',
|
||||
alreadyCancelled: 'Это предложение покупки уже отменено.',
|
||||
notYourProposal: 'Подтвердить или отменить эту покупку может только отправитель сообщения.',
|
||||
proposalUnavailable: 'Это предложение покупки уже недоступно.',
|
||||
parseFailed:
|
||||
'Пока не удалось распознать это как общую покупку. Напишите предмет, сумму и валюту явно.'
|
||||
},
|
||||
payments: {
|
||||
topicMissing:
|
||||
|
||||
@@ -187,8 +187,23 @@ export interface BotTranslationCatalog {
|
||||
}
|
||||
purchase: {
|
||||
sharedPurchaseFallback: string
|
||||
recorded: (summary: string) => string
|
||||
savedForReview: (summary: string) => string
|
||||
proposal: (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
|
||||
}
|
||||
payments: {
|
||||
|
||||
@@ -25,7 +25,7 @@ import { createFinanceCommandsService } from './finance-commands'
|
||||
import { createTelegramBot } from './bot'
|
||||
import { getBotRuntimeConfig } from './config'
|
||||
import { registerHouseholdSetupCommands } from './household-setup'
|
||||
import { createOpenAiParserFallback } from './openai-parser-fallback'
|
||||
import { createOpenAiPurchaseInterpreter } from './openai-purchase-interpreter'
|
||||
import {
|
||||
createPurchaseMessageRepository,
|
||||
registerConfiguredPurchaseTopicIngestion
|
||||
@@ -184,16 +184,19 @@ if (telegramPendingActionRepositoryClient) {
|
||||
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
||||
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel)
|
||||
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
|
||||
runtime.openaiApiKey,
|
||||
runtime.purchaseParserModel
|
||||
)
|
||||
|
||||
registerConfiguredPurchaseTopicIngestion(
|
||||
bot,
|
||||
householdConfigurationRepositoryClient.repository,
|
||||
purchaseRepositoryClient.repository,
|
||||
{
|
||||
...(llmFallback
|
||||
...(purchaseInterpreter
|
||||
? {
|
||||
llmFallback
|
||||
interpreter: purchaseInterpreter
|
||||
}
|
||||
: {}),
|
||||
logger: getLogger('purchase-ingestion')
|
||||
|
||||
173
apps/bot/src/openai-purchase-interpreter.ts
Normal file
173
apps/bot/src/openai-purchase-interpreter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
test('returns record when message belongs to configured topic', () => {
|
||||
const record = extractPurchaseTopicCandidate(candidate(), config)
|
||||
@@ -127,93 +172,103 @@ describe('resolveConfiguredPurchaseTopicRecord', () => {
|
||||
})
|
||||
|
||||
describe('buildPurchaseAcknowledgement', () => {
|
||||
test('returns parsed acknowledgement with amount summary', () => {
|
||||
test('returns proposal acknowledgement for a likely purchase', () => {
|
||||
const result = buildPurchaseAcknowledgement({
|
||||
status: 'created',
|
||||
processingStatus: 'parsed',
|
||||
status: 'pending_confirmation',
|
||||
purchaseMessageId: 'proposal-1',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
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({
|
||||
status: 'created',
|
||||
processingStatus: 'needs_review',
|
||||
status: 'clarification_needed',
|
||||
purchaseMessageId: 'proposal-2',
|
||||
clarificationQuestion: 'Which currency was this purchase in?',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'shared purchase',
|
||||
parserConfidence: 78,
|
||||
parserMode: 'rules'
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 61,
|
||||
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({
|
||||
status: 'created',
|
||||
processingStatus: 'parse_failed',
|
||||
status: 'clarification_needed',
|
||||
purchaseMessageId: 'proposal-3',
|
||||
clarificationQuestion: null,
|
||||
parsedAmountMinor: null,
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: null,
|
||||
parserConfidence: null,
|
||||
parserMode: null
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 42,
|
||||
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(
|
||||
buildPurchaseAcknowledgement({
|
||||
status: 'duplicate'
|
||||
})
|
||||
).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(
|
||||
{
|
||||
status: 'created',
|
||||
processingStatus: 'parsed',
|
||||
status: 'pending_confirmation',
|
||||
purchaseMessageId: 'proposal-6',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'туалетная бумага',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'rules'
|
||||
parserMode: 'llm'
|
||||
},
|
||||
'ru'
|
||||
)
|
||||
|
||||
expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL')
|
||||
expect(result).toBe(
|
||||
'Похоже, это общая покупка: туалетная бумага - 30.00 GEL. Подтвердите или отмените ниже.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerPurchaseTopicIngestion', () => {
|
||||
test('replies in-topic after a parsed purchase is recorded', async () => {
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
test('replies in-topic with a proposal and buttons for a likely purchase', async () => {
|
||||
const bot = createTestBot()
|
||||
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) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
@@ -234,14 +289,20 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async save() {
|
||||
return {
|
||||
status: 'created',
|
||||
processingStatus: 'parsed',
|
||||
status: 'pending_confirmation',
|
||||
purchaseMessageId: 'proposal-1',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'rules'
|
||||
parserMode: 'llm'
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,28 +316,28 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
reply_parameters: {
|
||||
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: [
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
callback_data: 'purchase:confirm:proposal-1'
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
callback_data: 'purchase:cancel:proposal-1'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reply for duplicate deliveries', async () => {
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
test('replies with a clarification question for ambiguous purchases', async () => {
|
||||
const bot = createTestBot()
|
||||
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) => {
|
||||
calls.push({ method, payload })
|
||||
|
||||
@@ -297,14 +358,240 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async save() {
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -304,7 +304,7 @@ export function createDbFinanceRepository(
|
||||
parsedCurrency: input.currency,
|
||||
parsedItemDescription: input.description,
|
||||
needsReview: 0,
|
||||
processingStatus: 'parsed',
|
||||
processingStatus: 'confirmed',
|
||||
parserError: null
|
||||
})
|
||||
.where(
|
||||
@@ -597,6 +597,10 @@ export function createDbFinanceRepository(
|
||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
||||
isNotNull(schema.purchaseMessages.parsedCurrency),
|
||||
or(
|
||||
eq(schema.purchaseMessages.processingStatus, 'parsed'),
|
||||
eq(schema.purchaseMessages.processingStatus, 'confirmed')
|
||||
),
|
||||
gte(schema.purchaseMessages.messageSentAt, instantToDate(start)),
|
||||
lt(schema.purchaseMessages.messageSentAt, instantToDate(end))
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ const server = {
|
||||
.transform((value) => parseOptionalCsv(value)),
|
||||
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'),
|
||||
SCHEDULER_SHARED_SECRET: z.string().min(1).optional()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user