mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 10:24:02 +00:00
feat(bot): quiet finance topics and support purchase payers
This commit is contained in:
@@ -1417,7 +1417,7 @@ Confirm or cancel below.`,
|
||||
})
|
||||
})
|
||||
|
||||
test('uses topic processor for classification and assistant for response', async () => {
|
||||
test('does not hand finance-topic helper routing over to the generic assistant', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
let assistantCalls = 0
|
||||
@@ -1493,17 +1493,8 @@ Confirm or cancel below.`,
|
||||
await bot.handleUpdate(topicMentionUpdate('@household_test_bot how is life?') as never)
|
||||
|
||||
expect(processorCalls).toBe(1)
|
||||
expect(assistantCalls).toBe(1)
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
method: 'sendMessage',
|
||||
payload: expect.objectContaining({
|
||||
text: 'Still here.'
|
||||
})
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(assistantCalls).toBe(0)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('stays silent for regular group chatter when the bot is not addressed', async () => {
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export function registerDmAssistant(options: {
|
||||
? getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
: purchaseResult.status === 'clarification_needed'
|
||||
@@ -1502,6 +1503,7 @@ export function registerDmAssistant(options: {
|
||||
const fallbackText = getBotTranslations(locale).purchase.proposal(
|
||||
formatPurchaseSummary(locale, purchaseResult),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
const purchaseText = await composeAssistantReplyText({
|
||||
|
||||
@@ -875,8 +875,7 @@ describe('registerHouseholdSetupCommands', () => {
|
||||
}
|
||||
})
|
||||
expect(sendPayload.text).toContain('New household! **Kojori House** is ready.')
|
||||
expect(sendPayload.text).toContain('Current setup progress: 0/5')
|
||||
expect(sendPayload.text).toContain('0/5')
|
||||
expect(sendPayload.text).toContain('Current setup progress: 0/4')
|
||||
expect(sendPayload.text).toContain('⚪ Purchases')
|
||||
expect(sendPayload.text).toContain('⚪ Payments')
|
||||
// Check that join household button exists
|
||||
|
||||
@@ -22,7 +22,6 @@ const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
||||
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
|
||||
|
||||
const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [
|
||||
'chat',
|
||||
'purchase',
|
||||
'feedback',
|
||||
'reminders',
|
||||
@@ -1104,7 +1103,7 @@ export function registerHouseholdSetupCommands(options: {
|
||||
)
|
||||
|
||||
options.bot.callbackQuery(
|
||||
/^bind_topic:(chat|purchase|feedback|reminders|payments):(\d+)$/,
|
||||
/^bind_topic:(purchase|feedback|reminders|payments):(\d+)$/,
|
||||
async (ctx) => {
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
|
||||
@@ -276,8 +276,13 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'shared purchase',
|
||||
processing: 'Checking that purchase...',
|
||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
||||
`I think this shared purchase was: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||
proposal: (
|
||||
summary: string,
|
||||
payer: string | null,
|
||||
calculationNote: string | null,
|
||||
participants: string | null
|
||||
) =>
|
||||
`I think this shared purchase was: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nConfirm or cancel below.`,
|
||||
calculatedAmountNote: (explanation: string | null) =>
|
||||
explanation
|
||||
? `I calculated the total as ${explanation}. Is that right?`
|
||||
@@ -295,6 +300,12 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
participantExcluded: (displayName) => `- ${displayName} (excluded)`,
|
||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||
payerHeading: 'Paid by:',
|
||||
payerSelected: (displayName) => `Paid by: ${displayName}`,
|
||||
payerQuestion: 'Who actually bought this?',
|
||||
payerFallbackQuestion: 'I could not tell who bought this. Pick the payer below.',
|
||||
payerButton: (displayName) => `${displayName} paid`,
|
||||
payerSelectedToast: (displayName) => `Set payer to ${displayName}.`,
|
||||
confirmButton: 'Confirm',
|
||||
calculatedConfirmButton: 'Looks right',
|
||||
calculatedFixAmountButton: 'Fix amount',
|
||||
|
||||
@@ -280,8 +280,13 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'общая покупка',
|
||||
processing: 'Проверяю покупку...',
|
||||
proposal: (summary: string, calculationNote: string | null, participants: string | null) =>
|
||||
`Похоже, это общая покупка: ${summary}.${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||
proposal: (
|
||||
summary: string,
|
||||
payer: string | null,
|
||||
calculationNote: string | null,
|
||||
participants: string | null
|
||||
) =>
|
||||
`Похоже, это общая покупка: ${summary}.${payer ? `\n${payer}` : ''}${calculationNote ? `\n${calculationNote}` : ''}${participants ? `\n\n${participants}` : ''}\nПодтвердите или отмените ниже.`,
|
||||
calculatedAmountNote: (explanation: string | null) =>
|
||||
explanation
|
||||
? `Я посчитал итог как ${explanation}. Всё верно?`
|
||||
@@ -299,6 +304,12 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
participantExcluded: (displayName) => `- ${displayName} (не участвует)`,
|
||||
participantToggleIncluded: (displayName) => `✅ ${displayName}`,
|
||||
participantToggleExcluded: (displayName) => `⬜ ${displayName}`,
|
||||
payerHeading: 'Кто оплатил:',
|
||||
payerSelected: (displayName) => `Оплатил: ${displayName}`,
|
||||
payerQuestion: 'Кто именно это купил?',
|
||||
payerFallbackQuestion: 'Не понял, кто именно это купил. Выберите человека ниже.',
|
||||
payerButton: (displayName) => `Оплатил ${displayName}`,
|
||||
payerSelectedToast: (displayName) => `Записал покупателя: ${displayName}.`,
|
||||
confirmButton: 'Подтвердить',
|
||||
calculatedConfirmButton: 'Верно',
|
||||
calculatedFixAmountButton: 'Исправить сумму',
|
||||
|
||||
@@ -264,6 +264,7 @@ export interface BotTranslationCatalog {
|
||||
processing: string
|
||||
proposal: (
|
||||
summary: string,
|
||||
payer: string | null,
|
||||
calculationNote: string | null,
|
||||
participants: string | null
|
||||
) => string
|
||||
@@ -279,6 +280,12 @@ export interface BotTranslationCatalog {
|
||||
participantExcluded: (displayName: string) => string
|
||||
participantToggleIncluded: (displayName: string) => string
|
||||
participantToggleExcluded: (displayName: string) => string
|
||||
payerHeading: string
|
||||
payerSelected: (displayName: string) => string
|
||||
payerQuestion: string
|
||||
payerFallbackQuestion: string
|
||||
payerButton: (displayName: string) => string
|
||||
payerSelectedToast: (displayName: string) => string
|
||||
confirmButton: string
|
||||
calculatedConfirmButton: string
|
||||
calculatedFixAmountButton: string
|
||||
|
||||
@@ -15,10 +15,29 @@ import type {
|
||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||
|
||||
function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(new Date())
|
||||
const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0')
|
||||
const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1')
|
||||
const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1')
|
||||
const carryMonth = day > rentDueDay ? month + 1 : month
|
||||
const normalizedYear = carryMonth > 12 ? year + 1 : year
|
||||
const normalizedMonth = carryMonth > 12 ? 1 : carryMonth
|
||||
|
||||
return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function repository(
|
||||
member: Awaited<ReturnType<FinanceRepository['getMemberByTelegramUserId']>>
|
||||
): FinanceRepository {
|
||||
const cycle = {
|
||||
let cycle: Awaited<ReturnType<FinanceRepository['getOpenCycle']>> extends infer T
|
||||
? Exclude<T, null>
|
||||
: never = {
|
||||
id: 'cycle-1',
|
||||
period: '2026-03',
|
||||
currency: 'GEL' as const
|
||||
@@ -38,7 +57,13 @@ function repository(
|
||||
getOpenCycle: async () => cycle,
|
||||
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
|
||||
getLatestCycle: async () => cycle,
|
||||
openCycle: async () => {},
|
||||
openCycle: async (period, currency) => {
|
||||
cycle = {
|
||||
id: 'opened-cycle',
|
||||
period,
|
||||
currency
|
||||
}
|
||||
},
|
||||
closeCycle: async () => {},
|
||||
saveRentRule: async () => {},
|
||||
getCycleExchangeRate: async () => null,
|
||||
@@ -326,7 +351,7 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
ok: true,
|
||||
authorized: true,
|
||||
dashboard: {
|
||||
period: '2026-03',
|
||||
period: expectedCurrentCyclePeriod('Asia/Tbilisi', 20),
|
||||
currency: 'GEL',
|
||||
paymentBalanceAdjustmentPolicy: 'utilities',
|
||||
totalDueMajor: '2010.00',
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PurchaseInterpretation {
|
||||
amountMinor: bigint | null
|
||||
currency: 'GEL' | 'USD' | null
|
||||
itemDescription: string | null
|
||||
payerMemberId?: string | null
|
||||
amountSource?: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation?: string | null
|
||||
participantMemberIds?: readonly string[] | null
|
||||
@@ -43,6 +44,7 @@ interface OpenAiStructuredResult {
|
||||
amountMinor: string | null
|
||||
currency: 'GEL' | 'USD' | null
|
||||
itemDescription: string | null
|
||||
payerMemberId: string | null
|
||||
amountSource: PurchaseInterpretationAmountSource | null
|
||||
calculationExplanation: string | null
|
||||
participantMemberIds: string[] | null
|
||||
@@ -104,6 +106,26 @@ function normalizeParticipantMemberIds(
|
||||
return normalized.length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function normalizePayerMemberId(
|
||||
value: string | null | undefined,
|
||||
householdMembers: readonly PurchaseInterpreterHouseholdMember[] | undefined
|
||||
): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalized = value.trim()
|
||||
if (normalized.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!householdMembers) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return householdMembers.some((member) => member.memberId === normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function resolveMissingCurrency(input: {
|
||||
decision: PurchaseInterpretationDecision
|
||||
amountMinor: bigint | null
|
||||
@@ -198,6 +220,7 @@ export function createOpenAiPurchaseInterpreter(
|
||||
'If the latest message is a complete standalone purchase on its own, ignore the earlier clarification context.',
|
||||
'If the latest message answers a previous clarification, combine it with the earlier messages to resolve the purchase.',
|
||||
'If a household member roster is provided and the user explicitly says who shares the purchase, return participantMemberIds as the included member IDs.',
|
||||
'If a household member roster is provided and the user explicitly says who paid for the purchase, return payerMemberId.',
|
||||
'For phrases like "split with Dima", "for me and Alice", or similar, include the sender and the explicitly mentioned household members in participantMemberIds.',
|
||||
'If the message does not clearly specify a participant subset, return participantMemberIds as null.',
|
||||
'Away members may still be included when the user explicitly names them.',
|
||||
@@ -260,6 +283,9 @@ export function createOpenAiPurchaseInterpreter(
|
||||
itemDescription: {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||
},
|
||||
payerMemberId: {
|
||||
anyOf: [{ type: 'string' }, { type: 'null' }]
|
||||
},
|
||||
amountSource: {
|
||||
anyOf: [
|
||||
{
|
||||
@@ -295,6 +321,7 @@ export function createOpenAiPurchaseInterpreter(
|
||||
'amountMinor',
|
||||
'currency',
|
||||
'itemDescription',
|
||||
'payerMemberId',
|
||||
'amountSource',
|
||||
'calculationExplanation',
|
||||
'participantMemberIds',
|
||||
@@ -339,6 +366,7 @@ export function createOpenAiPurchaseInterpreter(
|
||||
|
||||
const amountMinor = asOptionalBigInt(parsedJson.amountMinor)
|
||||
const itemDescription = normalizeOptionalText(parsedJson.itemDescription)
|
||||
const payerMemberId = normalizePayerMemberId(parsedJson.payerMemberId, options.householdMembers)
|
||||
const amountSource = normalizeAmountSource(parsedJson.amountSource, amountMinor)
|
||||
const calculationExplanation = normalizeOptionalText(parsedJson.calculationExplanation)
|
||||
const participantMemberIds = normalizeParticipantMemberIds(
|
||||
@@ -376,6 +404,10 @@ export function createOpenAiPurchaseInterpreter(
|
||||
clarificationQuestion: decision === 'clarification' ? clarificationQuestion : null
|
||||
}
|
||||
|
||||
if (payerMemberId) {
|
||||
result.payerMemberId = payerMemberId
|
||||
}
|
||||
|
||||
if (participantMemberIds) {
|
||||
result.participantMemberIds = participantMemberIds
|
||||
}
|
||||
|
||||
@@ -174,6 +174,28 @@ function formatPaymentBreakdown(locale: BotLocale, breakdown: PaymentProposalBre
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function shouldUseCompactTopicProposal(input: {
|
||||
surface: 'assistant' | 'topic'
|
||||
breakdown: PaymentProposalBreakdown
|
||||
}): boolean {
|
||||
if (input.surface !== 'topic') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.breakdown.guidance.kind !== 'rent') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.breakdown.guidance.adjustmentPolicy !== 'utilities') {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
input.breakdown.explicitAmount === null ||
|
||||
input.breakdown.explicitAmount.equals(input.breakdown.guidance.proposalAmount)
|
||||
)
|
||||
}
|
||||
|
||||
export function formatPaymentProposalText(input: {
|
||||
locale: BotLocale
|
||||
surface: 'assistant' | 'topic'
|
||||
@@ -199,6 +221,15 @@ export function formatPaymentProposalText(input: {
|
||||
amount.currency
|
||||
)
|
||||
|
||||
if (
|
||||
shouldUseCompactTopicProposal({
|
||||
surface: input.surface,
|
||||
breakdown: input.proposal.breakdown
|
||||
})
|
||||
) {
|
||||
return intro
|
||||
}
|
||||
|
||||
return `${intro}\n\n${formatPaymentBreakdown(input.locale, input.proposal.breakdown)}`
|
||||
}
|
||||
|
||||
|
||||
@@ -395,6 +395,9 @@ describe('registerConfiguredPaymentTopicIngestion', () => {
|
||||
]
|
||||
}
|
||||
})
|
||||
const payload = calls[0]?.payload as { text?: string } | undefined
|
||||
expect(String(payload?.text)).not.toContain('Аренда к оплате')
|
||||
expect(String(payload?.text)).not.toContain('Баланс по общим покупкам')
|
||||
|
||||
expect(await promptRepository.getPendingAction('-10012345', '10002')).toMatchObject({
|
||||
action: 'payment_topic_confirmation'
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
parsePaymentProposalPayload,
|
||||
synthesizePaymentConfirmationText
|
||||
} from './payment-proposals'
|
||||
import type { TopicMessageRouter } from './topic-message-router'
|
||||
import { cacheTopicMessageRoute, type TopicMessageRouter } from './topic-message-router'
|
||||
import {
|
||||
persistTopicHistoryMessage,
|
||||
telegramMessageIdFromMessage,
|
||||
@@ -662,6 +662,15 @@ export function registerConfiguredPaymentTopicIngestion(
|
||||
// Handle different routes
|
||||
switch (processorResult.route) {
|
||||
case 'silent': {
|
||||
cacheTopicMessageRoute(ctx, 'payments', {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: processorResult.reason === 'test' ? 0 : 80,
|
||||
reason: processorResult.reason
|
||||
})
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ function purchaseUpdate(
|
||||
options: {
|
||||
replyToBot?: boolean
|
||||
threadId?: number
|
||||
asCaption?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const commandToken = text.split(' ')[0] ?? text
|
||||
@@ -99,6 +100,19 @@ function purchaseUpdate(
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(options.asCaption
|
||||
? {
|
||||
caption: text,
|
||||
photo: [
|
||||
{
|
||||
file_id: 'photo-1',
|
||||
file_unique_id: 'photo-1',
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
text,
|
||||
entities: text.startsWith('/')
|
||||
? [
|
||||
@@ -109,6 +123,7 @@ function purchaseUpdate(
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,6 +643,160 @@ Confirm or cancel below.`,
|
||||
})
|
||||
})
|
||||
|
||||
test('reads purchase captions from photo messages', 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: {
|
||||
message_id: calls.length,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: Number(config.householdChatId),
|
||||
type: 'supergroup'
|
||||
},
|
||||
text: 'ok'
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save(record) {
|
||||
expect(record.rawText).toBe('Bought toilet paper 30 gel')
|
||||
return {
|
||||
status: 'pending_confirmation',
|
||||
purchaseMessageId: 'proposal-caption',
|
||||
parsedAmountMinor: 3000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'toilet paper',
|
||||
payerMemberId: 'member-1',
|
||||
payerDisplayName: 'Mia',
|
||||
parserConfidence: 90,
|
||||
parserMode: 'llm',
|
||||
participants: participants()
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async saveWithInterpretation() {
|
||||
throw new Error('not implemented')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
registerPurchaseTopicIngestion(bot, config, repository)
|
||||
await bot.handleUpdate(
|
||||
purchaseUpdate('Bought toilet paper 30 gel', { asCaption: true }) as never
|
||||
)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.payload).toMatchObject({
|
||||
text: expect.stringContaining('toilet paper - 30.00 GEL')
|
||||
})
|
||||
})
|
||||
|
||||
test('shows payer selection buttons when the purchase payer is ambiguous', 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: {
|
||||
message_id: calls.length,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
chat: {
|
||||
id: Number(config.householdChatId),
|
||||
type: 'supergroup'
|
||||
},
|
||||
text: 'ok'
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
const repository: PurchaseMessageIngestionRepository = {
|
||||
async hasClarificationContext() {
|
||||
return false
|
||||
},
|
||||
async save() {
|
||||
return {
|
||||
status: 'clarification_needed',
|
||||
purchaseMessageId: 'proposal-1',
|
||||
clarificationQuestion: null,
|
||||
parsedAmountMinor: 1000n,
|
||||
parsedCurrency: 'GEL',
|
||||
parsedItemDescription: 'chicken',
|
||||
payerMemberId: null,
|
||||
payerDisplayName: null,
|
||||
parserConfidence: 78,
|
||||
parserMode: 'llm',
|
||||
payerCandidates: [
|
||||
{ memberId: 'member-1', displayName: 'Mia' },
|
||||
{ memberId: 'member-2', displayName: 'Dima' }
|
||||
]
|
||||
}
|
||||
},
|
||||
async confirm() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async saveWithInterpretation() {
|
||||
throw new Error('not implemented')
|
||||
},
|
||||
async cancel() {
|
||||
throw new Error('not used')
|
||||
},
|
||||
async toggleParticipant() {
|
||||
throw new Error('not used')
|
||||
}
|
||||
}
|
||||
|
||||
registerPurchaseTopicIngestion(bot, config, repository)
|
||||
await bot.handleUpdate(purchaseUpdate('Dima bought chicken for 10 gel') as never)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const payload = calls[0]?.payload as {
|
||||
text: string
|
||||
reply_markup?: {
|
||||
inline_keyboard?: Array<Array<{ text: string; callback_data: string }>>
|
||||
}
|
||||
}
|
||||
|
||||
expect(payload.text).toBe('I could not tell who bought this. Pick the payer below.')
|
||||
expect(payload.reply_markup?.inline_keyboard?.[0]).toEqual([
|
||||
{
|
||||
text: 'Mia paid',
|
||||
callback_data: 'purchase:payer:proposal-1:member-1'
|
||||
}
|
||||
])
|
||||
expect(payload.reply_markup?.inline_keyboard?.[1]).toEqual([
|
||||
{
|
||||
text: 'Dima paid',
|
||||
callback_data: 'purchase:payer:proposal-1:member-2'
|
||||
}
|
||||
])
|
||||
expect(payload.reply_markup?.inline_keyboard?.[2]).toEqual([
|
||||
{
|
||||
text: 'Cancel',
|
||||
callback_data: 'purchase:cancel:proposal-1'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps bare-amount purchase reports on the ingestion path', async () => {
|
||||
const bot = createTestBot()
|
||||
const calls: Array<{ method: string; payload: unknown }> = []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,28 @@ export function fallbackTopicMessageRoute(
|
||||
reason: 'active_purchase_workflow'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.isExplicitMention || input.isReplyToBot) {
|
||||
return {
|
||||
route: 'topic_helper',
|
||||
replyText: null,
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: true,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 56,
|
||||
reason: 'addressed_finance_topic'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 78,
|
||||
reason: 'quiet_purchase_topic'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.topicRole === 'payments') {
|
||||
@@ -123,6 +145,28 @@ export function fallbackTopicMessageRoute(
|
||||
reason: 'active_payment_workflow'
|
||||
}
|
||||
}
|
||||
|
||||
if (input.isExplicitMention || input.isReplyToBot) {
|
||||
return {
|
||||
route: 'topic_helper',
|
||||
replyText: null,
|
||||
helperKind: 'assistant',
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 56,
|
||||
reason: 'addressed_finance_topic'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route: 'silent',
|
||||
replyText: null,
|
||||
helperKind: null,
|
||||
shouldStartTyping: false,
|
||||
shouldClearWorkflow: false,
|
||||
confidence: 78,
|
||||
reason: 'quiet_payments_topic'
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -295,11 +295,10 @@ export function createTopicProcessor(
|
||||
- The message reports a completed purchase or payment (your primary purpose in these topics)
|
||||
- The user addresses the bot (by @mention, reply to bot, or text reference in ANY language — бот, bot, kojori, кожори, or any recognizable variant)
|
||||
- There is an active clarification/confirmation workflow for this user
|
||||
- The user is clearly engaged with the bot (recent bot interaction, strong context reference)
|
||||
- Regular chat between users (plans, greetings, discussion) → silent
|
||||
|
||||
=== PURCHASE TOPIC (topicRole=purchase) ===
|
||||
Purchase detection is CONTENT-BASED — engagement signals are irrelevant for this decision.
|
||||
Purchase detection is CONTENT-BASED. This topic is a workflow topic, not a casual assistant thread.
|
||||
If the message reports a completed purchase (past-tense buy verb + realistic item + amount), classify as "purchase" REGARDLESS of mention/engagement.
|
||||
- Completed buy verbs: купил, bought, ordered, picked up, spent, взял, заказал, потратил, сходил взял, etc.
|
||||
- Realistic household items: food, groceries, household goods, toiletries, medicine, transport, cafe, restaurant
|
||||
@@ -307,6 +306,8 @@ If the message reports a completed purchase (past-tense buy verb + realistic ite
|
||||
- Gifts for household members ARE shared purchases
|
||||
- Plans, wishes, future intent → silent (NOT purchases)
|
||||
- Fantastical items (car, plane, island) or excessive amounts (>500) → chat_reply with playful response
|
||||
- If the user explicitly addresses the bot with non-purchase banter, use chat_reply with one short sentence.
|
||||
- Do not use topic_helper for casual banter in the purchase topic.
|
||||
|
||||
When classifying as "purchase":
|
||||
- amountMinor in minor currency units (350 GEL → 35000, 3.50 → 350)
|
||||
@@ -315,15 +316,19 @@ When classifying as "purchase":
|
||||
- Use clarification when amount, item, or intent is unclear but purchase seems likely
|
||||
|
||||
=== PAYMENT TOPIC (topicRole=payments) ===
|
||||
This topic is also a workflow topic, not a casual assistant thread.
|
||||
If the message reports a completed rent or utility payment (payment verb + rent/utilities + amount), classify as "payment".
|
||||
- Payment verbs: оплатил, paid, заплатил, перевёл, кинул, отправил
|
||||
- Realistic amount for rent/utilities
|
||||
- If the message is a payment-related balance/status question, use topic_helper.
|
||||
- If the user explicitly addresses the bot with non-payment banter, use chat_reply with one short sentence.
|
||||
- Otherwise ordinary discussion in this topic stays silent.
|
||||
|
||||
=== CHAT REPLIES ===
|
||||
CRITICAL: chat_reply replyText must NEVER claim a purchase or payment was saved, recorded, confirmed, or logged. The chat_reply route does NOT save anything. Only "purchase" and "payment" routes process real data.
|
||||
|
||||
=== BOT ADDRESSING ===
|
||||
When the user addresses the bot (by any means), you MUST respond — never silent.
|
||||
When the user addresses the bot (by any means), you should respond briefly, but finance topics still stay workflow-focused.
|
||||
For bare summons ("бот?", "bot", "@kojori_bot"), use topic_helper to let the assistant greet.
|
||||
For small talk or jokes directed at the bot, use chat_reply with a short playful response.
|
||||
For questions that need household knowledge, use topic_helper.
|
||||
|
||||
@@ -355,6 +355,7 @@ export function createDbFinanceRepository(
|
||||
id: purchaseId,
|
||||
householdId,
|
||||
senderMemberId: input.payerMemberId,
|
||||
payerMemberId: input.payerMemberId,
|
||||
senderTelegramUserId: 'miniapp',
|
||||
senderDisplayName: member?.displayName ?? 'Mini App',
|
||||
telegramChatId: 'miniapp',
|
||||
@@ -388,7 +389,7 @@ export function createDbFinanceRepository(
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
@@ -443,7 +444,8 @@ export function createDbFinanceRepository(
|
||||
: {}),
|
||||
...(input.payerMemberId
|
||||
? {
|
||||
senderMemberId: input.payerMemberId
|
||||
senderMemberId: input.payerMemberId,
|
||||
payerMemberId: input.payerMemberId
|
||||
}
|
||||
: {}),
|
||||
needsReview: 0,
|
||||
@@ -458,7 +460,7 @@ export function createDbFinanceRepository(
|
||||
)
|
||||
.returning({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
@@ -763,7 +765,7 @@ export function createDbFinanceRepository(
|
||||
const rows = await db
|
||||
.select({
|
||||
id: schema.purchaseMessages.id,
|
||||
payerMemberId: schema.purchaseMessages.senderMemberId,
|
||||
payerMemberId: schema.purchaseMessages.payerMemberId,
|
||||
amountMinor: schema.purchaseMessages.parsedAmountMinor,
|
||||
currency: schema.purchaseMessages.parsedCurrency,
|
||||
description: schema.purchaseMessages.parsedItemDescription,
|
||||
@@ -774,7 +776,7 @@ export function createDbFinanceRepository(
|
||||
.where(
|
||||
and(
|
||||
eq(schema.purchaseMessages.householdId, householdId),
|
||||
isNotNull(schema.purchaseMessages.senderMemberId),
|
||||
isNotNull(schema.purchaseMessages.payerMemberId),
|
||||
isNotNull(schema.purchaseMessages.parsedAmountMinor),
|
||||
isNotNull(schema.purchaseMessages.parsedCurrency),
|
||||
or(
|
||||
|
||||
@@ -15,6 +15,23 @@ import type {
|
||||
|
||||
import { createFinanceCommandService } from './finance-command-service'
|
||||
|
||||
function expectedCurrentCyclePeriod(timezone: string, rentDueDay: number): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(new Date())
|
||||
const year = Number(parts.find((part) => part.type === 'year')?.value ?? '0')
|
||||
const month = Number(parts.find((part) => part.type === 'month')?.value ?? '1')
|
||||
const day = Number(parts.find((part) => part.type === 'day')?.value ?? '1')
|
||||
const carryMonth = day > rentDueDay ? month + 1 : month
|
||||
const normalizedYear = carryMonth > 12 ? year + 1 : year
|
||||
const normalizedMonth = carryMonth > 12 ? 1 : carryMonth
|
||||
|
||||
return `${normalizedYear}-${String(normalizedMonth).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
class FinanceRepositoryStub implements FinanceRepository {
|
||||
householdId = 'household-1'
|
||||
member: FinanceMemberRecord | null = null
|
||||
@@ -428,9 +445,10 @@ describe('createFinanceCommandService', () => {
|
||||
const service = createService(repository)
|
||||
|
||||
const result = await service.addUtilityBill('Electricity', '55.20', 'member-1')
|
||||
const expectedPeriod = expectedCurrentCyclePeriod('Asia/Tbilisi', 20)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.period).toBe('2026-03')
|
||||
expect(result?.period).toBe(expectedPeriod)
|
||||
expect(repository.lastUtilityBill).toEqual({
|
||||
cycleId: 'opened-cycle',
|
||||
billName: 'Electricity',
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"0018_nimble_kojori.sql": "818738e729119c6de8049dcfca562926a5dc6e321ecbbf9cf38e02bc70b5a0dc",
|
||||
"0019_faithful_madame_masque.sql": "38711341799b04a7c47fcc64fd19faf5b26e6f183d6a4c01d492b9929cd63641",
|
||||
"0020_natural_mauler.sql": "a80a4a0196a3b4931040850089346d1bc99b34a5afca77d6d62478ee4b8902c1",
|
||||
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad"
|
||||
"0020_silver_payments.sql": "9686235c75453f1eaa016f2f4ab7fce8fe964c76a4e3515987a2b9f90bd7b1ad",
|
||||
"0021_sharp_payer.sql": "973596e154382984ba7769979ea58298b6d93c5139540854be01e8b283ddb4f1"
|
||||
}
|
||||
}
|
||||
|
||||
7
packages/db/drizzle/0021_sharp_payer.sql
Normal file
7
packages/db/drizzle/0021_sharp_payer.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "purchase_messages"
|
||||
ADD COLUMN "payer_member_id" uuid REFERENCES "members"("id") ON DELETE SET NULL;
|
||||
|
||||
UPDATE "purchase_messages"
|
||||
SET "payer_member_id" = "sender_member_id"
|
||||
WHERE "payer_member_id" IS NULL
|
||||
AND "sender_member_id" IS NOT NULL;
|
||||
@@ -148,6 +148,13 @@
|
||||
"when": 1773590603863,
|
||||
"tag": "0020_natural_mauler",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1774200000000,
|
||||
"tag": "0021_sharp_payer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -417,6 +417,9 @@ export const purchaseMessages = pgTable(
|
||||
senderMemberId: uuid('sender_member_id').references(() => members.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
payerMemberId: uuid('payer_member_id').references(() => members.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
senderTelegramUserId: text('sender_telegram_user_id').notNull(),
|
||||
senderDisplayName: text('sender_display_name'),
|
||||
rawText: text('raw_text').notNull(),
|
||||
|
||||
Reference in New Issue
Block a user