mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): add reminder utility entry flow
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: 'общая покупка',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
}
|
||||
|
||||
445
apps/bot/src/reminder-topic-utilities.test.ts
Normal file
445
apps/bot/src/reminder-topic-utilities.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
877
apps/bot/src/reminder-topic-utilities.ts
Normal file
877
apps/bot/src/reminder-topic-utilities.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user