mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54:02 +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) => {
|
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.'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }> = []
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user