feat(bot): add reminders topic binding command

This commit is contained in:
2026-03-10 02:01:56 +04:00
parent 29563c24eb
commit 9c4fe5cb52
15 changed files with 321 additions and 121 deletions

View File

@@ -65,6 +65,40 @@ function bindRejectionMessage(
}
}
function bindTopicUsageMessage(
locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders'
): string {
const t = getBotTranslations(locale).setup
switch (role) {
case 'purchase':
return t.useBindPurchaseTopicInGroup
case 'feedback':
return t.useBindFeedbackTopicInGroup
case 'reminders':
return t.useBindRemindersTopicInGroup
}
}
function bindTopicSuccessMessage(
locale: BotLocale,
role: 'purchase' | 'feedback' | 'reminders',
householdName: string,
threadId: string
): string {
const t = getBotTranslations(locale).setup
switch (role) {
case 'purchase':
return t.purchaseTopicSaved(householdName, threadId)
case 'feedback':
return t.feedbackTopicSaved(householdName, threadId)
case 'reminders':
return t.remindersTopicSaved(householdName, threadId)
}
}
function adminRejectionMessage(
locale: BotLocale,
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
@@ -182,6 +216,63 @@ export function registerHouseholdSetupCommands(options: {
miniAppUrl?: string
logger?: Logger
}): void {
async function handleBindTopicCommand(
ctx: Context,
role: 'purchase' | 'feedback' | 'reminders'
): Promise<void> {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
if (!isGroupChat(ctx)) {
await ctx.reply(bindTopicUsageMessage(locale, role))
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const telegramThreadId =
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
? ctx.msg.message_thread_id?.toString()
: undefined
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
role,
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(locale, result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.topic_bound',
role: result.binding.role,
telegramChatId: result.household.telegramChatId,
telegramThreadId: result.binding.telegramThreadId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString()
},
'Household topic bound'
)
await ctx.reply(
bindTopicSuccessMessage(
locale,
role,
result.household.householdName,
result.binding.telegramThreadId
)
)
}
options.bot.command('start', async (ctx) => {
const fallbackLocale = await resolveReplyLocale({
ctx,
@@ -353,103 +444,15 @@ export function registerHouseholdSetupCommands(options: {
})
options.bot.command('bind_purchase_topic', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale)
if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useBindPurchaseTopicInGroup)
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const telegramThreadId =
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
? ctx.msg.message_thread_id?.toString()
: undefined
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
role: 'purchase',
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(locale, result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.topic_bound',
role: result.binding.role,
telegramChatId: result.household.telegramChatId,
telegramThreadId: result.binding.telegramThreadId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString()
},
'Household topic bound'
)
await ctx.reply(
t.setup.purchaseTopicSaved(result.household.householdName, result.binding.telegramThreadId)
)
await handleBindTopicCommand(ctx, 'purchase')
})
options.bot.command('bind_feedback_topic', async (ctx) => {
const locale = await resolveReplyLocale({
ctx,
repository: options.householdConfigurationRepository
})
const t = getBotTranslations(locale)
await handleBindTopicCommand(ctx, 'feedback')
})
if (!isGroupChat(ctx)) {
await ctx.reply(t.setup.useBindFeedbackTopicInGroup)
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const telegramThreadId =
isTopicMessage(ctx) && ctx.msg && 'message_thread_id' in ctx.msg
? ctx.msg.message_thread_id?.toString()
: undefined
const result = await options.householdSetupService.bindTopic({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
role: 'feedback',
...(telegramThreadId
? {
telegramThreadId
}
: {})
})
if (result.status === 'rejected') {
await ctx.reply(bindRejectionMessage(locale, result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.topic_bound',
role: result.binding.role,
telegramChatId: result.household.telegramChatId,
telegramThreadId: result.binding.telegramThreadId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString()
},
'Household topic bound'
)
await ctx.reply(
t.setup.feedbackTopicSaved(result.household.householdName, result.binding.telegramThreadId)
)
options.bot.command('bind_reminders_topic', async (ctx) => {
await handleBindTopicCommand(ctx, 'reminders')
})
options.bot.command('pending_members', async (ctx) => {

View File

@@ -10,6 +10,7 @@ export const enBotTranslations: BotTranslationCatalog = {
setup: 'Register this group as a household',
bind_purchase_topic: 'Bind the current topic as purchases',
bind_feedback_topic: 'Bind the current topic as feedback',
bind_reminders_topic: 'Bind the current topic as reminders',
pending_members: 'List pending household join requests',
approve_member: 'Approve a pending household member'
},
@@ -52,7 +53,7 @@ export const enBotTranslations: BotTranslationCatalog = {
[
`Household ${created ? 'created' : 'already registered'}: ${householdName}`,
`Chat ID: ${telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic. If you want a dedicated reminders topic, open it and run /bind_reminders_topic.',
'Members should open the bot chat from the button below and confirm the join request there.'
].join('\n'),
useBindPurchaseTopicInGroup: 'Use /bind_purchase_topic inside the household group topic.',
@@ -61,6 +62,9 @@ export const enBotTranslations: BotTranslationCatalog = {
useBindFeedbackTopicInGroup: 'Use /bind_feedback_topic inside the household group topic.',
feedbackTopicSaved: (householdName, threadId) =>
`Feedback topic saved for ${householdName} (thread ${threadId}).`,
useBindRemindersTopicInGroup: 'Use /bind_reminders_topic inside the household group topic.',
remindersTopicSaved: (householdName, threadId) =>
`Reminders topic saved for ${householdName} (thread ${threadId}).`,
usePendingMembersInGroup: 'Use /pending_members inside the household group.',
useApproveMemberInGroup: 'Use /approve_member inside the household group.',
approveMemberUsage: 'Usage: /approve_member <telegram_user_id>',

View File

@@ -10,6 +10,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
setup: 'Подключить эту группу как дом',
bind_purchase_topic: 'Назначить текущий топик для покупок',
bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений',
bind_reminders_topic: 'Назначить текущий топик для напоминаний',
pending_members: 'Показать ожидающие заявки на вступление',
approve_member: 'Подтвердить участника дома'
},
@@ -54,7 +55,7 @@ export const ruBotTranslations: BotTranslationCatalog = {
[
`${created ? 'Дом создан' : 'Дом уже подключён'}: ${householdName}`,
`ID чата: ${telegramChatId}`,
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic.',
'Дальше: откройте топик покупок и выполните /bind_purchase_topic, затем откройте топик обратной связи и выполните /bind_feedback_topic. Если хотите отдельный топик для напоминаний, откройте его и выполните /bind_reminders_topic.',
'Участники должны открыть чат с ботом по кнопке ниже и подтвердить заявку на вступление.'
].join('\n'),
useBindPurchaseTopicInGroup: 'Используйте /bind_purchase_topic внутри топика группы дома.',
@@ -63,6 +64,9 @@ export const ruBotTranslations: BotTranslationCatalog = {
useBindFeedbackTopicInGroup: 'Используйте /bind_feedback_topic внутри топика группы дома.',
feedbackTopicSaved: (householdName, threadId) =>
`Топик обратной связи сохранён для ${householdName} (тред ${threadId}).`,
useBindRemindersTopicInGroup: 'Используйте /bind_reminders_topic внутри топика группы дома.',
remindersTopicSaved: (householdName, threadId) =>
`Топик напоминаний сохранён для ${householdName} (тред ${threadId}).`,
usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.',
useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.',
approveMemberUsage: 'Использование: /approve_member <telegram_user_id>',

View File

@@ -8,6 +8,7 @@ export type TelegramCommandName =
| 'setup'
| 'bind_purchase_topic'
| 'bind_feedback_topic'
| 'bind_reminders_topic'
| 'pending_members'
| 'approve_member'
@@ -19,6 +20,7 @@ export interface BotCommandDescriptions {
setup: string
bind_purchase_topic: string
bind_feedback_topic: string
bind_reminders_topic: string
pending_members: string
approve_member: string
}
@@ -73,6 +75,8 @@ export interface BotTranslationCatalog {
purchaseTopicSaved: (householdName: string, threadId: string) => string
useBindFeedbackTopicInGroup: string
feedbackTopicSaved: (householdName: string, threadId: string) => string
useBindRemindersTopicInGroup: string
remindersTopicSaved: (householdName: string, threadId: string) => string
usePendingMembersInGroup: string
useApproveMemberInGroup: string
approveMemberUsage: string

View File

@@ -1,6 +1,7 @@
import { describe, expect, mock, test } from 'bun:test'
import type { ReminderJobResult, ReminderJobService } from '@household/application'
import { Temporal } from '@household/domain'
import type { ReminderTarget } from '@household/ports'
import { createReminderJobsHandler } from './reminder-jobs'
@@ -10,9 +11,16 @@ const target: ReminderTarget = {
householdName: 'Kojori House',
telegramChatId: '-1001',
telegramThreadId: '12',
locale: 'ru'
locale: 'ru',
timezone: 'Asia/Tbilisi',
rentDueDay: 20,
rentWarningDay: 17,
utilitiesDueDay: 4,
utilitiesReminderDay: 3
}
const fixedNow = Temporal.Instant.from('2026-03-03T09:00:00Z')
describe('createReminderJobsHandler', () => {
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
const claimedResult: ReminderJobResult = {
@@ -33,7 +41,8 @@ describe('createReminderJobsHandler', () => {
listReminderTargets: async () => [target],
releaseReminderDispatch: mock(async () => {}),
sendReminderMessage,
reminderService
reminderService,
now: () => fixedNow
})
const response = await handler.handle(
@@ -73,6 +82,7 @@ describe('createReminderJobsHandler', () => {
householdName: 'Kojori House',
telegramChatId: '-1001',
telegramThreadId: '12',
period: '2026-03',
dedupeKey: '2026-03:utilities',
outcome: 'claimed',
messageText: 'Напоминание по коммунальным платежам за 2026-03'
@@ -101,7 +111,8 @@ describe('createReminderJobsHandler', () => {
releaseReminderDispatch: mock(async () => {}),
sendReminderMessage,
reminderService,
forceDryRun: true
forceDryRun: true,
now: () => fixedNow
})
const response = await handler.handle(
@@ -146,7 +157,8 @@ describe('createReminderJobsHandler', () => {
sendReminderMessage: mock(async () => {
throw new Error('Telegram unavailable')
}),
reminderService
reminderService,
now: () => fixedNow
})
const response = await handler.handle(
@@ -185,7 +197,8 @@ describe('createReminderJobsHandler', () => {
handleJob: mock(async () => {
throw new Error('should not be called')
})
}
},
now: () => fixedNow
})
const response = await handler.handle(
@@ -202,4 +215,94 @@ describe('createReminderJobsHandler', () => {
error: 'Invalid reminder type'
})
})
test('skips households whose configured reminder day does not match today when no period override is supplied', async () => {
const reminderService: ReminderJobService = {
handleJob: mock(async () => {
throw new Error('should not be called')
})
}
const handler = createReminderJobsHandler({
listReminderTargets: async () => [
{
...target,
utilitiesReminderDay: 31
}
],
releaseReminderDispatch: mock(async () => {}),
sendReminderMessage: mock(async () => {}),
reminderService,
now: () => fixedNow
})
const response = await handler.handle(
new Request('http://localhost/jobs/reminder/utilities', {
method: 'POST',
body: JSON.stringify({ jobId: 'job-3' })
}),
'utilities'
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
ok: true,
totals: {
targets: 0
},
dispatches: []
})
})
test('honors explicit period overrides even when today is not the configured reminder day', async () => {
const dryRunResult: ReminderJobResult = {
status: 'dry-run',
dedupeKey: '2026-03:rent-due',
payloadHash: 'hash',
reminderType: 'rent-due',
period: '2026-03',
messageText: 'Rent due reminder for 2026-03: please settle payment today.'
}
const reminderService: ReminderJobService = {
handleJob: mock(async () => dryRunResult)
}
const handler = createReminderJobsHandler({
listReminderTargets: async () => [
{
...target,
rentDueDay: 20
}
],
releaseReminderDispatch: mock(async () => {}),
sendReminderMessage: mock(async () => {}),
reminderService,
now: () => fixedNow
})
const response = await handler.handle(
new Request('http://localhost/jobs/reminder/rent-due', {
method: 'POST',
body: JSON.stringify({ period: '2026-03', dryRun: true })
}),
'rent-due'
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
ok: true,
totals: {
targets: 1,
'dry-run': 1
},
dispatches: [
expect.objectContaining({
householdId: 'household-1',
period: '2026-03',
outcome: 'dry-run'
})
]
})
})
})

View File

@@ -1,5 +1,5 @@
import type { ReminderJobService } from '@household/application'
import { BillingPeriod, nowInstant } from '@household/domain'
import { BillingPeriod, Temporal, nowInstant } from '@household/domain'
import type { Logger } from '@household/observability'
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
@@ -32,6 +32,34 @@ function currentPeriod(): string {
return BillingPeriod.fromInstant(nowInstant()).toString()
}
function targetLocalDate(target: ReminderTarget, instant: Temporal.Instant) {
return instant.toZonedDateTimeISO(target.timezone)
}
function isReminderDueToday(
target: ReminderTarget,
reminderType: ReminderType,
instant: Temporal.Instant
): boolean {
const currentDay = targetLocalDate(target, instant).day
switch (reminderType) {
case 'utilities':
return currentDay === target.utilitiesReminderDay
case 'rent-warning':
return currentDay === target.rentWarningDay
case 'rent-due':
return currentDay === target.rentDueDay
}
}
function targetPeriod(target: ReminderTarget, instant: Temporal.Instant): string {
const localDate = targetLocalDate(target, instant)
return BillingPeriod.fromString(
`${localDate.year}-${String(localDate.month).padStart(2, '0')}`
).toString()
}
async function readBody(request: Request): Promise<ReminderJobRequestBody> {
const text = await request.text()
@@ -56,6 +84,7 @@ export function createReminderJobsHandler(options: {
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
reminderService: ReminderJobService
forceDryRun?: boolean
now?: () => Temporal.Instant
logger?: Logger
}): {
handle: (request: Request, rawReminderType: string) => Promise<Response>
@@ -83,14 +112,19 @@ export function createReminderJobsHandler(options: {
try {
const body = await readBody(request)
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
const requestedPeriod = body.period
? BillingPeriod.fromString(body.period).toString()
: null
const defaultPeriod = requestedPeriod ?? currentPeriod()
const dryRun = options.forceDryRun === true || body.dryRun === true
const currentInstant = options.now?.() ?? nowInstant()
const targets = await options.listReminderTargets()
const dispatches: Array<{
householdId: string
householdName: string
telegramChatId: string
telegramThreadId: string | null
period: string
dedupeKey: string
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
messageText: string
@@ -98,6 +132,11 @@ export function createReminderJobsHandler(options: {
}> = []
for (const target of targets) {
if (!requestedPeriod && !isReminderDueToday(target, reminderType, currentInstant)) {
continue
}
const period = requestedPeriod ?? targetPeriod(target, currentInstant)
const result = await options.reminderService.handleJob({
householdId: target.householdId,
period,
@@ -148,6 +187,7 @@ export function createReminderJobsHandler(options: {
householdName: target.householdName,
telegramChatId: target.telegramChatId,
telegramThreadId: target.telegramThreadId,
period,
dedupeKey: result.dedupeKey,
outcome,
messageText: text,
@@ -174,7 +214,7 @@ export function createReminderJobsHandler(options: {
ok: true,
jobId: body.jobId ?? schedulerJobName ?? null,
reminderType,
period,
period: defaultPeriod,
dryRun,
totals,
dispatches

View File

@@ -26,6 +26,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [
'setup',
'bind_purchase_topic',
'bind_feedback_topic',
'bind_reminders_topic',
'pending_members',
'approve_member'
] as const satisfies readonly TelegramCommandName[]