feat(bot): add reminder utility entry flow

This commit is contained in:
2026-03-11 22:23:24 +04:00
parent 523b5144d8
commit 6b8c2fa397
10 changed files with 1473 additions and 18 deletions

View File

@@ -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',

View File

@@ -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: 'общая покупка',

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<void>
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
sendReminderMessage: (target: ReminderTarget, content: ReminderMessageContent) => Promise<void>
reminderService: ReminderJobService
forceDryRun?: boolean
now?: () => Temporal.Instant
miniAppUrl?: string
logger?: Logger
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
} {
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 } : {})
})
}

View File

@@ -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
}
})
})
})

View File

@@ -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<ReminderUtilityEntryPayload, { stage: 'confirm' }>
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<void> {
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<ReminderUtilityEntryPayload>)
: 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<ReminderUtilityEntryPayload>)
: 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<ReminderUtilityEntryPayload>)
: 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'
)
}
})
}

View File

@@ -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
}

View File

@@ -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