mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:44:03 +00:00
feat(bot): add confirmed purchase proposals in topic ingestion
This commit is contained in:
@@ -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'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user