mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 08:54:04 +00:00
Mini App Home Screen: - Add 'Record Payment' button to utilities and rent period cards - Pre-fill payment amount with member's share (rentShare/utilityShare) - Modal dialog with amount input and currency display - Toast notifications for copy and payment success/failure feedback Copy Button Improvements: - Increase spacing between icon and text (4px → 8px) - Add hover background and padding for better touch target - Green background highlight when copied (in addition to icon color change) - Toast notification appears when copying any value Backend: - Add /api/miniapp/payments/add endpoint for quick payments - Payment notifications sent to 'reminders' topic in Telegram - Include member name, payment type, amount, and period in notification Files: - New: apps/miniapp/src/components/ui/toast.tsx - Modified: apps/miniapp/src/routes/home.tsx, apps/miniapp/src/index.css, apps/miniapp/src/theme.css, apps/miniapp/src/i18n.ts, apps/bot/src/miniapp-billing.ts, apps/bot/src/server.ts Quality Gates: ✅ format, lint, typecheck, build, test Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2198 lines
63 KiB
TypeScript
2198 lines
63 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
||
|
||
import type { FinanceCommandService } from '@household/application'
|
||
import { Money } from '@household/domain'
|
||
import type {
|
||
HouseholdConfigurationRepository,
|
||
ProcessedBotMessageRepository,
|
||
TelegramPendingActionRecord,
|
||
TelegramPendingActionRepository,
|
||
TopicMessageHistoryRecord,
|
||
TopicMessageHistoryRepository
|
||
} from '@household/ports'
|
||
|
||
import { createTelegramBot } from './bot'
|
||
import {
|
||
createInMemoryAssistantConversationMemoryStore,
|
||
createInMemoryAssistantRateLimiter,
|
||
createInMemoryAssistantUsageTracker,
|
||
registerDmAssistant
|
||
} from './dm-assistant'
|
||
import {
|
||
registerConfiguredPurchaseTopicIngestion,
|
||
type PurchaseMessageIngestionRepository
|
||
} from './purchase-topic-ingestion'
|
||
|
||
function createTestBot() {
|
||
const bot = createTelegramBot('000000:test-token')
|
||
|
||
bot.botInfo = {
|
||
id: 999000,
|
||
is_bot: true,
|
||
first_name: 'Household Test Bot',
|
||
username: 'household_test_bot',
|
||
can_join_groups: true,
|
||
can_read_all_group_messages: false,
|
||
supports_inline_queries: false,
|
||
can_connect_to_business: false,
|
||
has_main_web_app: false,
|
||
has_topics_enabled: true,
|
||
allows_users_to_create_topics: false
|
||
}
|
||
|
||
return bot
|
||
}
|
||
|
||
function privateMessageUpdate(text: string) {
|
||
return {
|
||
update_id: 2001,
|
||
message: {
|
||
message_id: 55,
|
||
date: Math.floor(Date.now() / 1000),
|
||
chat: {
|
||
id: 123456,
|
||
type: 'private'
|
||
},
|
||
from: {
|
||
id: 123456,
|
||
is_bot: false,
|
||
first_name: 'Stan',
|
||
language_code: 'en'
|
||
},
|
||
text
|
||
}
|
||
}
|
||
}
|
||
|
||
function topicMessageUpdate(
|
||
text: string,
|
||
options?: {
|
||
replyToBot?: boolean
|
||
fromId?: number
|
||
firstName?: string
|
||
updateId?: number
|
||
}
|
||
) {
|
||
return {
|
||
update_id: options?.updateId ?? 3001,
|
||
message: {
|
||
message_id: 88,
|
||
date: Math.floor(Date.now() / 1000),
|
||
message_thread_id: 777,
|
||
is_topic_message: true,
|
||
chat: {
|
||
id: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
from: {
|
||
id: options?.fromId ?? 123456,
|
||
is_bot: false,
|
||
first_name: options?.firstName ?? 'Stan',
|
||
language_code: 'en'
|
||
},
|
||
text,
|
||
...(options?.replyToBot
|
||
? {
|
||
reply_to_message: {
|
||
message_id: 87,
|
||
date: Math.floor(Date.now() / 1000),
|
||
chat: {
|
||
id: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
from: {
|
||
id: 999000,
|
||
is_bot: true,
|
||
first_name: 'Household Test Bot',
|
||
username: 'household_test_bot'
|
||
},
|
||
text: 'previous bot reply'
|
||
}
|
||
}
|
||
: {})
|
||
}
|
||
}
|
||
}
|
||
|
||
function topicMentionUpdate(text: string) {
|
||
return topicMessageUpdate(text)
|
||
}
|
||
|
||
function privateCallbackUpdate(data: string) {
|
||
return {
|
||
update_id: 2002,
|
||
callback_query: {
|
||
id: 'callback-1',
|
||
from: {
|
||
id: 123456,
|
||
is_bot: false,
|
||
first_name: 'Stan',
|
||
language_code: 'en'
|
||
},
|
||
chat_instance: 'instance-1',
|
||
data,
|
||
message: {
|
||
message_id: 77,
|
||
date: Math.floor(Date.now() / 1000),
|
||
chat: {
|
||
id: 123456,
|
||
type: 'private'
|
||
},
|
||
text: 'placeholder'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function createHouseholdRepository(): HouseholdConfigurationRepository {
|
||
const household = {
|
||
householdId: 'household-1',
|
||
householdName: 'Kojori House',
|
||
telegramChatId: '-100123',
|
||
telegramChatType: 'supergroup',
|
||
title: 'Kojori House',
|
||
defaultLocale: 'en' as const
|
||
}
|
||
|
||
return {
|
||
registerTelegramHouseholdChat: async () => ({
|
||
status: 'existing',
|
||
household
|
||
}),
|
||
getTelegramHouseholdChat: async () => household,
|
||
getHouseholdChatByHouseholdId: async () => household,
|
||
bindHouseholdTopic: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
getHouseholdTopicBinding: async () => null,
|
||
findHouseholdTopicByTelegramContext: async () => null,
|
||
listHouseholdTopicBindings: async () => [],
|
||
clearHouseholdTopicBindings: async () => {},
|
||
listReminderTargets: async () => [],
|
||
upsertHouseholdJoinToken: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
getHouseholdJoinToken: async () => null,
|
||
getHouseholdByJoinToken: async () => null,
|
||
upsertPendingHouseholdMember: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
getPendingHouseholdMember: async () => null,
|
||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||
ensureHouseholdMember: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
getHouseholdMember: async () => ({
|
||
id: 'member-1',
|
||
householdId: 'household-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
status: 'active',
|
||
preferredLocale: null,
|
||
householdDefaultLocale: 'en',
|
||
rentShareWeight: 1,
|
||
isAdmin: true
|
||
}),
|
||
listHouseholdMembers: async () => [
|
||
{
|
||
id: 'member-1',
|
||
householdId: 'household-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
status: 'active',
|
||
preferredLocale: null,
|
||
householdDefaultLocale: 'en',
|
||
rentShareWeight: 1,
|
||
isAdmin: true
|
||
},
|
||
{
|
||
id: 'member-2',
|
||
householdId: 'household-1',
|
||
telegramUserId: '222222',
|
||
displayName: 'Dima',
|
||
status: 'active',
|
||
preferredLocale: null,
|
||
householdDefaultLocale: 'en',
|
||
rentShareWeight: 1,
|
||
isAdmin: false
|
||
},
|
||
{
|
||
id: 'member-3',
|
||
householdId: 'household-1',
|
||
telegramUserId: '333333',
|
||
displayName: 'Chorbanaut',
|
||
status: 'away',
|
||
preferredLocale: null,
|
||
householdDefaultLocale: 'en',
|
||
rentShareWeight: 1,
|
||
isAdmin: false
|
||
}
|
||
],
|
||
getHouseholdBillingSettings: async () => ({
|
||
householdId: 'household-1',
|
||
settlementCurrency: 'GEL',
|
||
rentAmountMinor: 70000n,
|
||
rentCurrency: 'USD',
|
||
rentDueDay: 20,
|
||
rentWarningDay: 17,
|
||
utilitiesDueDay: 4,
|
||
utilitiesReminderDay: 3,
|
||
timezone: 'Asia/Tbilisi',
|
||
rentPaymentDestinations: null
|
||
}),
|
||
updateHouseholdBillingSettings: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
listHouseholdUtilityCategories: async () => [],
|
||
upsertHouseholdUtilityCategory: async () => {
|
||
throw new Error('not used')
|
||
},
|
||
listHouseholdMembersByTelegramUserId: async () => [
|
||
{
|
||
id: 'member-1',
|
||
householdId: 'household-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
status: 'active',
|
||
preferredLocale: null,
|
||
householdDefaultLocale: 'en',
|
||
rentShareWeight: 1,
|
||
isAdmin: true
|
||
}
|
||
],
|
||
listPendingHouseholdMembers: async () => [],
|
||
approvePendingHouseholdMember: async () => null,
|
||
rejectPendingHouseholdMember: async () => false,
|
||
updateHouseholdDefaultLocale: async () => household,
|
||
updateMemberPreferredLocale: async () => null,
|
||
updateHouseholdMemberDisplayName: async () => null,
|
||
promoteHouseholdAdmin: async () => null,
|
||
updateHouseholdMemberRentShareWeight: async () => null,
|
||
updateHouseholdMemberStatus: async () => null,
|
||
listHouseholdMemberAbsencePolicies: async () => [],
|
||
upsertHouseholdMemberAbsencePolicy: async () => null
|
||
}
|
||
}
|
||
|
||
function createBoundHouseholdRepository(
|
||
role: 'purchase' | 'payments'
|
||
): HouseholdConfigurationRepository {
|
||
const repository = createHouseholdRepository()
|
||
|
||
return {
|
||
...repository,
|
||
findHouseholdTopicByTelegramContext: async () => ({
|
||
householdId: 'household-1',
|
||
role,
|
||
telegramThreadId: '777',
|
||
topicName: role === 'purchase' ? 'Purchases' : 'Payments'
|
||
})
|
||
}
|
||
}
|
||
|
||
function createFinanceService(): FinanceCommandService {
|
||
return {
|
||
getMemberByTelegramUserId: async () => ({
|
||
id: 'member-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
rentShareWeight: 1,
|
||
isAdmin: true
|
||
}),
|
||
getOpenCycle: async () => null,
|
||
ensureExpectedCycle: async () => ({
|
||
id: 'cycle-1',
|
||
period: '2026-03',
|
||
currency: 'GEL'
|
||
}),
|
||
getAdminCycleState: async () => ({
|
||
cycle: null,
|
||
rentRule: null,
|
||
utilityBills: []
|
||
}),
|
||
openCycle: async () => ({
|
||
id: 'cycle-1',
|
||
period: '2026-03',
|
||
currency: 'GEL'
|
||
}),
|
||
closeCycle: async () => null,
|
||
setRent: async () => null,
|
||
addUtilityBill: async () => null,
|
||
updateUtilityBill: async () => null,
|
||
deleteUtilityBill: async () => false,
|
||
updatePurchase: async () => null,
|
||
deletePurchase: async () => false,
|
||
addPayment: async (_memberId, kind, amountArg, currencyArg) => ({
|
||
paymentId: 'payment-1',
|
||
amount: {
|
||
amountMinor: (BigInt(amountArg.replace('.', '')) * 100n) / 100n,
|
||
currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD',
|
||
toMajorString: () => amountArg
|
||
} as never,
|
||
currency: (currencyArg ?? 'GEL') as 'GEL' | 'USD',
|
||
period: '2026-03'
|
||
}),
|
||
addPurchase: async () => ({
|
||
purchaseId: 'test-purchase',
|
||
amount: Money.fromMinor(0n, 'GEL'),
|
||
currency: 'GEL'
|
||
}),
|
||
updatePayment: async () => null,
|
||
deletePayment: async () => false,
|
||
generateDashboard: async () => ({
|
||
period: '2026-03',
|
||
currency: 'GEL',
|
||
timezone: 'Asia/Tbilisi',
|
||
rentWarningDay: 17,
|
||
rentDueDay: 20,
|
||
utilitiesReminderDay: 3,
|
||
utilitiesDueDay: 4,
|
||
paymentBalanceAdjustmentPolicy: 'utilities',
|
||
rentPaymentDestinations: null,
|
||
totalDue: Money.fromMajor('1000.00', 'GEL'),
|
||
totalPaid: Money.fromMajor('500.00', 'GEL'),
|
||
totalRemaining: Money.fromMajor('500.00', 'GEL'),
|
||
rentSourceAmount: Money.fromMajor('700.00', 'USD'),
|
||
rentDisplayAmount: Money.fromMajor('1890.00', 'GEL'),
|
||
rentFxRateMicros: null,
|
||
rentFxEffectiveDate: null,
|
||
members: [
|
||
{
|
||
memberId: 'member-1',
|
||
displayName: 'Stan',
|
||
rentShare: Money.fromMajor('700.00', 'GEL'),
|
||
utilityShare: Money.fromMajor('100.00', 'GEL'),
|
||
purchaseOffset: Money.fromMajor('50.00', 'GEL'),
|
||
netDue: Money.fromMajor('850.00', 'GEL'),
|
||
paid: Money.fromMajor('500.00', 'GEL'),
|
||
remaining: Money.fromMajor('350.00', 'GEL'),
|
||
explanations: []
|
||
},
|
||
{
|
||
memberId: 'member-2',
|
||
displayName: 'Dima',
|
||
rentShare: Money.fromMajor('700.00', 'GEL'),
|
||
utilityShare: Money.fromMajor('100.00', 'GEL'),
|
||
purchaseOffset: Money.fromMajor('15.00', 'GEL'),
|
||
netDue: Money.fromMajor('815.00', 'GEL'),
|
||
paid: Money.fromMajor('200.00', 'GEL'),
|
||
remaining: Money.fromMajor('615.00', 'GEL'),
|
||
explanations: []
|
||
},
|
||
{
|
||
memberId: 'member-3',
|
||
displayName: 'Chorbanaut',
|
||
rentShare: Money.fromMajor('700.00', 'GEL'),
|
||
utilityShare: Money.fromMajor('0.00', 'GEL'),
|
||
purchaseOffset: Money.fromMajor('-20.00', 'GEL'),
|
||
netDue: Money.fromMajor('680.00', 'GEL'),
|
||
paid: Money.fromMajor('100.00', 'GEL'),
|
||
remaining: Money.fromMajor('580.00', 'GEL'),
|
||
explanations: []
|
||
}
|
||
],
|
||
ledger: [
|
||
{
|
||
id: 'purchase-1',
|
||
kind: 'purchase' as const,
|
||
title: 'Soap',
|
||
memberId: 'member-1',
|
||
amount: Money.fromMajor('30.00', 'GEL'),
|
||
currency: 'GEL' as const,
|
||
displayAmount: Money.fromMajor('30.00', 'GEL'),
|
||
displayCurrency: 'GEL' as const,
|
||
fxRateMicros: null,
|
||
fxEffectiveDate: null,
|
||
actorDisplayName: 'Stan',
|
||
occurredAt: '2026-03-12T11:00:00.000Z',
|
||
paymentKind: null
|
||
}
|
||
]
|
||
}),
|
||
generateStatement: async () => null
|
||
}
|
||
}
|
||
|
||
function createPromptRepository(): TelegramPendingActionRepository {
|
||
let pending: TelegramPendingActionRecord | null = null
|
||
|
||
return {
|
||
async upsertPendingAction(input) {
|
||
pending = input
|
||
return input
|
||
},
|
||
async getPendingAction() {
|
||
return pending
|
||
},
|
||
async clearPendingAction() {
|
||
pending = null
|
||
},
|
||
async clearPendingActionsForChat(telegramChatId, action) {
|
||
if (!pending || pending.telegramChatId !== telegramChatId) {
|
||
return
|
||
}
|
||
|
||
if (action && pending.action !== action) {
|
||
return
|
||
}
|
||
|
||
pending = null
|
||
}
|
||
}
|
||
}
|
||
|
||
function createPurchaseRepository(): PurchaseMessageIngestionRepository {
|
||
const clarificationKeys = new Set<string>()
|
||
const proposals = new Map<
|
||
string,
|
||
{
|
||
householdId: string
|
||
senderTelegramUserId: string
|
||
parsedAmountMinor: bigint
|
||
parsedCurrency: 'GEL' | 'USD'
|
||
parsedItemDescription: string
|
||
participants: readonly {
|
||
id: string
|
||
memberId: string
|
||
displayName: string
|
||
included: boolean
|
||
}[]
|
||
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',
|
||
participants: [
|
||
{
|
||
id: 'participant-1',
|
||
memberId: 'member-1',
|
||
displayName: 'Mia',
|
||
included: true
|
||
}
|
||
],
|
||
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,
|
||
participants: [
|
||
{
|
||
id: 'participant-1',
|
||
memberId: 'member-1',
|
||
displayName: 'Mia',
|
||
included: true
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
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',
|
||
participants: [
|
||
{
|
||
id: 'participant-1',
|
||
memberId: 'member-1',
|
||
displayName: 'Mia',
|
||
included: true
|
||
}
|
||
],
|
||
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,
|
||
participants: [
|
||
{
|
||
id: 'participant-1',
|
||
memberId: 'member-1',
|
||
displayName: 'Mia',
|
||
included: true
|
||
}
|
||
]
|
||
}
|
||
}
|
||
|
||
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,
|
||
participants: proposal.participants
|
||
}
|
||
}
|
||
|
||
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,
|
||
participants: proposal.participants
|
||
}
|
||
},
|
||
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,
|
||
participants: proposal.participants
|
||
}
|
||
}
|
||
|
||
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,
|
||
participants: proposal.participants
|
||
}
|
||
},
|
||
async toggleParticipant() {
|
||
throw new Error('not used')
|
||
}
|
||
}
|
||
}
|
||
|
||
function createProcessedBotMessageRepository(): ProcessedBotMessageRepository {
|
||
const claims = new Set<string>()
|
||
|
||
return {
|
||
async claimMessage(input) {
|
||
const key = `${input.householdId}:${input.source}:${input.sourceMessageKey}`
|
||
if (claims.has(key)) {
|
||
return {
|
||
claimed: false
|
||
}
|
||
}
|
||
|
||
claims.add(key)
|
||
|
||
return {
|
||
claimed: true
|
||
}
|
||
},
|
||
async releaseMessage(input) {
|
||
claims.delete(`${input.householdId}:${input.source}:${input.sourceMessageKey}`)
|
||
}
|
||
}
|
||
}
|
||
|
||
function createTopicMessageHistoryRepository(): TopicMessageHistoryRepository {
|
||
const rows: TopicMessageHistoryRecord[] = []
|
||
|
||
return {
|
||
async saveMessage(input) {
|
||
rows.push(input)
|
||
},
|
||
async listRecentThreadMessages(input) {
|
||
return rows
|
||
.filter(
|
||
(row) =>
|
||
row.householdId === input.householdId &&
|
||
row.telegramChatId === input.telegramChatId &&
|
||
row.telegramThreadId === input.telegramThreadId
|
||
)
|
||
.slice(-input.limit)
|
||
},
|
||
async listRecentChatMessages(input) {
|
||
return rows
|
||
.filter(
|
||
(row) =>
|
||
row.householdId === input.householdId &&
|
||
row.telegramChatId === input.telegramChatId &&
|
||
row.messageSentAt &&
|
||
row.messageSentAt.epochMilliseconds >= input.sentAtOrAfter.epochMilliseconds
|
||
)
|
||
.slice(-input.limit)
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('registerDmAssistant', () => {
|
||
test('replies with a conversational DM answer and records token usage', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const usageTracker = createInMemoryAssistantUsageTracker()
|
||
|
||
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() {
|
||
return {
|
||
text: 'You still owe 350.00 GEL this cycle.',
|
||
usage: {
|
||
inputTokens: 100,
|
||
outputTokens: 25,
|
||
totalTokens: 125
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker
|
||
})
|
||
|
||
await bot.handleUpdate(privateMessageUpdate('How much do I still owe this month?') as never)
|
||
|
||
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: 'You still owe 350.00 GEL this cycle.'
|
||
}
|
||
})
|
||
expect(usageTracker.listHouseholdUsage('household-1')).toEqual([
|
||
{
|
||
householdId: 'household-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
requestCount: 1,
|
||
inputTokens: 100,
|
||
outputTokens: 25,
|
||
totalTokens: 125,
|
||
updatedAt: expect.any(String)
|
||
}
|
||
])
|
||
})
|
||
|
||
test('creates a payment confirmation proposal in DM', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const promptRepository = createPromptRepository()
|
||
|
||
bot.api.config.use(async (_prev, method, payload) => {
|
||
calls.push({ method, payload })
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository,
|
||
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 paid the rent') as never)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]?.payload).toMatchObject({
|
||
text: expect.stringContaining('I can record this rent payment: 700.00 GEL.'),
|
||
reply_markup: {
|
||
inline_keyboard: [
|
||
[
|
||
{
|
||
text: 'Confirm payment',
|
||
callback_data: expect.stringContaining('assistant_payment:confirm:')
|
||
},
|
||
{
|
||
text: 'Cancel',
|
||
callback_data: expect.stringContaining('assistant_payment:cancel:')
|
||
}
|
||
]
|
||
]
|
||
}
|
||
})
|
||
|
||
const pending = await promptRepository.getPendingAction('123456', '123456')
|
||
expect(pending?.action).toBe('assistant_payment_confirmation')
|
||
expect(pending?.payload).toMatchObject({
|
||
householdId: 'household-1',
|
||
memberId: 'member-1',
|
||
kind: 'rent',
|
||
amountMinor: '70000',
|
||
currency: 'GEL'
|
||
})
|
||
})
|
||
|
||
test('answers utilities balance questions deterministically 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,
|
||
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 much do I owe for utilities?') as never)
|
||
|
||
const replyCall = calls.find((call) => call.method === 'sendMessage')
|
||
expect(replyCall).toBeDefined()
|
||
const replyText = String((replyCall?.payload as { text?: unknown } | undefined)?.text ?? '')
|
||
expect(replyText).toContain('Current utilities payment guidance:')
|
||
expect(replyText).toContain('Utilities due: 100.00 GEL')
|
||
expect(replyText).toContain('Purchase balance: 50.00 GEL')
|
||
expect(replyText).toContain('Suggested payment under utilities adjustment: 150.00 GEL')
|
||
})
|
||
|
||
test('answers household roster questions from real member data', 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,
|
||
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('Who do we have in the household?') as never)
|
||
|
||
const replyText = String(
|
||
(
|
||
calls.find((call) => call.method === 'sendMessage')?.payload as
|
||
| { text?: unknown }
|
||
| undefined
|
||
)?.text ?? ''
|
||
)
|
||
expect(replyText).toContain('Current household members:')
|
||
expect(replyText).toContain('Stan (active)')
|
||
expect(replyText).toContain('Dima (active)')
|
||
expect(replyText).toContain('Chorbanaut (away)')
|
||
})
|
||
|
||
test('answers another member purchase balance from real data', 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,
|
||
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('What is Dima shared purchase balance?') as never)
|
||
|
||
const replyText = String(
|
||
(
|
||
calls.find((call) => call.method === 'sendMessage')?.payload as
|
||
| { text?: unknown }
|
||
| undefined
|
||
)?.text ?? ''
|
||
)
|
||
expect(replyText).toContain("Dima's shared purchase balance is 15.00 GEL.")
|
||
})
|
||
|
||
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('replies as the general assistant when explicitly mentioned in a household topic', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
assistantCalls += 1
|
||
expect(input.userMessage).toBe('how is life?')
|
||
return {
|
||
text: 'Still standing.',
|
||
usage: {
|
||
inputTokens: 15,
|
||
outputTokens: 4,
|
||
totalTokens: 19
|
||
}
|
||
}
|
||
}
|
||
},
|
||
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(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||
|
||
expect(assistantCalls).toBe(1)
|
||
expect(calls).toHaveLength(2)
|
||
expect(calls[0]).toMatchObject({
|
||
method: 'sendChatAction',
|
||
payload: {
|
||
chat_id: -100123,
|
||
action: 'typing',
|
||
message_thread_id: 777
|
||
}
|
||
})
|
||
expect(calls[1]).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
chat_id: -100123,
|
||
message_thread_id: 777,
|
||
text: 'Still standing.'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('uses the shared router for playful addressed topic replies without calling the full assistant', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
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: 'Should not be called.',
|
||
usage: {
|
||
inputTokens: 10,
|
||
outputTokens: 2,
|
||
totalTokens: 12
|
||
}
|
||
}
|
||
}
|
||
},
|
||
topicRouter: async () => ({
|
||
route: 'chat_reply',
|
||
replyText: 'Тут. Если что-то реально купили, подключусь.',
|
||
helperKind: null,
|
||
shouldStartTyping: false,
|
||
shouldClearWorkflow: false,
|
||
confidence: 96,
|
||
reason: 'smalltalk'
|
||
}),
|
||
purchaseRepository: createPurchaseRepository(),
|
||
purchaseInterpreter: async () => null,
|
||
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(topicMentionUpdate('@household_test_bot А ты тут?') as never)
|
||
|
||
expect(assistantCalls).toBe(0)
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
text: 'Тут. Если что-то реально купили, подключусь.'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('reuses the purchase-topic route instead of calling the shared router twice', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
let assistantCalls = 0
|
||
let routerCalls = 0
|
||
const householdConfigurationRepository = createBoundHouseholdRepository('purchase')
|
||
const topicRouter = async () => {
|
||
routerCalls += 1
|
||
|
||
return {
|
||
route: 'topic_helper' as const,
|
||
replyText: null,
|
||
helperKind: 'assistant' as const,
|
||
shouldStartTyping: true,
|
||
shouldClearWorkflow: false,
|
||
confidence: 96,
|
||
reason: 'question'
|
||
}
|
||
}
|
||
|
||
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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerConfiguredPurchaseTopicIngestion(
|
||
bot,
|
||
householdConfigurationRepository,
|
||
createPurchaseRepository(),
|
||
{
|
||
router: topicRouter
|
||
}
|
||
)
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond() {
|
||
assistantCalls += 1
|
||
return {
|
||
text: 'Still here.',
|
||
usage: {
|
||
inputTokens: 10,
|
||
outputTokens: 3,
|
||
totalTokens: 13
|
||
}
|
||
}
|
||
}
|
||
},
|
||
topicRouter,
|
||
purchaseRepository: createPurchaseRepository(),
|
||
purchaseInterpreter: async () => null,
|
||
householdConfigurationRepository,
|
||
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(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||
|
||
expect(routerCalls).toBe(1)
|
||
expect(assistantCalls).toBe(1)
|
||
expect(calls).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({
|
||
method: 'sendMessage',
|
||
payload: expect.objectContaining({
|
||
text: 'Still here.'
|
||
})
|
||
})
|
||
])
|
||
)
|
||
})
|
||
|
||
test('stays silent for regular group chatter when the bot is not addressed', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
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: 'I should not speak here.',
|
||
usage: {
|
||
inputTokens: 12,
|
||
outputTokens: 5,
|
||
totalTokens: 17
|
||
}
|
||
}
|
||
}
|
||
},
|
||
purchaseRepository: createPurchaseRepository(),
|
||
purchaseInterpreter: async () => null,
|
||
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(topicMessageUpdate('Dima is joking with Stas again') as never)
|
||
|
||
expect(assistantCalls).toBe(0)
|
||
expect(calls).toHaveLength(0)
|
||
})
|
||
|
||
test('creates a purchase proposal in a household topic without an explicit mention', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
expect(input.authoritativeFacts).toEqual([
|
||
'The purchase has not been saved yet.',
|
||
'Detected shared purchase: door handle - 30.00 GEL.',
|
||
'Buttons shown to the user are Confirm and Cancel.'
|
||
])
|
||
return {
|
||
text: 'Looks like a shared purchase: door handle - 30.00 GEL.',
|
||
usage: {
|
||
inputTokens: 10,
|
||
outputTokens: 2,
|
||
totalTokens: 12
|
||
}
|
||
}
|
||
}
|
||
},
|
||
purchaseRepository: createPurchaseRepository(),
|
||
purchaseInterpreter: async () => null,
|
||
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(topicMessageUpdate('I bought a door handle for 30 lari') as never)
|
||
|
||
expect(calls).toHaveLength(1)
|
||
expect(calls[0]).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
chat_id: -100123,
|
||
message_thread_id: 777,
|
||
text: 'Looks like a shared purchase: door handle - 30.00 GEL.',
|
||
reply_markup: {
|
||
inline_keyboard: [
|
||
[
|
||
{
|
||
text: 'Confirm',
|
||
callback_data: 'assistant_purchase:confirm:purchase-1'
|
||
},
|
||
{
|
||
text: 'Cancel',
|
||
callback_data: 'assistant_purchase:cancel:purchase-1'
|
||
}
|
||
]
|
||
]
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
test('replies when a household member answers the bot message in a topic', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
assistantCalls += 1
|
||
expect(input.userMessage).toBe('tell me a joke')
|
||
return {
|
||
text: 'Rent is still due on the 20th.',
|
||
usage: {
|
||
inputTokens: 17,
|
||
outputTokens: 8,
|
||
totalTokens: 25
|
||
}
|
||
}
|
||
}
|
||
},
|
||
purchaseRepository: createPurchaseRepository(),
|
||
purchaseInterpreter: async () => null,
|
||
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(
|
||
topicMessageUpdate('tell me a joke', {
|
||
replyToBot: true
|
||
}) as never
|
||
)
|
||
|
||
expect(assistantCalls).toBe(1)
|
||
expect(calls).toHaveLength(2)
|
||
expect(calls[0]).toMatchObject({
|
||
method: 'sendChatAction',
|
||
payload: {
|
||
chat_id: -100123,
|
||
action: 'typing',
|
||
message_thread_id: 777
|
||
}
|
||
})
|
||
expect(calls[1]).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
chat_id: -100123,
|
||
message_thread_id: 777,
|
||
text: 'Rent is still due on the 20th.'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('loads persisted thread and same-day chat history including bot replies', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const topicMessageHistoryRepository = createTopicMessageHistoryRepository()
|
||
let recentThreadTexts: string[] = []
|
||
let sameDayTexts: string[] = []
|
||
|
||
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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
recentThreadTexts = input.recentThreadMessages?.map((message) => message.text) ?? []
|
||
sameDayTexts = input.sameDayChatMessages?.map((message) => message.text) ?? []
|
||
|
||
return {
|
||
text: 'Yes. You were discussing a TV for the house.',
|
||
usage: {
|
||
inputTokens: 20,
|
||
outputTokens: 9,
|
||
totalTokens: 29
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker: createInMemoryAssistantUsageTracker(),
|
||
topicMessageHistoryRepository
|
||
})
|
||
|
||
await bot.handleUpdate(topicMessageUpdate('I think we need a TV in the house') as never)
|
||
await bot.handleUpdate(
|
||
topicMentionUpdate('@household_test_bot do you remember what we said today?') as never
|
||
)
|
||
await bot.handleUpdate(
|
||
topicMentionUpdate('@household_test_bot do you remember what you answered?') as never
|
||
)
|
||
|
||
expect(recentThreadTexts).toContain('I think we need a TV in the house')
|
||
expect(recentThreadTexts).toContain('Yes. You were discussing a TV for the house.')
|
||
expect(sameDayTexts).toContain('I think we need a TV in the house')
|
||
expect(sameDayTexts).toContain('Yes. You were discussing a TV for the house.')
|
||
expect(calls.at(-1)).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
text: 'Yes. You were discussing a TV for the house.'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('uses rolling chat history for summary questions instead of finance helper replies', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const topicMessageHistoryRepository = createTopicMessageHistoryRepository()
|
||
let sameDayTexts: string[] = []
|
||
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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
assistantCalls += 1
|
||
sameDayTexts = input.sameDayChatMessages?.map((message) => message.text) ?? []
|
||
|
||
return {
|
||
text: 'В чате ты говорил, что думаешь о семечках.',
|
||
usage: {
|
||
inputTokens: 24,
|
||
outputTokens: 10,
|
||
totalTokens: 34
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker: createInMemoryAssistantUsageTracker(),
|
||
topicMessageHistoryRepository
|
||
})
|
||
|
||
await bot.handleUpdate(topicMessageUpdate('Я думаю о семечках') as never)
|
||
await bot.handleUpdate(
|
||
topicMentionUpdate('@household_test_bot можешь дать сводку, что происходило в чате?') as never
|
||
)
|
||
|
||
expect(assistantCalls).toBe(1)
|
||
expect(sameDayTexts).toContain('Я думаю о семечках')
|
||
expect(calls.at(-1)).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
text: 'В чате ты говорил, что думаешь о семечках.'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('responds to strong contextual follow-ups without a repeated mention', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
text: (payload as { text?: string }).text ?? 'ok'
|
||
}
|
||
} as never
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
assistant: {
|
||
async respond(input) {
|
||
assistantCalls += 1
|
||
|
||
return {
|
||
text:
|
||
assistantCalls === 1
|
||
? 'Still standing.'
|
||
: `Отвечаю по контексту: ${input.userMessage}`,
|
||
usage: {
|
||
inputTokens: 15,
|
||
outputTokens: 8,
|
||
totalTokens: 23
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker: createInMemoryAssistantUsageTracker(),
|
||
topicMessageHistoryRepository: createTopicMessageHistoryRepository()
|
||
})
|
||
|
||
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||
await bot.handleUpdate(
|
||
topicMessageUpdate('Вопрос выше, я уже задал, ты просто не ответил') as never
|
||
)
|
||
|
||
expect(assistantCalls).toBe(2)
|
||
expect(calls.at(-1)).toMatchObject({
|
||
method: 'sendMessage',
|
||
payload: {
|
||
text: 'Отвечаю по контексту: Вопрос выше, я уже задал, ты просто не ответил'
|
||
}
|
||
})
|
||
})
|
||
|
||
test('stays silent for casual follow-ups after a recent bot reply', 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: -100123,
|
||
type: 'supergroup'
|
||
},
|
||
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: 'Still standing.',
|
||
usage: {
|
||
inputTokens: 15,
|
||
outputTokens: 8,
|
||
totalTokens: 23
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker: createInMemoryAssistantUsageTracker(),
|
||
topicMessageHistoryRepository: createTopicMessageHistoryRepository()
|
||
})
|
||
|
||
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||
await bot.handleUpdate(topicMessageUpdate('ok', { updateId: 3002 }) as never)
|
||
|
||
expect(assistantCalls).toBe(1)
|
||
expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1)
|
||
})
|
||
|
||
test('ignores duplicate deliveries of the same DM update', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const usageTracker = createInMemoryAssistantUsageTracker()
|
||
|
||
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() {
|
||
return {
|
||
text: 'You still owe 350.00 GEL this cycle.',
|
||
usage: {
|
||
inputTokens: 100,
|
||
outputTokens: 25,
|
||
totalTokens: 125
|
||
}
|
||
}
|
||
}
|
||
},
|
||
householdConfigurationRepository: createHouseholdRepository(),
|
||
messageProcessingRepository: createProcessedBotMessageRepository(),
|
||
promptRepository: createPromptRepository(),
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker
|
||
})
|
||
|
||
const update = privateMessageUpdate('How much do I still owe this month?')
|
||
await bot.handleUpdate(update as never)
|
||
await bot.handleUpdate(update as never)
|
||
|
||
expect(calls).toHaveLength(2)
|
||
expect(usageTracker.listHouseholdUsage('household-1')).toEqual([
|
||
{
|
||
householdId: 'household-1',
|
||
telegramUserId: '123456',
|
||
displayName: 'Stan',
|
||
requestCount: 1,
|
||
inputTokens: 100,
|
||
outputTokens: 25,
|
||
totalTokens: 125,
|
||
updatedAt: expect.any(String)
|
||
}
|
||
])
|
||
})
|
||
|
||
test('confirms a pending payment proposal from DM callback', async () => {
|
||
const bot = createTestBot()
|
||
const calls: Array<{ method: string; payload: unknown }> = []
|
||
const promptRepository = createPromptRepository()
|
||
const repository = createHouseholdRepository()
|
||
|
||
await promptRepository.upsertPendingAction({
|
||
telegramUserId: '123456',
|
||
telegramChatId: '123456',
|
||
action: 'assistant_payment_confirmation',
|
||
payload: {
|
||
proposalId: 'proposal-1',
|
||
householdId: 'household-1',
|
||
memberId: 'member-1',
|
||
kind: 'rent',
|
||
amountMinor: '70000',
|
||
currency: 'GEL'
|
||
},
|
||
expiresAt: null
|
||
})
|
||
|
||
bot.api.config.use(async (_prev, method, payload) => {
|
||
calls.push({ method, payload })
|
||
return {
|
||
ok: true,
|
||
result: true
|
||
} as never
|
||
})
|
||
|
||
registerDmAssistant({
|
||
bot,
|
||
householdConfigurationRepository: repository,
|
||
promptRepository,
|
||
financeServiceForHousehold: () => createFinanceService(),
|
||
memoryStore: createInMemoryAssistantConversationMemoryStore(12),
|
||
rateLimiter: createInMemoryAssistantRateLimiter({
|
||
burstLimit: 5,
|
||
burstWindowMs: 60_000,
|
||
rollingLimit: 50,
|
||
rollingWindowMs: 86_400_000
|
||
}),
|
||
usageTracker: createInMemoryAssistantUsageTracker()
|
||
})
|
||
|
||
await bot.handleUpdate(privateCallbackUpdate('assistant_payment:confirm:proposal-1') as never)
|
||
|
||
expect(calls[0]).toMatchObject({
|
||
method: 'answerCallbackQuery',
|
||
payload: {
|
||
callback_query_id: 'callback-1',
|
||
text: 'Recorded rent payment: 700.00 GEL'
|
||
}
|
||
})
|
||
expect(calls[1]).toMatchObject({
|
||
method: 'editMessageText',
|
||
payload: {
|
||
chat_id: 123456,
|
||
message_id: 77,
|
||
text: 'Recorded rent payment: 700.00 GEL'
|
||
}
|
||
})
|
||
expect(await promptRepository.getPendingAction('123456', '123456')).toBeNull()
|
||
})
|
||
})
|