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: {
|
reminders: {
|
||||||
utilities: (period) => `Utilities reminder for ${period}`,
|
utilities: (period) => `Utilities reminder for ${period}`,
|
||||||
rentWarning: (period) => `Rent reminder for ${period}: payment is coming up soon.`,
|
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: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'shared purchase',
|
sharedPurchaseFallback: 'shared purchase',
|
||||||
|
|||||||
@@ -258,7 +258,34 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
|||||||
reminders: {
|
reminders: {
|
||||||
utilities: (period) => `Напоминание по коммунальным платежам за ${period}`,
|
utilities: (period) => `Напоминание по коммунальным платежам за ${period}`,
|
||||||
rentWarning: (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: {
|
purchase: {
|
||||||
sharedPurchaseFallback: 'общая покупка',
|
sharedPurchaseFallback: 'общая покупка',
|
||||||
|
|||||||
@@ -242,6 +242,26 @@ export interface BotTranslationCatalog {
|
|||||||
utilities: (period: string) => string
|
utilities: (period: string) => string
|
||||||
rentWarning: (period: string) => string
|
rentWarning: (period: string) => string
|
||||||
rentDue: (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: {
|
purchase: {
|
||||||
sharedPurchaseFallback: string
|
sharedPurchaseFallback: string
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
import { webhookCallback } from 'grammy'
|
||||||
|
import type { InlineKeyboardMarkup } from 'grammy/types'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAnonymousFeedbackService,
|
createAnonymousFeedbackService,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
} from './purchase-topic-ingestion'
|
} from './purchase-topic-ingestion'
|
||||||
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
|
import { registerConfiguredPaymentTopicIngestion } from './payment-topic-ingestion'
|
||||||
import { createReminderJobsHandler } from './reminder-jobs'
|
import { createReminderJobsHandler } from './reminder-jobs'
|
||||||
|
import { registerReminderTopicUtilities } from './reminder-topic-utilities'
|
||||||
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
|
||||||
import { createBotWebhookServer } from './server'
|
import { createBotWebhookServer } from './server'
|
||||||
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-auth'
|
||||||
@@ -328,7 +330,7 @@ const reminderJobs = runtime.reminderJobsEnabled
|
|||||||
},
|
},
|
||||||
releaseReminderDispatch: (input) =>
|
releaseReminderDispatch: (input) =>
|
||||||
reminderRepositoryClient.repository.releaseReminderDispatch(input),
|
reminderRepositoryClient.repository.releaseReminderDispatch(input),
|
||||||
sendReminderMessage: async (target, text) => {
|
sendReminderMessage: async (target, content) => {
|
||||||
const threadId =
|
const threadId =
|
||||||
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
|
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
|
||||||
|
|
||||||
@@ -338,17 +340,25 @@ const reminderJobs = runtime.reminderJobsEnabled
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
await bot.api.sendMessage(
|
await bot.api.sendMessage(target.telegramChatId, content.text, {
|
||||||
target.telegramChatId,
|
...(threadId
|
||||||
text,
|
|
||||||
threadId
|
|
||||||
? {
|
? {
|
||||||
message_thread_id: threadId
|
message_thread_id: threadId
|
||||||
}
|
}
|
||||||
: undefined
|
: {}),
|
||||||
)
|
...(content.replyMarkup
|
||||||
|
? {
|
||||||
|
reply_markup: content.replyMarkup as InlineKeyboardMarkup
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
reminderService,
|
reminderService,
|
||||||
|
...(runtime.miniAppAllowedOrigins[0]
|
||||||
|
? {
|
||||||
|
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
logger: getLogger('scheduler')
|
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
|
|||||||
@@ -59,7 +59,23 @@ describe('createReminderJobsHandler', () => {
|
|||||||
expect(sendReminderMessage).toHaveBeenCalledTimes(1)
|
expect(sendReminderMessage).toHaveBeenCalledTimes(1)
|
||||||
expect(sendReminderMessage).toHaveBeenCalledWith(
|
expect(sendReminderMessage).toHaveBeenCalledWith(
|
||||||
target,
|
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)
|
expect(response.status).toBe(200)
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { ReminderJobService } from '@household/application'
|
|||||||
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
|
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
||||||
|
import type { InlineKeyboardMarkup } from 'grammy/types'
|
||||||
|
|
||||||
import { getBotTranslations } from './i18n'
|
import { getBotTranslations } from './i18n'
|
||||||
|
import { buildUtilitiesReminderReplyMarkup } from './reminder-topic-utilities'
|
||||||
|
|
||||||
interface ReminderJobRequestBody {
|
interface ReminderJobRequestBody {
|
||||||
period?: string
|
period?: string
|
||||||
@@ -11,6 +13,11 @@ interface ReminderJobRequestBody {
|
|||||||
dryRun?: boolean
|
dryRun?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReminderMessageContent {
|
||||||
|
text: string
|
||||||
|
replyMarkup?: InlineKeyboardMarkup
|
||||||
|
}
|
||||||
|
|
||||||
function json(body: object, status = 200): Response {
|
function json(body: object, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status,
|
status,
|
||||||
@@ -82,24 +89,36 @@ export function createReminderJobsHandler(options: {
|
|||||||
period: string
|
period: string
|
||||||
reminderType: ReminderType
|
reminderType: ReminderType
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
sendReminderMessage: (target: ReminderTarget, content: ReminderMessageContent) => Promise<void>
|
||||||
reminderService: ReminderJobService
|
reminderService: ReminderJobService
|
||||||
forceDryRun?: boolean
|
forceDryRun?: boolean
|
||||||
now?: () => Temporal.Instant
|
now?: () => Temporal.Instant
|
||||||
|
miniAppUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): {
|
}): {
|
||||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
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
|
const t = getBotTranslations(target.locale).reminders
|
||||||
|
|
||||||
switch (reminderType) {
|
switch (reminderType) {
|
||||||
case 'utilities':
|
case 'utilities':
|
||||||
return t.utilities(period)
|
return {
|
||||||
|
text: t.utilities(period),
|
||||||
|
replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, options.miniAppUrl)
|
||||||
|
}
|
||||||
case 'rent-warning':
|
case 'rent-warning':
|
||||||
return t.rentWarning(period)
|
return {
|
||||||
|
text: t.rentWarning(period)
|
||||||
|
}
|
||||||
case 'rent-due':
|
case 'rent-due':
|
||||||
return t.rentDue(period)
|
return {
|
||||||
|
text: t.rentDue(period)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,14 +168,14 @@ export function createReminderJobsHandler(options: {
|
|||||||
reminderType,
|
reminderType,
|
||||||
dryRun
|
dryRun
|
||||||
})
|
})
|
||||||
const text = messageText(target, reminderType, period)
|
const content = messageContent(target, reminderType, period)
|
||||||
|
|
||||||
let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status
|
let outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed' = result.status
|
||||||
let error: string | undefined
|
let error: string | undefined
|
||||||
|
|
||||||
if (result.status === 'claimed') {
|
if (result.status === 'claimed') {
|
||||||
try {
|
try {
|
||||||
await options.sendReminderMessage(target, text)
|
await options.sendReminderMessage(target, content)
|
||||||
} catch (dispatchError) {
|
} catch (dispatchError) {
|
||||||
await options.releaseReminderDispatch({
|
await options.releaseReminderDispatch({
|
||||||
householdId: target.householdId,
|
householdId: target.householdId,
|
||||||
@@ -196,7 +215,7 @@ export function createReminderJobsHandler(options: {
|
|||||||
period,
|
period,
|
||||||
dedupeKey: result.dedupeKey,
|
dedupeKey: result.dedupeKey,
|
||||||
outcome,
|
outcome,
|
||||||
messageText: text,
|
messageText: content.text,
|
||||||
...(error ? { error } : {})
|
...(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'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (raw === 'reminder_utility_entry') {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
if (raw === 'setup_topic_binding') {
|
if (raw === 'setup_topic_binding') {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const TELEGRAM_PENDING_ACTION_TYPES = [
|
|||||||
'household_group_invite',
|
'household_group_invite',
|
||||||
'payment_topic_clarification',
|
'payment_topic_clarification',
|
||||||
'payment_topic_confirmation',
|
'payment_topic_confirmation',
|
||||||
|
'reminder_utility_entry',
|
||||||
'setup_topic_binding'
|
'setup_topic_binding'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user