mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(bot): route purchase dms through confirmation flow
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
createInMemoryAssistantUsageTracker,
|
||||
registerDmAssistant
|
||||
} from './dm-assistant'
|
||||
import type { PurchaseMessageIngestionRepository } from './purchase-topic-ingestion'
|
||||
|
||||
function createTestBot() {
|
||||
const bot = createTelegramBot('000000:test-token')
|
||||
@@ -326,6 +327,190 @@ function createPromptRepository(): TelegramPendingActionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||||
const clarificationKeys = new Set<string>()
|
||||
const proposals = new Map<
|
||||
string,
|
||||
{
|
||||
householdId: string
|
||||
senderTelegramUserId: string
|
||||
parsedAmountMinor: bigint
|
||||
parsedCurrency: 'GEL' | 'USD'
|
||||
parsedItemDescription: string
|
||||
status: 'pending_confirmation' | 'confirmed' | 'cancelled'
|
||||
}
|
||||
>()
|
||||
|
||||
function key(input: { householdId: string; senderTelegramUserId: string; threadId: string }) {
|
||||
return `${input.householdId}:${input.senderTelegramUserId}:${input.threadId}`
|
||||
}
|
||||
|
||||
return {
|
||||
async hasClarificationContext(record) {
|
||||
return clarificationKeys.has(key(record))
|
||||
},
|
||||
async save(record) {
|
||||
const threadKey = key(record)
|
||||
|
||||
if (record.rawText === 'I bought a door handle for 30 lari') {
|
||||
proposals.set('purchase-1', {
|
||||
householdId: record.householdId,
|
||||
senderTelegramUserId: record.senderTelegramUserId,
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'door handle',
|
||||
status: 'pending_confirmation'
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'pending_confirmation' as const,
|
||||
purchaseMessageId: 'purchase-1',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL' as const,
|
||||
parsedItemDescription: 'door handle',
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (record.rawText === 'I bought sausages, paid 45') {
|
||||
clarificationKeys.add(threadKey)
|
||||
return {
|
||||
status: 'clarification_needed' as const,
|
||||
purchaseMessageId: 'purchase-clarification-1',
|
||||
clarificationQuestion: 'Which currency was this purchase in?',
|
||||
parsedAmountMinor: 4500n,
|
||||
parsedCurrency: null,
|
||||
parsedItemDescription: 'sausages',
|
||||
parserConfidence: 61,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (record.rawText === 'lari' && clarificationKeys.has(threadKey)) {
|
||||
clarificationKeys.delete(threadKey)
|
||||
proposals.set('purchase-2', {
|
||||
householdId: record.householdId,
|
||||
senderTelegramUserId: record.senderTelegramUserId,
|
||||
parsedAmountMinor: 4500n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'sausages',
|
||||
status: 'pending_confirmation'
|
||||
})
|
||||
|
||||
return {
|
||||
status: 'pending_confirmation' as const,
|
||||
purchaseMessageId: 'purchase-2',
|
||||
parsedAmountMinor: 4500n,
|
||||
parsedCurrency: 'GEL' as const,
|
||||
parsedItemDescription: 'sausages',
|
||||
parserConfidence: 88,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ignored_not_purchase' as const,
|
||||
purchaseMessageId: `ignored-${record.messageId}`
|
||||
}
|
||||
},
|
||||
async confirm(purchaseMessageId, actorTelegramUserId) {
|
||||
const proposal = proposals.get(purchaseMessageId)
|
||||
if (!proposal) {
|
||||
return {
|
||||
status: 'not_found' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.senderTelegramUserId !== actorTelegramUserId) {
|
||||
return {
|
||||
status: 'forbidden' as const,
|
||||
householdId: proposal.householdId
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.status === 'confirmed') {
|
||||
return {
|
||||
status: 'already_confirmed' as const,
|
||||
purchaseMessageId,
|
||||
householdId: proposal.householdId,
|
||||
parsedAmountMinor: proposal.parsedAmountMinor,
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.status !== 'pending_confirmation') {
|
||||
return {
|
||||
status: 'not_pending' as const,
|
||||
householdId: proposal.householdId
|
||||
}
|
||||
}
|
||||
|
||||
proposal.status = 'confirmed'
|
||||
return {
|
||||
status: 'confirmed' as const,
|
||||
purchaseMessageId,
|
||||
householdId: proposal.householdId,
|
||||
parsedAmountMinor: proposal.parsedAmountMinor,
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
},
|
||||
async cancel(purchaseMessageId, actorTelegramUserId) {
|
||||
const proposal = proposals.get(purchaseMessageId)
|
||||
if (!proposal) {
|
||||
return {
|
||||
status: 'not_found' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.senderTelegramUserId !== actorTelegramUserId) {
|
||||
return {
|
||||
status: 'forbidden' as const,
|
||||
householdId: proposal.householdId
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.status === 'cancelled') {
|
||||
return {
|
||||
status: 'already_cancelled' as const,
|
||||
purchaseMessageId,
|
||||
householdId: proposal.householdId,
|
||||
parsedAmountMinor: proposal.parsedAmountMinor,
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
|
||||
if (proposal.status !== 'pending_confirmation') {
|
||||
return {
|
||||
status: 'not_pending' as const,
|
||||
householdId: proposal.householdId
|
||||
}
|
||||
}
|
||||
|
||||
proposal.status = 'cancelled'
|
||||
return {
|
||||
status: 'cancelled' as const,
|
||||
purchaseMessageId,
|
||||
householdId: proposal.householdId,
|
||||
parsedAmountMinor: proposal.parsedAmountMinor,
|
||||
parsedCurrency: proposal.parsedCurrency,
|
||||
parsedItemDescription: proposal.parsedItemDescription,
|
||||
parserConfidence: 92,
|
||||
parserMode: 'llm' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createProcessedBotMessageRepository(): ProcessedBotMessageRepository {
|
||||
const claims = new Set<string>()
|
||||
|
||||
@@ -498,6 +683,266 @@ describe('registerDmAssistant', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('routes obvious purchase-like DMs into purchase confirmation flow', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
let assistantCalls = 0
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
registerDmAssistant({
|
||||
bot,
|
||||
assistant: {
|
||||
async respond() {
|
||||
assistantCalls += 1
|
||||
return {
|
||||
text: 'fallback assistant reply',
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
householdConfigurationRepository: createHouseholdRepository(),
|
||||
promptRepository: createPromptRepository(),
|
||||
financeServiceForHousehold: () => createFinanceService(),
|
||||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||
burstLimit: 5,
|
||||
burstWindowMs: 60_000,
|
||||
rollingLimit: 50,
|
||||
rollingWindowMs: 86_400_000
|
||||
}),
|
||||
usageTracker: createInMemoryAssistantUsageTracker()
|
||||
})
|
||||
|
||||
await bot.handleUpdate(privateMessageUpdate('I bought a door handle for 30 lari') as never)
|
||||
|
||||
expect(assistantCalls).toBe(0)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'sendChatAction',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
action: 'typing'
|
||||
}
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
text: 'I think this shared purchase was: door handle - 30.00 GEL. Confirm or cancel below.',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
callback_data: 'assistant_purchase:confirm:purchase-1'
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
callback_data: 'assistant_purchase:cancel:purchase-1'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('uses clarification context for follow-up purchase replies in DM', 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
|
||||
})
|
||||
|
||||
registerDmAssistant({
|
||||
bot,
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
householdConfigurationRepository: createHouseholdRepository(),
|
||||
promptRepository: createPromptRepository(),
|
||||
financeServiceForHousehold: () => createFinanceService(),
|
||||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||
burstLimit: 5,
|
||||
burstWindowMs: 60_000,
|
||||
rollingLimit: 50,
|
||||
rollingWindowMs: 86_400_000
|
||||
}),
|
||||
usageTracker: createInMemoryAssistantUsageTracker()
|
||||
})
|
||||
|
||||
await bot.handleUpdate(privateMessageUpdate('I bought sausages, paid 45') as never)
|
||||
await bot.handleUpdate(privateMessageUpdate('lari') as never)
|
||||
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
text: 'Which currency was this purchase in?'
|
||||
}
|
||||
})
|
||||
expect(calls[3]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
text: 'I think this shared purchase was: sausages - 45.00 GEL. Confirm or cancel below.',
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Confirm',
|
||||
callback_data: 'assistant_purchase:confirm:purchase-2'
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
callback_data: 'assistant_purchase:cancel:purchase-2'
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('confirms a pending purchase proposal from DM callback', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
const purchaseRepository = createPurchaseRepository()
|
||||
|
||||
bot.api.config.use(async (_prev, method, payload) => {
|
||||
calls.push({ method, payload })
|
||||
return {
|
||||
ok: true,
|
||||
result: true
|
||||
} as never
|
||||
})
|
||||
|
||||
registerDmAssistant({
|
||||
bot,
|
||||
purchaseRepository,
|
||||
householdConfigurationRepository: createHouseholdRepository(),
|
||||
promptRepository: createPromptRepository(),
|
||||
financeServiceForHousehold: () => createFinanceService(),
|
||||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||
burstLimit: 5,
|
||||
burstWindowMs: 60_000,
|
||||
rollingLimit: 50,
|
||||
rollingWindowMs: 86_400_000
|
||||
}),
|
||||
usageTracker: createInMemoryAssistantUsageTracker()
|
||||
})
|
||||
|
||||
await bot.handleUpdate(privateMessageUpdate('I bought a door handle for 30 lari') as never)
|
||||
calls.length = 0
|
||||
|
||||
await bot.handleUpdate(privateCallbackUpdate('assistant_purchase:confirm:purchase-1') as never)
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
method: 'answerCallbackQuery',
|
||||
payload: {
|
||||
callback_query_id: 'callback-1',
|
||||
text: 'Purchase confirmed.'
|
||||
}
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'editMessageText',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
message_id: 77,
|
||||
text: 'Purchase confirmed: door handle - 30.00 GEL'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to the generic assistant for non-purchase chatter', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
let assistantCalls = 0
|
||||
|
||||
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
|
||||
} as never
|
||||
})
|
||||
|
||||
registerDmAssistant({
|
||||
bot,
|
||||
assistant: {
|
||||
async respond() {
|
||||
assistantCalls += 1
|
||||
return {
|
||||
text: 'general fallback reply',
|
||||
usage: {
|
||||
inputTokens: 22,
|
||||
outputTokens: 7,
|
||||
totalTokens: 29
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
purchaseRepository: createPurchaseRepository(),
|
||||
householdConfigurationRepository: createHouseholdRepository(),
|
||||
promptRepository: createPromptRepository(),
|
||||
financeServiceForHousehold: () => createFinanceService(),
|
||||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||||
burstLimit: 5,
|
||||
burstWindowMs: 60_000,
|
||||
rollingLimit: 50,
|
||||
rollingWindowMs: 86_400_000
|
||||
}),
|
||||
usageTracker: createInMemoryAssistantUsageTracker()
|
||||
})
|
||||
|
||||
await bot.handleUpdate(privateMessageUpdate('How are you?') as never)
|
||||
|
||||
expect(assistantCalls).toBe(1)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1]).toMatchObject({
|
||||
method: 'sendMessage',
|
||||
payload: {
|
||||
chat_id: 123456,
|
||||
text: 'general fallback reply'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('ignores duplicate deliveries of the same DM update', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parsePaymentConfirmationMessage, type FinanceCommandService } from '@household/application'
|
||||
import { Money } from '@household/domain'
|
||||
import { instantFromEpochSeconds, Money } from '@household/domain'
|
||||
import type { Logger } from '@household/observability'
|
||||
import type {
|
||||
HouseholdConfigurationRepository,
|
||||
@@ -11,13 +11,25 @@ import type { Bot, Context } from 'grammy'
|
||||
import { resolveReplyLocale } from './bot-locale'
|
||||
import { getBotTranslations, type BotLocale } from './i18n'
|
||||
import type { AssistantReply, ConversationalAssistant } from './openai-chat-assistant'
|
||||
import type { PurchaseMessageInterpreter } from './openai-purchase-interpreter'
|
||||
import type {
|
||||
PurchaseMessageIngestionRepository,
|
||||
PurchaseProposalActionResult,
|
||||
PurchaseTopicRecord
|
||||
} from './purchase-topic-ingestion'
|
||||
import { startTypingIndicator } from './telegram-chat-action'
|
||||
|
||||
const ASSISTANT_PAYMENT_ACTION = 'assistant_payment_confirmation' as const
|
||||
const ASSISTANT_PAYMENT_CONFIRM_CALLBACK_PREFIX = 'assistant_payment:confirm:'
|
||||
const ASSISTANT_PAYMENT_CANCEL_CALLBACK_PREFIX = 'assistant_payment:cancel:'
|
||||
const ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX = 'assistant_purchase:confirm:'
|
||||
const ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX = 'assistant_purchase:cancel:'
|
||||
const DM_ASSISTANT_MESSAGE_SOURCE = 'telegram-dm-assistant'
|
||||
const MEMORY_SUMMARY_MAX_CHARS = 1200
|
||||
const PURCHASE_VERB_PATTERN =
|
||||
/\b(?:bought|buy|got|picked up|spent|купил(?:а|и)?|взял(?:а|и)?|выложил(?:а|и)?|отдал(?:а|и)?|потратил(?:а|и)?)\b/iu
|
||||
const PURCHASE_MONEY_PATTERN =
|
||||
/(?:\d+(?:[.,]\d{1,2})?\s*(?:₾|gel|lari|лари|usd|\$|доллар(?:а|ов)?|кровн\p{L}*)|\b\d+(?:[.,]\d{1,2})\b)/iu
|
||||
|
||||
interface AssistantConversationTurn {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -73,6 +85,11 @@ interface PaymentProposalPayload {
|
||||
currency: 'GEL' | 'USD'
|
||||
}
|
||||
|
||||
type PurchaseActionResult = Extract<
|
||||
PurchaseProposalActionResult,
|
||||
{ status: 'confirmed' | 'already_confirmed' | 'cancelled' | 'already_cancelled' }
|
||||
>
|
||||
|
||||
function describeError(error: unknown): {
|
||||
errorMessage?: string
|
||||
errorName?: string
|
||||
@@ -257,6 +274,133 @@ function paymentProposalReplyMarkup(locale: BotLocale, proposalId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function purchaseProposalReplyMarkup(locale: BotLocale, purchaseMessageId: string) {
|
||||
const t = getBotTranslations(locale).purchase
|
||||
|
||||
return {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: t.confirmButton,
|
||||
callback_data: `${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||
},
|
||||
{
|
||||
text: t.cancelButton,
|
||||
callback_data: `${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}${purchaseMessageId}`
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function formatPurchaseSummary(
|
||||
locale: BotLocale,
|
||||
result: {
|
||||
parsedAmountMinor: bigint | null
|
||||
parsedCurrency: 'GEL' | 'USD' | null
|
||||
parsedItemDescription: string | null
|
||||
}
|
||||
): string {
|
||||
if (
|
||||
result.parsedAmountMinor === null ||
|
||||
result.parsedCurrency === null ||
|
||||
result.parsedItemDescription === null
|
||||
) {
|
||||
return getBotTranslations(locale).purchase.sharedPurchaseFallback
|
||||
}
|
||||
|
||||
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
|
||||
return `${result.parsedItemDescription} - ${amount.toMajorString()} ${result.parsedCurrency}`
|
||||
}
|
||||
|
||||
function buildPurchaseActionMessage(locale: BotLocale, result: PurchaseActionResult): string {
|
||||
const t = getBotTranslations(locale).purchase
|
||||
const summary = formatPurchaseSummary(locale, result)
|
||||
|
||||
if (result.status === 'confirmed' || result.status === 'already_confirmed') {
|
||||
return t.confirmed(summary)
|
||||
}
|
||||
|
||||
return t.cancelled(summary)
|
||||
}
|
||||
|
||||
function buildPurchaseClarificationText(
|
||||
locale: BotLocale,
|
||||
result: {
|
||||
clarificationQuestion: string | null
|
||||
parsedAmountMinor: bigint | null
|
||||
parsedCurrency: 'GEL' | 'USD' | null
|
||||
parsedItemDescription: string | null
|
||||
}
|
||||
): string {
|
||||
const t = getBotTranslations(locale).purchase
|
||||
if (result.clarificationQuestion) {
|
||||
return t.clarification(result.clarificationQuestion)
|
||||
}
|
||||
|
||||
if (result.parsedAmountMinor === null && result.parsedCurrency === null) {
|
||||
return t.clarificationMissingAmountAndCurrency
|
||||
}
|
||||
|
||||
if (result.parsedAmountMinor === null) {
|
||||
return t.clarificationMissingAmount
|
||||
}
|
||||
|
||||
if (result.parsedCurrency === null) {
|
||||
return t.clarificationMissingCurrency
|
||||
}
|
||||
|
||||
if (result.parsedItemDescription === null) {
|
||||
return t.clarificationMissingItem
|
||||
}
|
||||
|
||||
return t.clarificationLowConfidence
|
||||
}
|
||||
|
||||
function createDmPurchaseRecord(ctx: Context, householdId: string): PurchaseTopicRecord | null {
|
||||
if (!isPrivateChat(ctx) || !ctx.msg || !('text' in ctx.msg) || !ctx.from) {
|
||||
return null
|
||||
}
|
||||
|
||||
const chat = ctx.chat
|
||||
if (!chat) {
|
||||
return null
|
||||
}
|
||||
|
||||
const senderDisplayName = [ctx.from.first_name, ctx.from.last_name]
|
||||
.filter((part) => !!part && part.trim().length > 0)
|
||||
.join(' ')
|
||||
|
||||
return {
|
||||
updateId: ctx.update.update_id,
|
||||
householdId,
|
||||
chatId: chat.id.toString(),
|
||||
messageId: ctx.msg.message_id.toString(),
|
||||
threadId: chat.id.toString(),
|
||||
senderTelegramUserId: ctx.from.id.toString(),
|
||||
rawText: ctx.msg.text.trim(),
|
||||
messageSentAt: instantFromEpochSeconds(ctx.msg.date),
|
||||
...(senderDisplayName.length > 0
|
||||
? {
|
||||
senderDisplayName
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikePurchaseIntent(rawText: string): boolean {
|
||||
const normalized = rawText.trim()
|
||||
if (normalized.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (PURCHASE_VERB_PATTERN.test(normalized)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return PURCHASE_MONEY_PATTERN.test(normalized) && /\p{L}/u.test(normalized)
|
||||
}
|
||||
|
||||
function parsePaymentProposalPayload(
|
||||
payload: Record<string, unknown>
|
||||
): PaymentProposalPayload | null {
|
||||
@@ -436,6 +580,8 @@ async function maybeCreatePaymentProposal(input: {
|
||||
export function registerDmAssistant(options: {
|
||||
bot: Bot
|
||||
assistant?: ConversationalAssistant
|
||||
purchaseRepository?: PurchaseMessageIngestionRepository
|
||||
purchaseInterpreter?: PurchaseMessageInterpreter
|
||||
householdConfigurationRepository: HouseholdConfigurationRepository
|
||||
messageProcessingRepository?: ProcessedBotMessageRepository
|
||||
promptRepository: TelegramPendingActionRepository
|
||||
@@ -580,6 +726,131 @@ export function registerDmAssistant(options: {
|
||||
}
|
||||
)
|
||||
|
||||
options.bot.callbackQuery(
|
||||
new RegExp(`^${ASSISTANT_PURCHASE_CONFIRM_CALLBACK_PREFIX}([^:]+)$`),
|
||||
async (ctx) => {
|
||||
if (!isPrivateChat(ctx) || !options.purchaseRepository) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: getBotTranslations('en').purchase.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const purchaseMessageId = ctx.match[1]
|
||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||
if (!actorTelegramUserId || !purchaseMessageId) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: getBotTranslations('en').purchase.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await options.purchaseRepository.confirm(
|
||||
purchaseMessageId,
|
||||
actorTelegramUserId
|
||||
)
|
||||
const locale =
|
||||
'householdId' in result
|
||||
? await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
: 'en'
|
||||
const t = getBotTranslations(locale).purchase
|
||||
|
||||
if (result.status === 'not_found' || result.status === 'not_pending') {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: t.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'forbidden') {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: t.notYourProposal,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.answerCallbackQuery({
|
||||
text: result.status === 'confirmed' ? t.confirmedToast : t.alreadyConfirmed
|
||||
})
|
||||
|
||||
if (ctx.msg) {
|
||||
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
|
||||
reply_markup: {
|
||||
inline_keyboard: []
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
options.bot.callbackQuery(
|
||||
new RegExp(`^${ASSISTANT_PURCHASE_CANCEL_CALLBACK_PREFIX}([^:]+)$`),
|
||||
async (ctx) => {
|
||||
if (!isPrivateChat(ctx) || !options.purchaseRepository) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: getBotTranslations('en').purchase.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const purchaseMessageId = ctx.match[1]
|
||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||
if (!actorTelegramUserId || !purchaseMessageId) {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: getBotTranslations('en').purchase.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await options.purchaseRepository.cancel(purchaseMessageId, actorTelegramUserId)
|
||||
const locale =
|
||||
'householdId' in result
|
||||
? await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
: 'en'
|
||||
const t = getBotTranslations(locale).purchase
|
||||
|
||||
if (result.status === 'not_found' || result.status === 'not_pending') {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: t.proposalUnavailable,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'forbidden') {
|
||||
await ctx.answerCallbackQuery({
|
||||
text: t.notYourProposal,
|
||||
show_alert: true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.answerCallbackQuery({
|
||||
text: result.status === 'cancelled' ? t.cancelledToast : t.alreadyCancelled
|
||||
})
|
||||
|
||||
if (ctx.msg) {
|
||||
await ctx.editMessageText(buildPurchaseActionMessage(locale, result), {
|
||||
reply_markup: {
|
||||
inline_keyboard: []
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
options.bot.on('message:text', async (ctx, next) => {
|
||||
if (!isPrivateChat(ctx) || isCommandMessage(ctx)) {
|
||||
await next()
|
||||
@@ -651,6 +922,64 @@ export function registerDmAssistant(options: {
|
||||
return
|
||||
}
|
||||
|
||||
const purchaseRecord = createDmPurchaseRecord(ctx, member.householdId)
|
||||
const shouldAttemptPurchase =
|
||||
purchaseRecord &&
|
||||
options.purchaseRepository &&
|
||||
(looksLikePurchaseIntent(purchaseRecord.rawText) ||
|
||||
(await options.purchaseRepository.hasClarificationContext(purchaseRecord)))
|
||||
|
||||
if (purchaseRecord && options.purchaseRepository && shouldAttemptPurchase) {
|
||||
const typingIndicator = startTypingIndicator(ctx)
|
||||
|
||||
try {
|
||||
const settings =
|
||||
await options.householdConfigurationRepository.getHouseholdBillingSettings(
|
||||
member.householdId
|
||||
)
|
||||
const purchaseResult = await options.purchaseRepository.save(
|
||||
purchaseRecord,
|
||||
options.purchaseInterpreter,
|
||||
settings.settlementCurrency
|
||||
)
|
||||
|
||||
if (purchaseResult.status !== 'ignored_not_purchase') {
|
||||
const purchaseText =
|
||||
purchaseResult.status === 'pending_confirmation'
|
||||
? getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult)
|
||||
)
|
||||
: purchaseResult.status === 'clarification_needed'
|
||||
? buildPurchaseClarificationText(locale, purchaseResult)
|
||||
: getBotTranslations(locale).purchase.parseFailed
|
||||
|
||||
options.memoryStore.appendTurn(telegramUserId, {
|
||||
role: 'user',
|
||||
text: ctx.msg.text
|
||||
})
|
||||
options.memoryStore.appendTurn(telegramUserId, {
|
||||
role: 'assistant',
|
||||
text: purchaseText
|
||||
})
|
||||
|
||||
const replyOptions =
|
||||
purchaseResult.status === 'pending_confirmation'
|
||||
? {
|
||||
reply_markup: purchaseProposalReplyMarkup(
|
||||
locale,
|
||||
purchaseResult.purchaseMessageId
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
|
||||
await ctx.reply(purchaseText, replyOptions)
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
typingIndicator.stop()
|
||||
}
|
||||
}
|
||||
|
||||
const financeService = options.financeServiceForHousehold(member.householdId)
|
||||
const paymentProposal = await maybeCreatePaymentProposal({
|
||||
rawText: ctx.msg.text,
|
||||
|
||||
@@ -117,6 +117,13 @@ const processedBotMessageRepositoryClient =
|
||||
runtime.databaseUrl && runtime.assistantEnabled
|
||||
? createDbProcessedBotMessageRepository(runtime.databaseUrl!)
|
||||
: null
|
||||
const purchaseRepositoryClient = runtime.databaseUrl
|
||||
? createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||
: null
|
||||
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
|
||||
runtime.openaiApiKey,
|
||||
runtime.purchaseParserModel
|
||||
)
|
||||
const assistantMemoryStore = createInMemoryAssistantConversationMemoryStore(
|
||||
runtime.assistantMemoryMaxTurns
|
||||
)
|
||||
@@ -214,14 +221,11 @@ if (processedBotMessageRepositoryClient) {
|
||||
shutdownTasks.push(processedBotMessageRepositoryClient.close)
|
||||
}
|
||||
|
||||
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
|
||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||
if (purchaseRepositoryClient) {
|
||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
||||
const purchaseInterpreter = createOpenAiPurchaseInterpreter(
|
||||
runtime.openaiApiKey,
|
||||
runtime.purchaseParserModel
|
||||
)
|
||||
}
|
||||
|
||||
if (purchaseRepositoryClient && householdConfigurationRepositoryClient) {
|
||||
registerConfiguredPurchaseTopicIngestion(
|
||||
bot,
|
||||
householdConfigurationRepositoryClient.repository,
|
||||
@@ -392,6 +396,16 @@ if (
|
||||
memoryStore: assistantMemoryStore,
|
||||
rateLimiter: assistantRateLimiter,
|
||||
usageTracker: assistantUsageTracker,
|
||||
...(purchaseRepositoryClient
|
||||
? {
|
||||
purchaseRepository: purchaseRepositoryClient.repository
|
||||
}
|
||||
: {}),
|
||||
...(purchaseInterpreter
|
||||
? {
|
||||
purchaseInterpreter
|
||||
}
|
||||
: {}),
|
||||
...(conversationalAssistant
|
||||
? {
|
||||
assistant: conversationalAssistant
|
||||
@@ -408,6 +422,16 @@ if (
|
||||
memoryStore: assistantMemoryStore,
|
||||
rateLimiter: assistantRateLimiter,
|
||||
usageTracker: assistantUsageTracker,
|
||||
...(purchaseRepositoryClient
|
||||
? {
|
||||
purchaseRepository: purchaseRepositoryClient.repository
|
||||
}
|
||||
: {}),
|
||||
...(purchaseInterpreter
|
||||
? {
|
||||
purchaseInterpreter
|
||||
}
|
||||
: {}),
|
||||
...(conversationalAssistant
|
||||
? {
|
||||
assistant: conversationalAssistant
|
||||
|
||||
@@ -287,6 +287,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
return {
|
||||
status: 'pending_confirmation',
|
||||
@@ -356,6 +359,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
return {
|
||||
status: 'clarification_needed',
|
||||
@@ -414,6 +420,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
return {
|
||||
status: 'pending_confirmation',
|
||||
@@ -504,6 +513,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
saveCall += 1
|
||||
return saveCall === 1
|
||||
@@ -544,6 +556,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
return {
|
||||
status: 'pending_confirmation',
|
||||
@@ -610,6 +625,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
@@ -662,6 +680,9 @@ describe('registerPurchaseTopicIngestion', () => {
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
|
||||
@@ -120,6 +120,7 @@ export type PurchaseProposalActionResult =
|
||||
}
|
||||
|
||||
export interface PurchaseMessageIngestionRepository {
|
||||
hasClarificationContext(record: PurchaseTopicRecord): Promise<boolean>
|
||||
save(
|
||||
record: PurchaseTopicRecord,
|
||||
interpreter?: PurchaseMessageInterpreter,
|
||||
@@ -626,6 +627,11 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
||||
}
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext(record) {
|
||||
const clarificationContext = await getClarificationContext(record)
|
||||
return Boolean(clarificationContext && clarificationContext.length > 0)
|
||||
},
|
||||
|
||||
async save(record, interpreter, defaultCurrency) {
|
||||
const matchedMember = await db
|
||||
.select({ id: schema.members.id })
|
||||
|
||||
Reference in New Issue
Block a user