feat(bot): show processing replies during llm work

This commit is contained in:
2026-03-11 03:10:23 +04:00
parent 5d83309a9e
commit dc09a07e21
7 changed files with 264 additions and 11 deletions

View File

@@ -321,6 +321,22 @@ describe('registerDmAssistant', () => {
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: 123456,
type: 'private'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
return {
ok: true,
result: true
@@ -356,11 +372,19 @@ describe('registerDmAssistant', () => {
await bot.handleUpdate(privateMessageUpdate('How much do I still owe this month?') as never)
expect(calls).toHaveLength(1)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: 123456,
text: 'Working on it...'
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: 123456,
message_id: 1,
text: 'You still owe 350.00 GEL this cycle.'
}
})

View File

@@ -234,6 +234,57 @@ function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) {
}
}
interface PendingAssistantReply {
chatId: number
messageId: number
}
async function sendAssistantProcessingReply(
ctx: Context,
text: string
): Promise<PendingAssistantReply | null> {
const message = await ctx.reply(text)
if (!message?.chat?.id || typeof message.message_id !== 'number') {
return null
}
return {
chatId: message.chat.id,
messageId: message.message_id
}
}
async function finalizeAssistantReply(
ctx: Context,
pendingReply: PendingAssistantReply | null,
text: string,
replyMarkup?: {
inline_keyboard: Array<
Array<{
text: string
callback_data: string
}>
>
}
): Promise<void> {
if (!pendingReply) {
await ctx.reply(text, replyMarkup ? { reply_markup: replyMarkup } : undefined)
return
}
try {
await ctx.api.editMessageText(
pendingReply.chatId,
pendingReply.messageId,
text,
replyMarkup ? { reply_markup: replyMarkup } : {}
)
} catch {
await ctx.reply(text, replyMarkup ? { reply_markup: replyMarkup } : undefined)
}
}
function parsePaymentProposalPayload(
payload: Record<string, unknown>
): PaymentProposalPayload | null {
@@ -669,6 +720,7 @@ export function registerDmAssistant(options: {
householdConfigurationRepository: options.householdConfigurationRepository,
financeService
})
const pendingReply = await sendAssistantProcessingReply(ctx, t.processing)
try {
const reply = await options.assistant.respond({
@@ -706,7 +758,7 @@ export function registerDmAssistant(options: {
'DM assistant reply generated'
)
await ctx.reply(reply.text)
await finalizeAssistantReply(ctx, pendingReply, reply.text)
} catch (error) {
options.logger?.error(
{
@@ -717,7 +769,7 @@ export function registerDmAssistant(options: {
},
'DM assistant reply failed'
)
await ctx.reply(t.unavailable)
await finalizeAssistantReply(ctx, pendingReply, t.unavailable)
}
})
}

View File

@@ -114,6 +114,7 @@ export const enBotTranslations: BotTranslationCatalog = {
},
assistant: {
unavailable: 'The assistant is temporarily unavailable. Try again in a moment.',
processing: 'Working on it...',
noHousehold:
'I can help after your Telegram account is linked to a household. Open the household group and complete the join flow first.',
multipleHouseholds:
@@ -190,6 +191,7 @@ export const enBotTranslations: BotTranslationCatalog = {
},
purchase: {
sharedPurchaseFallback: 'shared purchase',
processing: 'Checking that purchase...',
proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
clarification: (question) => question,
clarificationMissingAmountAndCurrency:

View File

@@ -117,6 +117,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
},
assistant: {
unavailable: 'Ассистент сейчас недоступен. Попробуйте ещё раз чуть позже.',
processing: 'Сейчас разберусь...',
noHousehold:
'Я смогу помочь после того, как ваш Telegram-профиль будет привязан к дому. Сначала откройте группу дома и завершите вступление.',
multipleHouseholds:
@@ -193,6 +194,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
},
purchase: {
sharedPurchaseFallback: 'общая покупка',
processing: 'Проверяю покупку...',
proposal: (summary) => `Похоже, это общая покупка: ${summary}. Подтвердите или отмените ниже.`,
clarification: (question) => question,
clarificationMissingAmountAndCurrency:

View File

@@ -122,6 +122,7 @@ export interface BotTranslationCatalog {
}
assistant: {
unavailable: string
processing: string
noHousehold: string
multipleHouseholds: string
rateLimited: (retryDelay: string) => string
@@ -207,6 +208,7 @@ export interface BotTranslationCatalog {
}
purchase: {
sharedPurchaseFallback: string
processing: string
proposal: (summary: string) => string
clarification: (question: string) => string
clarificationMissingAmountAndCurrency: string

View File

@@ -385,6 +385,103 @@ describe('registerPurchaseTopicIngestion', () => {
})
})
test('sends a processing reply and edits it when an interpreter is configured', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []
bot.api.config.use(async (_prev, method, payload) => {
calls.push({ method, payload })
if (method === 'sendMessage') {
return {
ok: true,
result: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: Number(config.householdChatId),
type: 'supergroup'
},
text: (payload as { text?: string }).text ?? 'ok'
}
} as never
}
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() {
throw new Error('not used')
},
async cancel() {
throw new Error('not used')
}
}
registerPurchaseTopicIngestion(bot, config, repository, {
interpreter: async () => ({
decision: 'purchase',
amountMinor: 3000n,
currency: 'GEL',
itemDescription: 'toilet paper',
confidence: 92,
parserMode: 'llm',
clarificationQuestion: null
})
})
await bot.handleUpdate(purchaseUpdate('Bought toilet paper 30 gel') as never)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({
method: 'sendMessage',
payload: {
chat_id: Number(config.householdChatId),
text: 'Checking that purchase...',
reply_parameters: {
message_id: 55
}
}
})
expect(calls[1]).toMatchObject({
method: 'editMessageText',
payload: {
chat_id: Number(config.householdChatId),
message_id: 1,
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 or non-purchase chatter', async () => {
const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = []

View File

@@ -292,6 +292,76 @@ async function replyToPurchaseMessage(
})
}
interface PendingPurchaseReply {
chatId: number
messageId: number
}
async function sendPurchaseProcessingReply(
ctx: Context,
text: string
): Promise<PendingPurchaseReply | null> {
const message = ctx.msg
if (!message) {
return null
}
const reply = await ctx.reply(text, {
reply_parameters: {
message_id: message.message_id
}
})
if (!reply?.chat?.id || typeof reply.message_id !== 'number') {
return null
}
return {
chatId: reply.chat.id,
messageId: reply.message_id
}
}
async function finalizePurchaseReply(
ctx: Context,
pendingReply: PendingPurchaseReply | null,
text: string | null,
replyMarkup?: {
inline_keyboard: Array<
Array<{
text: string
callback_data: string
}>
>
}
): Promise<void> {
if (!text) {
if (pendingReply) {
try {
await ctx.api.deleteMessage(pendingReply.chatId, pendingReply.messageId)
} catch {}
}
return
}
if (!pendingReply) {
await replyToPurchaseMessage(ctx, text, replyMarkup)
return
}
try {
await ctx.api.editMessageText(
pendingReply.chatId,
pendingReply.messageId,
text,
replyMarkup ? { reply_markup: replyMarkup } : {}
)
} catch {
await replyToPurchaseMessage(ctx, text, replyMarkup)
}
}
function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
const message = ctx.message
if (!message || !('text' in message)) {
@@ -728,7 +798,8 @@ async function handlePurchaseMessageResult(
record: PurchaseTopicRecord,
result: PurchaseMessageIngestionResult,
locale: BotLocale,
logger: Logger | undefined
logger: Logger | undefined,
pendingReply: PendingPurchaseReply | null = null
): Promise<void> {
if (result.status !== 'duplicate') {
logger?.info(
@@ -747,12 +818,9 @@ async function handlePurchaseMessageResult(
}
const acknowledgement = buildPurchaseAcknowledgement(result, locale)
if (!acknowledgement) {
return
}
await replyToPurchaseMessage(
await finalizePurchaseReply(
ctx,
pendingReply,
acknowledgement,
result.status === 'pending_confirmation'
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
@@ -921,8 +989,11 @@ export function registerPurchaseTopicIngestion(
}
try {
const pendingReply = options.interpreter
? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing)
: null
const result = await repository.save(record, options.interpreter, 'GEL')
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger)
await handlePurchaseMessageResult(ctx, record, result, 'en', options.logger, pendingReply)
} catch (error) {
options.logger?.error(
{
@@ -986,13 +1057,16 @@ export function registerConfiguredPurchaseTopicIngestion(
householdConfigurationRepository,
record.householdId
)
const pendingReply = options.interpreter
? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing)
: null
const result = await repository.save(
record,
options.interpreter,
billingSettings.settlementCurrency
)
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger)
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
} catch (error) {
options.logger?.error(
{