mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 11:54:03 +00:00
feat(bot): show processing replies during llm work
This commit is contained in:
@@ -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.'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }> = []
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user