diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index e41931c..299e7fa 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -254,7 +254,33 @@ export const enBotTranslations: BotTranslationCatalog = { reminders: { utilities: (period) => `Utilities reminder for ${period}`, rentWarning: (period) => `Rent reminder for ${period}: payment is coming up soon.`, - rentDue: (period) => `Rent due reminder for ${period}: please settle payment today.` + rentDue: (period) => `Rent due reminder for ${period}: please settle payment today.`, + guidedEntryButton: 'Guided entry', + copyTemplateButton: 'Copy template', + openDashboardButton: 'Open dashboard', + noActiveCategories: + 'This household has no active utility categories yet. Use the dashboard to add them first.', + startToast: 'Guided utility entry started.', + templateToast: 'Utility template sent.', + promptAmount: (categoryName, currency, remainingCount) => + `Reply with the amount for ${categoryName} in ${currency}. Send 0 or "skip" to leave it out.${remainingCount > 0 ? ` ${remainingCount} categories remain after this.` : ''}`, + invalidAmount: (categoryName, currency) => + `I could not read that amount for ${categoryName}. Reply with a number in ${currency}, or send 0 / "skip".`, + templateIntro: (currency) => + `Fill in the utility amounts below in ${currency}, then send the completed message back in this topic.`, + templateInstruction: 'Use 0 or skip for any category you want to leave empty.', + templateInvalid: + 'I could not read any utility amounts from that template. Send the filled template back with at least one amount.', + summaryTitle: (period) => `Utility charges for ${period}`, + summaryLine: (categoryName, amount, currency) => `- ${categoryName}: ${amount} ${currency}`, + confirmPrompt: 'Confirm or cancel below.', + confirmButton: 'Save utility charges', + cancelButton: 'Cancel', + cancelled: 'Utility submission cancelled.', + saved: (count, period) => + `Saved ${count} utility ${count === 1 ? 'charge' : 'charges'} for ${period}.`, + proposalUnavailable: 'This utility submission is no longer available.', + onlyOriginalSender: 'Only the person who started this utility submission can confirm it.' }, purchase: { sharedPurchaseFallback: 'shared purchase', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 020e817..2fa5085 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -258,7 +258,34 @@ export const ruBotTranslations: BotTranslationCatalog = { reminders: { utilities: (period) => `Напоминание по коммунальным платежам за ${period}`, rentWarning: (period) => `Напоминание по аренде за ${period}: срок оплаты скоро наступит.`, - rentDue: (period) => `Напоминание по аренде за ${period}: пожалуйста, оплатите сегодня.` + rentDue: (period) => `Напоминание по аренде за ${period}: пожалуйста, оплатите сегодня.`, + guidedEntryButton: 'Ввести по шагам', + copyTemplateButton: 'Шаблон', + openDashboardButton: 'Открыть дашборд', + noActiveCategories: + 'Для этого дома пока нет активных категорий коммуналки. Сначала добавьте их в дашборде.', + startToast: 'Пошаговый ввод коммуналки запущен.', + templateToast: 'Шаблон коммуналки отправлен.', + promptAmount: (categoryName, currency, remainingCount) => + `Ответьте суммой для «${categoryName}» в ${currency}. Отправьте 0 или «пропуск», если эту категорию не нужно добавлять.${remainingCount > 0 ? ` После этого останется ещё ${remainingCount}.` : ''}`, + invalidAmount: (categoryName, currency) => + `Не удалось распознать сумму для «${categoryName}». Отправьте число в ${currency} или 0 / «пропуск».`, + templateIntro: (currency) => + `Заполните суммы по коммуналке ниже в ${currency}, затем отправьте заполненное сообщение обратно в этот топик.`, + templateInstruction: + 'Для любой категории, которую не нужно добавлять, укажите 0 или слово «пропуск».', + templateInvalid: + 'Не удалось распознать ни одной суммы в этом шаблоне. Отправьте заполненный шаблон хотя бы с одной суммой.', + summaryTitle: (period) => `Коммунальные начисления за ${period}`, + summaryLine: (categoryName, amount, currency) => `- ${categoryName}: ${amount} ${currency}`, + confirmPrompt: 'Подтвердите или отмените ниже.', + confirmButton: 'Сохранить коммуналку', + cancelButton: 'Отменить', + cancelled: 'Ввод коммуналки отменён.', + saved: (count, period) => + `Сохранено ${count} ${count === 1 ? 'начисление коммуналки' : 'начислений коммуналки'} за ${period}.`, + proposalUnavailable: 'Это предложение по коммуналке уже недоступно.', + onlyOriginalSender: 'Подтвердить это добавление коммуналки может только тот, кто его начал.' }, purchase: { sharedPurchaseFallback: 'общая покупка', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 6f97064..5ae7fb1 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -242,6 +242,26 @@ export interface BotTranslationCatalog { utilities: (period: string) => string rentWarning: (period: string) => string rentDue: (period: string) => string + guidedEntryButton: string + copyTemplateButton: string + openDashboardButton: string + noActiveCategories: string + startToast: string + templateToast: string + promptAmount: (categoryName: string, currency: string, remainingCount: number) => string + invalidAmount: (categoryName: string, currency: string) => string + templateIntro: (currency: string) => string + templateInstruction: string + templateInvalid: string + summaryTitle: (period: string) => string + summaryLine: (categoryName: string, amount: string, currency: string) => string + confirmPrompt: string + confirmButton: string + cancelButton: string + cancelled: string + saved: (count: number, period: string) => string + proposalUnavailable: string + onlyOriginalSender: string } purchase: { sharedPurchaseFallback: string diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index db4316d..b8199aa 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,4 +1,5 @@ import { webhookCallback } from 'grammy' +import type { InlineKeyboardMarkup } from 'grammy/types' import { createAnonymousFeedbackService, @@ -40,6 +41,7 @@ import { } from './purchase-topic-ingestion' import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion' import { createReminderJobsHandler } from './reminder-jobs' +import { registerReminderTopicUtilities } from './reminder-topic-utilities' import { createSchedulerRequestAuthorizer } from './scheduler-auth' import { createBotWebhookServer } from './server' import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth' @@ -328,7 +330,7 @@ const reminderJobs = runtime.reminderJobsEnabled }, releaseReminderDispatch: (input) => reminderRepositoryClient.repository.releaseReminderDispatch(input), - sendReminderMessage: async (target, text) => { + sendReminderMessage: async (target, content) => { const threadId = target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined @@ -338,17 +340,25 @@ const reminderJobs = runtime.reminderJobsEnabled ) } - await bot.api.sendMessage( - target.telegramChatId, - text, - threadId + await bot.api.sendMessage(target.telegramChatId, content.text, { + ...(threadId ? { message_thread_id: threadId } - : undefined - ) + : {}), + ...(content.replyMarkup + ? { + reply_markup: content.replyMarkup as InlineKeyboardMarkup + } + : {}) + }) }, reminderService, + ...(runtime.miniAppAllowedOrigins[0] + ? { + miniAppUrl: runtime.miniAppAllowedOrigins[0] + } + : {}), logger: getLogger('scheduler') }) })() @@ -447,6 +457,16 @@ if ( } } +if (householdConfigurationRepositoryClient && telegramPendingActionRepositoryClient) { + registerReminderTopicUtilities({ + bot, + householdConfigurationRepository: householdConfigurationRepositoryClient.repository, + promptRepository: telegramPendingActionRepositoryClient.repository, + financeServiceForHousehold, + logger: getLogger('reminder-utilities') + }) +} + const server = createBotWebhookServer({ webhookPath: runtime.telegramWebhookPath, webhookSecret: runtime.telegramWebhookSecret, diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts index 46dfc85..a29df74 100644 --- a/apps/bot/src/reminder-jobs.test.ts +++ b/apps/bot/src/reminder-jobs.test.ts @@ -59,7 +59,23 @@ describe('createReminderJobsHandler', () => { expect(sendReminderMessage).toHaveBeenCalledTimes(1) expect(sendReminderMessage).toHaveBeenCalledWith( target, - 'Напоминание по коммунальным платежам за 2026-03' + expect.objectContaining({ + text: 'Напоминание по коммунальным платежам за 2026-03', + replyMarkup: { + inline_keyboard: [ + [ + { + text: 'Ввести по шагам', + callback_data: 'reminder_util:guided' + }, + { + text: 'Шаблон', + callback_data: 'reminder_util:template' + } + ] + ] + } + }) ) expect(response.status).toBe(200) diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 6726293..096c818 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -2,8 +2,10 @@ import type { ReminderJobService } from '@household/application' import { BillingPeriod, Temporal, nowInstant } from '@household/domain' import type { Logger } from '@household/observability' import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports' +import type { InlineKeyboardMarkup } from 'grammy/types' import { getBotTranslations } from './i18n' +import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities' interface ReminderJobRequestBody { period?: string @@ -11,6 +13,11 @@ interface ReminderJobRequestBody { dryRun?: boolean } +export interface ReminderMessageContent { + text: string + replyMarkup?: InlineKeyboardMarkup +} + function json(body: object, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -82,24 +89,36 @@ export function createReminderJobsHandler(options: { period: string reminderType: ReminderType }) => Promise - sendReminderMessage: (target: ReminderTarget, text: string) => Promise + sendReminderMessage: (target: ReminderTarget, content: ReminderMessageContent) => Promise reminderService: ReminderJobService forceDryRun?: boolean now?: () => Temporal.Instant + miniAppUrl?: string logger?: Logger }): { handle: (request: Request, rawReminderType: string) => Promise } { - function messageText(target: ReminderTarget, reminderType: ReminderType, period: string): string { + function messageContent( + target: ReminderTarget, + reminderType: ReminderType, + period: string + ): ReminderMessageContent { const t = getBotTranslations(target.locale).reminders switch (reminderType) { case 'utilities': - return t.utilities(period) + return { + text: t.utilities(period), + replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, options.miniAppUrl) + } case 'rent-warning': - return t.rentWarning(period) + return { + text: t.rentWarning(period) + } case 'rent-due': - return t.rentDue(period) + return { + text: t.rentDue(period) + } } } @@ -149,14 +168,14 @@ export function createReminderJobsHandler(options: { reminderType, dryRun }) - const text = messageText(target, reminderType, period) + const content = messageContent(target, reminderType, period) let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status let error: string | undefined if (result.status === 'claimed') { try { - await options.sendReminderMessage(target, text) + await options.sendReminderMessage(target, content) } catch (dispatchError) { await options.releaseReminderDispatch({ householdId: target.householdId, @@ -196,7 +215,7 @@ export function createReminderJobsHandler(options: { period, dedupeKey: result.dedupeKey, outcome, - messageText: text, + messageText: content.text, ...(error ? { error } : {}) }) } diff --git a/apps/bot/src/reminder-topic-utilities.test.ts b/apps/bot/src/reminder-topic-utilities.test.ts new file mode 100644 index 0000000..0fa7c22 --- /dev/null +++ b/apps/bot/src/reminder-topic-utilities.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, test } from 'bun:test' + +import type { FinanceCommandService } from '@household/application' +import { instantFromIso, nowInstant } from '@household/domain' +import type { TelegramPendingActionRecord, TelegramPendingActionRepository } from '@household/ports' + +import { createTelegramBot } from './bot' +import { + registerReminderTopicUtilities, + REMINDER_UTILITY_GUIDED_CALLBACK, + REMINDER_UTILITY_TEMPLATE_CALLBACK +} from './reminder-topic-utilities' + +function reminderCallbackUpdate(data: string, fromId = 10002) { + return { + update_id: 2001, + callback_query: { + id: 'callback-1', + from: { + id: fromId, + is_bot: false, + first_name: 'Mia' + }, + chat_instance: 'instance-1', + data, + message: { + message_id: 77, + date: Math.floor(Date.now() / 1000), + message_thread_id: 555, + is_topic_message: true, + chat: { + id: -10012345, + type: 'supergroup' + }, + text: 'Utilities reminder' + } + } + } +} + +function reminderMessageUpdate(text: string, fromId = 10002) { + return { + update_id: 2002, + message: { + message_id: 88, + date: Math.floor(Date.now() / 1000), + message_thread_id: 555, + is_topic_message: true, + chat: { + id: -10012345, + type: 'supergroup' + }, + from: { + id: fromId, + is_bot: false, + first_name: 'Mia' + }, + text + } + } +} + +function createPromptRepository(): TelegramPendingActionRepository & { + current: () => TelegramPendingActionRecord | null + expire: () => void +} { + let pending: TelegramPendingActionRecord | null = null + + return { + current: () => pending, + expire: () => { + if (!pending) { + return + } + + pending = { + ...pending, + expiresAt: instantFromIso('2000-01-01T00:00:00.000Z') + } + }, + async upsertPendingAction(input) { + pending = input + return input + }, + async getPendingAction() { + if (!pending) { + return null + } + + if ( + pending.expiresAt && + pending.expiresAt.epochMilliseconds <= nowInstant().epochMilliseconds + ) { + pending = null + return null + } + + 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 createHouseholdRepository() { + return { + getTelegramHouseholdChat: async () => ({ + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-10012345', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'ru' as const + }), + getHouseholdChatByHouseholdId: async () => ({ + householdId: 'household-1', + householdName: 'Kojori House', + telegramChatId: '-10012345', + telegramChatType: 'supergroup', + title: 'Kojori House', + defaultLocale: 'ru' as const + }), + findHouseholdTopicByTelegramContext: async () => ({ + householdId: 'household-1', + role: 'reminders' as const, + telegramThreadId: '555', + topicName: 'Напоминания' + }), + getHouseholdBillingSettings: async () => ({ + householdId: 'household-1', + settlementCurrency: 'GEL' as const, + paymentBalanceAdjustmentPolicy: 'utilities' as const, + rentAmountMinor: null, + rentCurrency: 'USD' as const, + rentDueDay: 20, + rentWarningDay: 17, + utilitiesDueDay: 4, + utilitiesReminderDay: 3, + timezone: 'Asia/Tbilisi' + }), + listHouseholdUtilityCategories: async () => [ + { + id: 'cat-1', + householdId: 'household-1', + slug: 'electricity', + name: 'Electricity', + sortOrder: 1, + isActive: true + }, + { + id: 'cat-2', + householdId: 'household-1', + slug: 'water', + name: 'Water', + sortOrder: 2, + isActive: true + } + ] + } +} + +function createFinanceService(): FinanceCommandService & { + addedUtilityBills: Array<{ + billName: string + amountMajor: string + createdByMemberId: string + currency?: string + }> +} { + return { + addedUtilityBills: [], + getMemberByTelegramUserId: async () => ({ + id: 'member-1', + telegramUserId: '10002', + displayName: 'Mia', + rentShareWeight: 1, + isAdmin: false + }), + 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 function (billName, amountMajor, createdByMemberId, currencyArg) { + if (currencyArg) { + this.addedUtilityBills.push({ + billName, + amountMajor, + createdByMemberId, + currency: currencyArg + }) + } else { + this.addedUtilityBills.push({ + billName, + amountMajor, + createdByMemberId + }) + } + + return { + amount: undefined as never, + currency: 'GEL', + period: '2026-03' + } + }, + updateUtilityBill: async () => null, + deleteUtilityBill: async () => false, + updatePurchase: async () => null, + deletePurchase: async () => false, + addPayment: async () => null, + updatePayment: async () => null, + deletePayment: async () => false, + generateDashboard: async () => null, + generateStatement: async () => null + } +} + +function setupBot() { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const promptRepository = createPromptRepository() + const financeService = createFinanceService() + + 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 + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + return { + ok: true, + result: true + } as never + }) + + registerReminderTopicUtilities({ + bot, + householdConfigurationRepository: createHouseholdRepository() as never, + promptRepository, + financeServiceForHousehold: () => financeService + }) + + return { + bot, + calls, + promptRepository, + financeService + } +} + +describe('registerReminderTopicUtilities', () => { + test('runs the guided reminder flow and records utility bills on confirmation', async () => { + const { bot, calls, financeService, promptRepository } = setupBot() + + await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + callback_query_id: 'callback-1', + text: 'Пошаговый ввод коммуналки запущен.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: expect.stringContaining('Electricity'), + message_thread_id: 555 + } + }) + + calls.length = 0 + await bot.handleUpdate(reminderMessageUpdate('55') as never) + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + text: expect.stringContaining('Water') + } + }) + + calls.length = 0 + await bot.handleUpdate(reminderMessageUpdate('12.5') as never) + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + text: expect.stringContaining('Коммунальные начисления за 2026-03'), + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Сохранить коммуналку', + callback_data: expect.stringMatching(/^reminder_util:confirm:[^:]+$/) + }, + { + text: 'Отменить', + callback_data: expect.stringMatching(/^reminder_util:cancel:[^:]+$/) + } + ] + ] + } + } + }) + + const confirmProposalId = ( + promptRepository.current()?.payload as { + proposalId?: string + } | null + )?.proposalId + const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}` + calls.length = 0 + await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never) + + expect(financeService.addedUtilityBills).toEqual([ + { + billName: 'Electricity', + amountMajor: '55.00', + createdByMemberId: 'member-1', + currency: 'GEL' + }, + { + billName: 'Water', + amountMajor: '12.50', + createdByMemberId: 'member-1', + currency: 'GEL' + } + ]) + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + text: 'Сохранено 2 начислений коммуналки за 2026-03.' + } + }) + }) + + test('parses the filled template and turns it into a confirmation proposal', async () => { + const { bot, calls } = setupBot() + + await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_TEMPLATE_CALLBACK) as never) + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + text: expect.stringContaining('Electricity:'), + message_thread_id: 555 + } + }) + + calls.length = 0 + await bot.handleUpdate(reminderMessageUpdate('Electricity: 22\nWater: 0') as never) + + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + text: expect.stringContaining('- Electricity: 22.00 GEL') + } + }) + }) + + test('treats expired pending reminder submissions as unavailable', async () => { + const { bot, calls, promptRepository } = setupBot() + + await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never) + await bot.handleUpdate(reminderMessageUpdate('55') as never) + await bot.handleUpdate(reminderMessageUpdate('12') as never) + const confirmProposalId = ( + promptRepository.current()?.payload as { + proposalId?: string + } | null + )?.proposalId + const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}` + promptRepository.expire() + calls.length = 0 + + await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + text: 'Это предложение по коммуналке уже недоступно.', + show_alert: true + } + }) + }) + + test('does not re-confirm after the pending submission was already cleared', async () => { + const { bot, calls, promptRepository } = setupBot() + + await bot.handleUpdate(reminderCallbackUpdate(REMINDER_UTILITY_GUIDED_CALLBACK) as never) + await bot.handleUpdate(reminderMessageUpdate('55') as never) + await bot.handleUpdate(reminderMessageUpdate('12') as never) + const confirmProposalId = ( + promptRepository.current()?.payload as { + proposalId?: string + } | null + )?.proposalId + const confirmCallbackData = `reminder_util:confirm:${confirmProposalId ?? 'missing'}` + await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never) + calls.length = 0 + + await bot.handleUpdate(reminderCallbackUpdate(confirmCallbackData ?? 'missing') as never) + + expect(calls[0]).toMatchObject({ + method: 'answerCallbackQuery', + payload: { + text: 'Это предложение по коммуналке уже недоступно.', + show_alert: true + } + }) + }) +}) diff --git a/apps/bot/src/reminder-topic-utilities.ts b/apps/bot/src/reminder-topic-utilities.ts new file mode 100644 index 0000000..adfbb0d --- /dev/null +++ b/apps/bot/src/reminder-topic-utilities.ts @@ -0,0 +1,877 @@ +import type { FinanceCommandService } from '@household/application' +import { nowInstant } from '@household/domain' +import type { Logger } from '@household/observability' +import type { + HouseholdConfigurationRepository, + TelegramPendingActionRepository +} from '@household/ports' +import type { Bot, Context } from 'grammy' +import type { InlineKeyboardMarkup } from 'grammy/types' + +import { getBotTranslations, type BotLocale } from './i18n' +import { resolveReplyLocale } from './bot-locale' + +export const REMINDER_UTILITY_GUIDED_CALLBACK = 'reminder_util:guided' +export const REMINDER_UTILITY_TEMPLATE_CALLBACK = 'reminder_util:template' +const REMINDER_UTILITY_CONFIRM_CALLBACK_PREFIX = 'reminder_util:confirm:' +const REMINDER_UTILITY_CANCEL_CALLBACK_PREFIX = 'reminder_util:cancel:' +const REMINDER_UTILITY_ACTION = 'reminder_utility_entry' as const +const REMINDER_UTILITY_ACTION_TTL_MS = 30 * 60_000 + +type ReminderUtilityEntryPayload = + | { + stage: 'guided' + householdId: string + threadId: string + period: string + currency: 'GEL' | 'USD' + memberId: string + categories: readonly string[] + currentIndex: number + entries: readonly UtilityDraftEntry[] + } + | { + stage: 'template' + householdId: string + threadId: string + period: string + currency: 'GEL' | 'USD' + memberId: string + categories: readonly string[] + } + | { + stage: 'confirm' + proposalId: string + householdId: string + threadId: string + period: string + currency: 'GEL' | 'USD' + memberId: string + entries: readonly UtilityDraftEntry[] + } + +type ReminderUtilityConfirmPayload = Extract + +interface UtilityDraftEntry { + billName: string + amountMajor: string +} + +interface ReminderTopicCandidate { + chatId: string + threadId: string + senderTelegramUserId: string + messageId: number + rawText: string +} + +function readMessageText(ctx: Context): string | null { + const message = ctx.message + if (!message) { + return null + } + + if ('text' in message && typeof message.text === 'string') { + return message.text + } + + if ('caption' in message && typeof message.caption === 'string') { + return message.caption + } + + return null +} + +function toReminderTopicCandidate(ctx: Context): ReminderTopicCandidate | null { + const message = ctx.message + const rawText = readMessageText(ctx)?.trim() + if (!message || !rawText) { + return null + } + + if (!('is_topic_message' in message) || message.is_topic_message !== true) { + return null + } + + if (!('message_thread_id' in message) || message.message_thread_id === undefined) { + return null + } + + const senderTelegramUserId = ctx.from?.id?.toString() + if (!senderTelegramUserId) { + return null + } + + return { + chatId: message.chat.id.toString(), + threadId: message.message_thread_id.toString(), + senderTelegramUserId, + messageId: message.message_id, + rawText + } +} + +function normalizeDraftAmount(raw: string): string | null { + const match = raw.replace(',', '.').match(/\d+(?:\.\d{1,2})?/) + if (!match) { + return null + } + + const parsed = Number(match[0]) + if (!Number.isFinite(parsed) || parsed < 0) { + return null + } + + return parsed.toFixed(2) +} + +function isSkipValue(raw: string): boolean { + const normalized = raw.trim().toLowerCase() + return ( + normalized === '0' || + normalized === 'skip' || + normalized === 'пропуск' || + normalized === 'нет' || + normalized === '-' + ) +} + +function parseTemplateEntries( + rawText: string, + categories: readonly string[] +): readonly UtilityDraftEntry[] | null { + const categoryByKey = new Map( + categories.map((category) => [category.trim().toLowerCase(), category]) + ) + const entries: UtilityDraftEntry[] = [] + + for (const line of rawText.split('\n')) { + const trimmed = line.trim() + if (trimmed.length === 0) { + continue + } + + const separatorIndex = trimmed.indexOf(':') + if (separatorIndex <= 0) { + continue + } + + const rawCategory = trimmed.slice(0, separatorIndex).trim().toLowerCase() + const category = categoryByKey.get(rawCategory) + if (!category) { + continue + } + + const rawValue = trimmed.slice(separatorIndex + 1).trim() + if (rawValue.length === 0 || isSkipValue(rawValue)) { + continue + } + + const amountMajor = normalizeDraftAmount(rawValue) + if (!amountMajor || amountMajor === '0.00') { + continue + } + + entries.push({ + billName: category, + amountMajor + }) + } + + return entries.length > 0 ? entries : null +} + +function buildTemplateText( + locale: BotLocale, + currency: 'GEL' | 'USD', + categories: readonly string[] +): string { + const t = getBotTranslations(locale).reminders + + return [ + t.templateIntro(currency), + '', + ...categories.map((category) => `${category}: `), + '', + t.templateInstruction + ].join('\n') +} + +function reminderUtilitySummaryText( + locale: BotLocale, + period: string, + currency: 'GEL' | 'USD', + entries: readonly UtilityDraftEntry[] +): string { + const t = getBotTranslations(locale).reminders + + return [ + t.summaryTitle(period), + '', + ...entries.map((entry) => t.summaryLine(entry.billName, entry.amountMajor, currency)), + '', + t.confirmPrompt + ].join('\n') +} + +function reminderUtilityReplyMarkup(locale: BotLocale) { + const t = getBotTranslations(locale).reminders + + return (proposalId: string): InlineKeyboardMarkup => ({ + inline_keyboard: [ + [ + { + text: t.confirmButton, + callback_data: `${REMINDER_UTILITY_CONFIRM_CALLBACK_PREFIX}${proposalId}` + }, + { + text: t.cancelButton, + callback_data: `${REMINDER_UTILITY_CANCEL_CALLBACK_PREFIX}${proposalId}` + } + ] + ] + }) +} + +function createReminderUtilityProposalId(): string { + return crypto.randomUUID().slice(0, 8) +} + +function buildReminderConfirmationPayload(input: { + householdId: string + threadId: string + period: string + currency: 'GEL' | 'USD' + memberId: string + entries: readonly UtilityDraftEntry[] +}): ReminderUtilityConfirmPayload { + return { + stage: 'confirm', + proposalId: createReminderUtilityProposalId(), + householdId: input.householdId, + threadId: input.threadId, + period: input.period, + currency: input.currency, + memberId: input.memberId, + entries: input.entries + } +} + +async function replyInTopic( + ctx: Context, + text: string, + replyMarkup?: InlineKeyboardMarkup +): Promise { + const message = ctx.msg + if (!ctx.chat || !message) { + return + } + + const threadId = + 'message_thread_id' in message && message.message_thread_id !== undefined + ? message.message_thread_id + : undefined + + await ctx.api.sendMessage(ctx.chat.id, text, { + ...(threadId !== undefined + ? { + message_thread_id: threadId + } + : {}), + reply_parameters: { + message_id: message.message_id + }, + ...(replyMarkup + ? { + reply_markup: replyMarkup as InlineKeyboardMarkup + } + : {}) + }) +} + +async function resolveReminderContext( + ctx: Context, + householdConfigurationRepository: HouseholdConfigurationRepository, + financeServiceForHousehold: (householdId: string) => FinanceCommandService +): Promise<{ + locale: BotLocale + householdId: string + threadId: string + memberId: string + categories: readonly string[] + currency: 'GEL' | 'USD' + period: string +} | null> { + const threadId = + ctx.msg && 'message_thread_id' in ctx.msg && ctx.msg.message_thread_id !== undefined + ? ctx.msg.message_thread_id.toString() + : null + + if (!ctx.chat || !threadId) { + return null + } + + const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({ + telegramChatId: ctx.chat.id.toString(), + telegramThreadId: threadId + }) + + if (!binding || binding.role !== 'reminders') { + return null + } + + const telegramUserId = ctx.from?.id?.toString() + if (!telegramUserId) { + return null + } + + const financeService = financeServiceForHousehold(binding.householdId) + const [locale, member, settings, categories, cycle] = await Promise.all([ + resolveReplyLocale({ + ctx, + repository: householdConfigurationRepository, + householdId: binding.householdId + }), + financeService.getMemberByTelegramUserId(telegramUserId), + householdConfigurationRepository.getHouseholdBillingSettings(binding.householdId), + householdConfigurationRepository.listHouseholdUtilityCategories(binding.householdId), + financeService.ensureExpectedCycle() + ]) + + if (!member) { + return null + } + + return { + locale, + householdId: binding.householdId, + threadId, + memberId: member.id, + categories: categories + .filter((category) => category.isActive) + .sort((left, right) => left.sortOrder - right.sortOrder) + .map((category) => category.name), + currency: settings.settlementCurrency, + period: cycle.period + } +} + +export function buildUtilitiesReminderReplyMarkup( + locale: BotLocale, + miniAppUrl?: string +): InlineKeyboardMarkup { + const t = getBotTranslations(locale).reminders + const dashboardUrl = miniAppUrl?.trim() + + return { + inline_keyboard: [ + [ + { + text: t.guidedEntryButton, + callback_data: REMINDER_UTILITY_GUIDED_CALLBACK + }, + { + text: t.copyTemplateButton, + callback_data: REMINDER_UTILITY_TEMPLATE_CALLBACK + } + ], + ...(dashboardUrl + ? [ + [ + { + text: t.openDashboardButton, + web_app: { + url: dashboardUrl + } + } + ] + ] + : []) + ] + } +} + +export function registerReminderTopicUtilities(options: { + bot: Bot + householdConfigurationRepository: HouseholdConfigurationRepository + promptRepository: TelegramPendingActionRepository + financeServiceForHousehold: (householdId: string) => FinanceCommandService + logger?: Logger +}): void { + async function startFlow(ctx: Context, stage: 'guided' | 'template') { + if (ctx.chat?.type !== 'group' && ctx.chat?.type !== 'supergroup') { + return + } + + const reminderContext = await resolveReminderContext( + ctx, + options.householdConfigurationRepository, + options.financeServiceForHousehold + ) + + if (!reminderContext) { + return + } + + const t = getBotTranslations(reminderContext.locale).reminders + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId) { + return + } + + if (reminderContext.categories.length === 0) { + await ctx.answerCallbackQuery({ + text: t.noActiveCategories, + show_alert: true + }) + return + } + + if (stage === 'guided') { + await options.promptRepository.upsertPendingAction({ + telegramUserId: actorTelegramUserId, + telegramChatId: ctx.chat.id.toString(), + action: REMINDER_UTILITY_ACTION, + payload: { + stage: 'guided', + householdId: reminderContext.householdId, + threadId: reminderContext.threadId, + period: reminderContext.period, + currency: reminderContext.currency, + memberId: reminderContext.memberId, + categories: reminderContext.categories, + currentIndex: 0, + entries: [] + } satisfies ReminderUtilityEntryPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await ctx.answerCallbackQuery({ + text: t.startToast + }) + await replyInTopic( + ctx, + t.promptAmount( + reminderContext.categories[0]!, + reminderContext.currency, + reminderContext.categories.length - 1 + ) + ) + return + } + + await options.promptRepository.upsertPendingAction({ + telegramUserId: actorTelegramUserId, + telegramChatId: ctx.chat.id.toString(), + action: REMINDER_UTILITY_ACTION, + payload: { + stage: 'template', + householdId: reminderContext.householdId, + threadId: reminderContext.threadId, + period: reminderContext.period, + currency: reminderContext.currency, + memberId: reminderContext.memberId, + categories: reminderContext.categories + } satisfies ReminderUtilityEntryPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await ctx.answerCallbackQuery({ + text: t.templateToast + }) + await replyInTopic( + ctx, + buildTemplateText( + reminderContext.locale, + reminderContext.currency, + reminderContext.categories + ) + ) + } + + options.bot.callbackQuery(REMINDER_UTILITY_GUIDED_CALLBACK, async (ctx) => { + await startFlow(ctx, 'guided') + }) + + options.bot.callbackQuery(REMINDER_UTILITY_TEMPLATE_CALLBACK, async (ctx) => { + await startFlow(ctx, 'template') + }) + + const handleReminderUtilityConfirm = async (ctx: Context, proposalId: string) => { + const messageChat = + ctx.callbackQuery && 'message' in ctx.callbackQuery + ? ctx.callbackQuery.message?.chat + : undefined + if (messageChat?.type !== 'group' && messageChat?.type !== 'supergroup') { + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId || !messageChat || !proposalId) { + return + } + + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).reminders + const pending = await options.promptRepository.getPendingAction( + messageChat.id.toString(), + actorTelegramUserId + ) + const payload = + pending?.action === REMINDER_UTILITY_ACTION + ? (pending.payload as Partial) + : null + + if ( + !payload || + payload.stage !== 'confirm' || + !Array.isArray(payload.entries) || + payload.proposalId !== proposalId + ) { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + const financeService = options.financeServiceForHousehold(payload.householdId!) + for (const entry of payload.entries) { + await financeService.addUtilityBill( + entry.billName, + entry.amountMajor, + payload.memberId!, + payload.currency + ) + } + + await options.promptRepository.clearPendingAction( + messageChat.id.toString(), + actorTelegramUserId + ) + await ctx.answerCallbackQuery({ + text: t.saved(payload.entries.length, payload.period!) + }) + + if (ctx.msg) { + await ctx.editMessageText(t.saved(payload.entries.length, payload.period!), { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + + const handleReminderUtilityCancel = async (ctx: Context, proposalId: string) => { + const messageChat = + ctx.callbackQuery && 'message' in ctx.callbackQuery + ? ctx.callbackQuery.message?.chat + : undefined + if (messageChat?.type !== 'group' && messageChat?.type !== 'supergroup') { + return + } + + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId || !messageChat || !proposalId) { + return + } + + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale).reminders + const pending = await options.promptRepository.getPendingAction( + messageChat.id.toString(), + actorTelegramUserId + ) + const payload = + pending?.action === REMINDER_UTILITY_ACTION + ? (pending.payload as Partial) + : null + + if (!payload || payload.stage !== 'confirm' || payload.proposalId !== proposalId) { + await ctx.answerCallbackQuery({ + text: t.proposalUnavailable, + show_alert: true + }) + return + } + + await options.promptRepository.clearPendingAction( + messageChat.id.toString(), + actorTelegramUserId + ) + await ctx.answerCallbackQuery({ + text: t.cancelled + }) + + if (ctx.msg) { + await ctx.editMessageText(t.cancelled, { + reply_markup: { + inline_keyboard: [] + } + }) + } + } + + options.bot.on('callback_query:data', async (ctx, next) => { + const data = typeof ctx.callbackQuery?.data === 'string' ? ctx.callbackQuery.data : null + if (!data) { + await next() + return + } + + if (data.startsWith(REMINDER_UTILITY_CONFIRM_CALLBACK_PREFIX)) { + await handleReminderUtilityConfirm( + ctx, + data.slice(REMINDER_UTILITY_CONFIRM_CALLBACK_PREFIX.length) + ) + return + } + + if (data.startsWith(REMINDER_UTILITY_CANCEL_CALLBACK_PREFIX)) { + await handleReminderUtilityCancel( + ctx, + data.slice(REMINDER_UTILITY_CANCEL_CALLBACK_PREFIX.length) + ) + return + } + + await next() + }) + + options.bot.on('message', async (ctx, next) => { + const candidate = toReminderTopicCandidate(ctx) + if (!candidate || candidate.rawText.startsWith('/')) { + await next() + return + } + + const pending = await options.promptRepository.getPendingAction( + candidate.chatId, + candidate.senderTelegramUserId + ) + const payload = + pending?.action === REMINDER_UTILITY_ACTION + ? (pending.payload as Partial) + : null + + if (!payload || payload.threadId !== candidate.threadId) { + await next() + return + } + + const localeOptions = payload.householdId + ? { + ctx, + repository: options.householdConfigurationRepository, + householdId: payload.householdId + } + : { + ctx, + repository: options.householdConfigurationRepository + } + const locale = await resolveReplyLocale(localeOptions) + const t = getBotTranslations(locale).reminders + + try { + if (payload.stage === 'guided' && Array.isArray(payload.categories)) { + if (isSkipValue(candidate.rawText)) { + const nextPayload: ReminderUtilityEntryPayload = { + stage: 'guided', + householdId: payload.householdId!, + threadId: payload.threadId!, + period: payload.period!, + currency: payload.currency!, + memberId: payload.memberId!, + categories: payload.categories, + currentIndex: (payload.currentIndex ?? 0) + 1, + entries: payload.entries ?? [] + } + const nextIndex = (payload.currentIndex ?? 0) + 1 + const nextCategory = payload.categories[nextIndex] + if (!nextCategory) { + const confirmationPayload = buildReminderConfirmationPayload({ + householdId: payload.householdId!, + threadId: payload.threadId!, + period: payload.period!, + currency: payload.currency!, + memberId: payload.memberId!, + entries: payload.entries ?? [] + }) + await options.promptRepository.upsertPendingAction({ + telegramUserId: candidate.senderTelegramUserId, + telegramChatId: candidate.chatId, + action: REMINDER_UTILITY_ACTION, + payload: confirmationPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + if ((payload.entries?.length ?? 0) === 0) { + await options.promptRepository.clearPendingAction( + candidate.chatId, + candidate.senderTelegramUserId + ) + await replyInTopic(ctx, t.cancelled) + return + } + + await replyInTopic( + ctx, + reminderUtilitySummaryText( + locale, + payload.period!, + payload.currency!, + payload.entries ?? [] + ), + reminderUtilityReplyMarkup(locale)(confirmationPayload.proposalId) + ) + return + } + + await options.promptRepository.upsertPendingAction({ + telegramUserId: candidate.senderTelegramUserId, + telegramChatId: candidate.chatId, + action: REMINDER_UTILITY_ACTION, + payload: nextPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await replyInTopic( + ctx, + t.promptAmount( + nextCategory, + payload.currency!, + payload.categories.length - nextIndex - 1 + ) + ) + return + } + + const amountMajor = normalizeDraftAmount(candidate.rawText) + const currentIndex = payload.currentIndex ?? 0 + const currentCategory = payload.categories[currentIndex] + if (!amountMajor || !currentCategory) { + await replyInTopic( + ctx, + t.invalidAmount( + currentCategory ?? payload.categories[0] ?? 'utility', + payload.currency ?? 'GEL' + ) + ) + return + } + + const nextEntries = [...(payload.entries ?? []), { billName: currentCategory, amountMajor }] + const nextIndex = currentIndex + 1 + const nextCategory = payload.categories[nextIndex] + + if (!nextCategory) { + const confirmationPayload = buildReminderConfirmationPayload({ + householdId: payload.householdId!, + threadId: payload.threadId!, + period: payload.period!, + currency: payload.currency!, + memberId: payload.memberId!, + entries: nextEntries + }) + await options.promptRepository.upsertPendingAction({ + telegramUserId: candidate.senderTelegramUserId, + telegramChatId: candidate.chatId, + action: REMINDER_UTILITY_ACTION, + payload: confirmationPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await replyInTopic( + ctx, + reminderUtilitySummaryText(locale, payload.period!, payload.currency!, nextEntries), + reminderUtilityReplyMarkup(locale)(confirmationPayload.proposalId) + ) + return + } + + await options.promptRepository.upsertPendingAction({ + telegramUserId: candidate.senderTelegramUserId, + telegramChatId: candidate.chatId, + action: REMINDER_UTILITY_ACTION, + payload: { + stage: 'guided', + householdId: payload.householdId!, + threadId: payload.threadId!, + period: payload.period!, + currency: payload.currency!, + memberId: payload.memberId!, + categories: payload.categories, + currentIndex: nextIndex, + entries: nextEntries + } as ReminderUtilityEntryPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await replyInTopic( + ctx, + t.promptAmount(nextCategory, payload.currency!, payload.categories.length - nextIndex - 1) + ) + return + } + + if (payload.stage === 'template' && Array.isArray(payload.categories)) { + if (isSkipValue(candidate.rawText) || candidate.rawText.trim().toLowerCase() === 'cancel') { + await options.promptRepository.clearPendingAction( + candidate.chatId, + candidate.senderTelegramUserId + ) + await replyInTopic(ctx, t.cancelled) + return + } + + const entries = parseTemplateEntries(candidate.rawText, payload.categories) + if (!entries) { + await replyInTopic(ctx, t.templateInvalid) + return + } + + const confirmationPayload = buildReminderConfirmationPayload({ + householdId: payload.householdId!, + threadId: payload.threadId!, + period: payload.period!, + currency: payload.currency!, + memberId: payload.memberId!, + entries + }) + await options.promptRepository.upsertPendingAction({ + telegramUserId: candidate.senderTelegramUserId, + telegramChatId: candidate.chatId, + action: REMINDER_UTILITY_ACTION, + payload: confirmationPayload, + expiresAt: nowInstant().add({ milliseconds: REMINDER_UTILITY_ACTION_TTL_MS }) + }) + + await replyInTopic( + ctx, + reminderUtilitySummaryText(locale, payload.period!, payload.currency!, entries), + reminderUtilityReplyMarkup(locale)(confirmationPayload.proposalId) + ) + return + } + + await next() + } catch (error) { + options.logger?.error( + { + event: 'reminder.utility_entry_failed', + chatId: candidate.chatId, + threadId: candidate.threadId, + messageId: candidate.messageId, + error + }, + 'Failed to process reminder utility entry' + ) + } + }) +} diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index d10cfe2..11e93da 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -29,6 +29,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType { return raw } + if (raw === 'reminder_utility_entry') { + return raw + } + if (raw === 'setup_topic_binding') { return raw } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index 2623f28..9351bc8 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -6,6 +6,7 @@ export const TELEGRAM_PENDING_ACTION_TYPES = [ 'household_group_invite', 'payment_topic_clarification', 'payment_topic_confirmation', + 'reminder_utility_entry', 'setup_topic_binding' ] as const