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) => { bot.api.config.use(async (_prev, method, payload) => {
calls.push({ 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 { return {
ok: true, ok: true,
result: true result: true
@@ -356,11 +372,19 @@ describe('registerDmAssistant', () => {
await bot.handleUpdate(privateMessageUpdate('How much do I still owe this month?') as never) 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({ expect(calls[0]).toMatchObject({
method: 'sendMessage', method: 'sendMessage',
payload: { payload: {
chat_id: 123456, 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.' 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( function parsePaymentProposalPayload(
payload: Record<string, unknown> payload: Record<string, unknown>
): PaymentProposalPayload | null { ): PaymentProposalPayload | null {
@@ -669,6 +720,7 @@ export function registerDmAssistant(options: {
householdConfigurationRepository: options.householdConfigurationRepository, householdConfigurationRepository: options.householdConfigurationRepository,
financeService financeService
}) })
const pendingReply = await sendAssistantProcessingReply(ctx, t.processing)
try { try {
const reply = await options.assistant.respond({ const reply = await options.assistant.respond({
@@ -706,7 +758,7 @@ export function registerDmAssistant(options: {
'DM assistant reply generated' 'DM assistant reply generated'
) )
await ctx.reply(reply.text) await finalizeAssistantReply(ctx, pendingReply, reply.text)
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {
@@ -717,7 +769,7 @@ export function registerDmAssistant(options: {
}, },
'DM assistant reply failed' '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: { assistant: {
unavailable: 'The assistant is temporarily unavailable. Try again in a moment.', unavailable: 'The assistant is temporarily unavailable. Try again in a moment.',
processing: 'Working on it...',
noHousehold: noHousehold:
'I can help after your Telegram account is linked to a household. Open the household group and complete the join flow first.', 'I can help after your Telegram account is linked to a household. Open the household group and complete the join flow first.',
multipleHouseholds: multipleHouseholds:
@@ -190,6 +191,7 @@ export const enBotTranslations: BotTranslationCatalog = {
}, },
purchase: { purchase: {
sharedPurchaseFallback: 'shared purchase', sharedPurchaseFallback: 'shared purchase',
processing: 'Checking that purchase...',
proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`, proposal: (summary) => `I think this shared purchase was: ${summary}. Confirm or cancel below.`,
clarification: (question) => question, clarification: (question) => question,
clarificationMissingAmountAndCurrency: clarificationMissingAmountAndCurrency:

View File

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

View File

@@ -122,6 +122,7 @@ export interface BotTranslationCatalog {
} }
assistant: { assistant: {
unavailable: string unavailable: string
processing: string
noHousehold: string noHousehold: string
multipleHouseholds: string multipleHouseholds: string
rateLimited: (retryDelay: string) => string rateLimited: (retryDelay: string) => string
@@ -207,6 +208,7 @@ export interface BotTranslationCatalog {
} }
purchase: { purchase: {
sharedPurchaseFallback: string sharedPurchaseFallback: string
processing: string
proposal: (summary: string) => string proposal: (summary: string) => string
clarification: (question: string) => string clarification: (question: string) => string
clarificationMissingAmountAndCurrency: 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 () => { test('does not reply for duplicate deliveries or non-purchase chatter', async () => {
const bot = createTestBot() const bot = createTestBot()
const calls: Array<{ method: string; payload: unknown }> = [] 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 { function toCandidateFromContext(ctx: Context): PurchaseTopicCandidate | null {
const message = ctx.message const message = ctx.message
if (!message || !('text' in message)) { if (!message || !('text' in message)) {
@@ -728,7 +798,8 @@ async function handlePurchaseMessageResult(
record: PurchaseTopicRecord, record: PurchaseTopicRecord,
result: PurchaseMessageIngestionResult, result: PurchaseMessageIngestionResult,
locale: BotLocale, locale: BotLocale,
logger: Logger | undefined logger: Logger | undefined,
pendingReply: PendingPurchaseReply | null = null
): Promise<void> { ): Promise<void> {
if (result.status !== 'duplicate') { if (result.status !== 'duplicate') {
logger?.info( logger?.info(
@@ -747,12 +818,9 @@ async function handlePurchaseMessageResult(
} }
const acknowledgement = buildPurchaseAcknowledgement(result, locale) const acknowledgement = buildPurchaseAcknowledgement(result, locale)
if (!acknowledgement) { await finalizePurchaseReply(
return
}
await replyToPurchaseMessage(
ctx, ctx,
pendingReply,
acknowledgement, acknowledgement,
result.status === 'pending_confirmation' result.status === 'pending_confirmation'
? purchaseProposalReplyMarkup(locale, result.purchaseMessageId) ? purchaseProposalReplyMarkup(locale, result.purchaseMessageId)
@@ -921,8 +989,11 @@ export function registerPurchaseTopicIngestion(
} }
try { try {
const pendingReply = options.interpreter
? await sendPurchaseProcessingReply(ctx, getBotTranslations('en').purchase.processing)
: null
const result = await repository.save(record, options.interpreter, 'GEL') 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) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {
@@ -986,13 +1057,16 @@ export function registerConfiguredPurchaseTopicIngestion(
householdConfigurationRepository, householdConfigurationRepository,
record.householdId record.householdId
) )
const pendingReply = options.interpreter
? await sendPurchaseProcessingReply(ctx, getBotTranslations(locale).purchase.processing)
: null
const result = await repository.save( const result = await repository.save(
record, record,
options.interpreter, options.interpreter,
billingSettings.settlementCurrency billingSettings.settlementCurrency
) )
await handlePurchaseMessageResult(ctx, record, result, locale, options.logger) await handlePurchaseMessageResult(ctx, record, result, locale, options.logger, pendingReply)
} catch (error) { } catch (error) {
options.logger?.error( options.logger?.error(
{ {