mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 16:14:02 +00:00
feat(bot): add multi-household reminder delivery
This commit is contained in:
@@ -132,6 +132,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
: null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async () => ({
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface BotRuntimeConfig {
|
||||
telegramWebhookSecret: string
|
||||
telegramWebhookPath: string
|
||||
databaseUrl?: string
|
||||
householdId?: string
|
||||
telegramHouseholdChatId?: string
|
||||
telegramPurchaseTopicId?: number
|
||||
telegramFeedbackTopicId?: number
|
||||
@@ -94,7 +93,6 @@ function parseOptionalCsv(value: string | undefined): readonly string[] {
|
||||
|
||||
export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRuntimeConfig {
|
||||
const databaseUrl = parseOptionalValue(env.DATABASE_URL)
|
||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_ID)
|
||||
const telegramFeedbackTopicId = parseOptionalTopicId(env.TELEGRAM_FEEDBACK_TOPIC_ID)
|
||||
@@ -109,9 +107,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
const miniAppAuthEnabled = databaseUrl !== undefined
|
||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||
const reminderJobsEnabled =
|
||||
databaseUrl !== undefined &&
|
||||
householdId !== undefined &&
|
||||
(schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
||||
databaseUrl !== undefined && (schedulerSharedSecret !== undefined || hasSchedulerOidcConfig)
|
||||
|
||||
const runtime: BotRuntimeConfig = {
|
||||
port: parsePort(env.PORT),
|
||||
@@ -132,9 +128,6 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
||||
if (databaseUrl !== undefined) {
|
||||
runtime.databaseUrl = databaseUrl
|
||||
}
|
||||
if (householdId !== undefined) {
|
||||
runtime.householdId = householdId
|
||||
}
|
||||
if (telegramHouseholdChatId !== undefined) {
|
||||
runtime.telegramHouseholdChatId = telegramHouseholdChatId
|
||||
}
|
||||
|
||||
@@ -133,6 +133,11 @@ export const enBotTranslations: BotTranslationCatalog = {
|
||||
statementTotal: (amount, currency) => `Total: ${amount} ${currency}`,
|
||||
statementFailed: (message) => `Failed to generate statement: ${message}`
|
||||
},
|
||||
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.`
|
||||
},
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'shared purchase',
|
||||
recorded: (summary) => `Recorded purchase: ${summary}`,
|
||||
|
||||
@@ -136,6 +136,11 @@ export const ruBotTranslations: BotTranslationCatalog = {
|
||||
statementTotal: (amount, currency) => `Итого: ${amount} ${currency}`,
|
||||
statementFailed: (message) => `Не удалось построить выписку: ${message}`
|
||||
},
|
||||
reminders: {
|
||||
utilities: (period) => `Напоминание по коммунальным платежам за ${period}`,
|
||||
rentWarning: (period) => `Напоминание по аренде за ${period}: срок оплаты скоро наступит.`,
|
||||
rentDue: (period) => `Напоминание по аренде за ${period}: пожалуйста, оплатите сегодня.`
|
||||
},
|
||||
purchase: {
|
||||
sharedPurchaseFallback: 'общая покупка',
|
||||
recorded: (summary) => `Покупка сохранена: ${summary}`,
|
||||
|
||||
@@ -138,6 +138,11 @@ export interface BotTranslationCatalog {
|
||||
statementTotal: (amount: string, currency: string) => string
|
||||
statementFailed: (message: string) => string
|
||||
}
|
||||
reminders: {
|
||||
utilities: (period: string) => string
|
||||
rentWarning: (period: string) => string
|
||||
rentDue: (period: string) => string
|
||||
}
|
||||
purchase: {
|
||||
sharedPurchaseFallback: string
|
||||
recorded: (summary: string) => string
|
||||
|
||||
@@ -202,7 +202,30 @@ const reminderJobs = runtime.reminderJobsEnabled
|
||||
shutdownTasks.push(reminderRepositoryClient.close)
|
||||
|
||||
return createReminderJobsHandler({
|
||||
householdId: runtime.householdId!,
|
||||
listReminderTargets: () =>
|
||||
householdConfigurationRepositoryClient!.repository.listReminderTargets(),
|
||||
releaseReminderDispatch: (input) =>
|
||||
reminderRepositoryClient.repository.releaseReminderDispatch(input),
|
||||
sendReminderMessage: async (target, text) => {
|
||||
const threadId =
|
||||
target.telegramThreadId !== null ? Number(target.telegramThreadId) : undefined
|
||||
|
||||
if (target.telegramThreadId !== null && (!threadId || !Number.isInteger(threadId))) {
|
||||
throw new Error(
|
||||
`Invalid reminder thread id for household ${target.householdId}: ${target.telegramThreadId}`
|
||||
)
|
||||
}
|
||||
|
||||
await bot.api.sendMessage(
|
||||
target.telegramChatId,
|
||||
text,
|
||||
threadId
|
||||
? {
|
||||
message_thread_id: threadId
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
},
|
||||
reminderService,
|
||||
logger: getLogger('scheduler')
|
||||
})
|
||||
@@ -215,7 +238,7 @@ if (!runtime.reminderJobsEnabled) {
|
||||
event: 'runtime.feature_disabled',
|
||||
feature: 'reminder-jobs'
|
||||
},
|
||||
'Reminder jobs are disabled. Set DATABASE_URL, HOUSEHOLD_ID, and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
||||
'Reminder jobs are disabled. Set DATABASE_URL and either SCHEDULER_SHARED_SECRET or SCHEDULER_OIDC_ALLOWED_EMAILS to enable.'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
|
||||
@@ -58,6 +58,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
|
||||
@@ -97,6 +97,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async (input) => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
|
||||
@@ -64,6 +64,7 @@ function repository(): HouseholdConfigurationRepository {
|
||||
getHouseholdTopicBinding: async () => null,
|
||||
findHouseholdTopicByTelegramContext: async () => null,
|
||||
listHouseholdTopicBindings: async () => [],
|
||||
listReminderTargets: async () => [],
|
||||
upsertHouseholdJoinToken: async () => ({
|
||||
householdId: household.householdId,
|
||||
householdName: household.householdName,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
import type { ReminderJobResult, ReminderJobService } from '@household/application'
|
||||
import type { ReminderTarget } from '@household/ports'
|
||||
|
||||
import { createReminderJobsHandler } from './reminder-jobs'
|
||||
|
||||
const target: ReminderTarget = {
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-1001',
|
||||
telegramThreadId: '12',
|
||||
locale: 'ru'
|
||||
}
|
||||
|
||||
describe('createReminderJobsHandler', () => {
|
||||
test('returns job outcome with dedupe metadata', async () => {
|
||||
test('returns per-household dispatch outcome with Telegram delivery metadata', async () => {
|
||||
const claimedResult: ReminderJobResult = {
|
||||
status: 'claimed',
|
||||
dedupeKey: '2026-03:utilities',
|
||||
@@ -18,9 +27,12 @@ describe('createReminderJobsHandler', () => {
|
||||
const reminderService: ReminderJobService = {
|
||||
handleJob: mock(async () => claimedResult)
|
||||
}
|
||||
const sendReminderMessage = mock(async () => {})
|
||||
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
listReminderTargets: async () => [target],
|
||||
releaseReminderDispatch: mock(async () => {}),
|
||||
sendReminderMessage,
|
||||
reminderService
|
||||
})
|
||||
|
||||
@@ -35,20 +47,41 @@ describe('createReminderJobsHandler', () => {
|
||||
'utilities'
|
||||
)
|
||||
|
||||
expect(sendReminderMessage).toHaveBeenCalledTimes(1)
|
||||
expect(sendReminderMessage).toHaveBeenCalledWith(
|
||||
target,
|
||||
'Напоминание по коммунальным платежам за 2026-03'
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
ok: true,
|
||||
jobId: 'job-1',
|
||||
reminderType: 'utilities',
|
||||
period: '2026-03',
|
||||
dedupeKey: '2026-03:utilities',
|
||||
outcome: 'claimed',
|
||||
dryRun: false,
|
||||
messageText: 'Utilities reminder for 2026-03'
|
||||
totals: {
|
||||
targets: 1,
|
||||
claimed: 1,
|
||||
duplicate: 0,
|
||||
'dry-run': 0,
|
||||
failed: 0
|
||||
},
|
||||
dispatches: [
|
||||
{
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-1001',
|
||||
telegramThreadId: '12',
|
||||
dedupeKey: '2026-03:utilities',
|
||||
outcome: 'claimed',
|
||||
messageText: 'Напоминание по коммунальным платежам за 2026-03'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test('supports forced dry-run mode', async () => {
|
||||
test('supports forced dry-run mode without posting to Telegram', async () => {
|
||||
const dryRunResult: ReminderJobResult = {
|
||||
status: 'dry-run',
|
||||
dedupeKey: '2026-03:rent-warning',
|
||||
@@ -61,9 +94,12 @@ describe('createReminderJobsHandler', () => {
|
||||
const reminderService: ReminderJobService = {
|
||||
handleJob: mock(async () => dryRunResult)
|
||||
}
|
||||
const sendReminderMessage = mock(async () => {})
|
||||
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
listReminderTargets: async () => [target],
|
||||
releaseReminderDispatch: mock(async () => {}),
|
||||
sendReminderMessage,
|
||||
reminderService,
|
||||
forceDryRun: true
|
||||
})
|
||||
@@ -76,16 +112,75 @@ describe('createReminderJobsHandler', () => {
|
||||
'rent-warning'
|
||||
)
|
||||
|
||||
expect(sendReminderMessage).toHaveBeenCalledTimes(0)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
outcome: 'dry-run',
|
||||
dryRun: true
|
||||
dryRun: true,
|
||||
totals: {
|
||||
targets: 1,
|
||||
claimed: 0,
|
||||
duplicate: 0,
|
||||
'dry-run': 1,
|
||||
failed: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('releases a dispatch claim when Telegram delivery fails', async () => {
|
||||
const failedResult: ReminderJobResult = {
|
||||
status: 'claimed',
|
||||
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 () => failedResult)
|
||||
}
|
||||
const releaseReminderDispatch = mock(async () => {})
|
||||
|
||||
const handler = createReminderJobsHandler({
|
||||
listReminderTargets: async () => [target],
|
||||
releaseReminderDispatch,
|
||||
sendReminderMessage: mock(async () => {
|
||||
throw new Error('Telegram unavailable')
|
||||
}),
|
||||
reminderService
|
||||
})
|
||||
|
||||
const response = await handler.handle(
|
||||
new Request('http://localhost/jobs/reminder/rent-due', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ period: '2026-03' })
|
||||
}),
|
||||
'rent-due'
|
||||
)
|
||||
|
||||
expect(releaseReminderDispatch).toHaveBeenCalledWith({
|
||||
householdId: 'household-1',
|
||||
period: '2026-03',
|
||||
reminderType: 'rent-due'
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
totals: {
|
||||
failed: 1
|
||||
},
|
||||
dispatches: [
|
||||
expect.objectContaining({
|
||||
outcome: 'failed',
|
||||
error: 'Telegram unavailable'
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects unsupported reminder type', async () => {
|
||||
const handler = createReminderJobsHandler({
|
||||
householdId: 'household-1',
|
||||
listReminderTargets: async () => [target],
|
||||
releaseReminderDispatch: mock(async () => {}),
|
||||
sendReminderMessage: mock(async () => {}),
|
||||
reminderService: {
|
||||
handleJob: mock(async () => {
|
||||
throw new Error('should not be called')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ReminderJobService } from '@household/application'
|
||||
import { BillingPeriod, nowInstant } from '@household/domain'
|
||||
import type { Logger } from '@household/observability'
|
||||
import { REMINDER_TYPES, type ReminderType } from '@household/ports'
|
||||
import { REMINDER_TYPES, type ReminderTarget, type ReminderType } from '@household/ports'
|
||||
|
||||
import { getBotTranslations } from './i18n'
|
||||
|
||||
interface ReminderJobRequestBody {
|
||||
period?: string
|
||||
@@ -45,13 +47,32 @@ async function readBody(request: Request): Promise<ReminderJobRequestBody> {
|
||||
}
|
||||
|
||||
export function createReminderJobsHandler(options: {
|
||||
householdId: string
|
||||
listReminderTargets: () => Promise<readonly ReminderTarget[]>
|
||||
releaseReminderDispatch: (input: {
|
||||
householdId: string
|
||||
period: string
|
||||
reminderType: ReminderType
|
||||
}) => Promise<void>
|
||||
sendReminderMessage: (target: ReminderTarget, text: string) => Promise<void>
|
||||
reminderService: ReminderJobService
|
||||
forceDryRun?: boolean
|
||||
logger?: Logger
|
||||
}): {
|
||||
handle: (request: Request, rawReminderType: string) => Promise<Response>
|
||||
} {
|
||||
function messageText(target: ReminderTarget, reminderType: ReminderType, period: string): string {
|
||||
const t = getBotTranslations(target.locale).reminders
|
||||
|
||||
switch (reminderType) {
|
||||
case 'utilities':
|
||||
return t.utilities(period)
|
||||
case 'rent-warning':
|
||||
return t.rentWarning(period)
|
||||
case 'rent-due':
|
||||
return t.rentDue(period)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handle: async (request, rawReminderType) => {
|
||||
const reminderType = parseReminderType(rawReminderType)
|
||||
@@ -64,34 +85,99 @@ export function createReminderJobsHandler(options: {
|
||||
const schedulerJobName = request.headers.get('x-cloudscheduler-jobname')
|
||||
const period = BillingPeriod.fromString(body.period ?? currentPeriod()).toString()
|
||||
const dryRun = options.forceDryRun === true || body.dryRun === true
|
||||
const result = await options.reminderService.handleJob({
|
||||
householdId: options.householdId,
|
||||
period,
|
||||
reminderType,
|
||||
dryRun
|
||||
})
|
||||
const targets = await options.listReminderTargets()
|
||||
const dispatches: Array<{
|
||||
householdId: string
|
||||
householdName: string
|
||||
telegramChatId: string
|
||||
telegramThreadId: string | null
|
||||
dedupeKey: string
|
||||
outcome: 'dry-run' | 'claimed' | 'duplicate' | 'failed'
|
||||
messageText: string
|
||||
error?: string
|
||||
}> = []
|
||||
|
||||
const logPayload = {
|
||||
event: 'scheduler.reminder.dispatch',
|
||||
reminderType,
|
||||
period,
|
||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome: result.status,
|
||||
dryRun
|
||||
for (const target of targets) {
|
||||
const result = await options.reminderService.handleJob({
|
||||
householdId: target.householdId,
|
||||
period,
|
||||
reminderType,
|
||||
dryRun
|
||||
})
|
||||
const text = messageText(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)
|
||||
} catch (dispatchError) {
|
||||
await options.releaseReminderDispatch({
|
||||
householdId: target.householdId,
|
||||
period,
|
||||
reminderType
|
||||
})
|
||||
|
||||
outcome = 'failed'
|
||||
error =
|
||||
dispatchError instanceof Error
|
||||
? dispatchError.message
|
||||
: 'Unknown reminder delivery error'
|
||||
}
|
||||
}
|
||||
|
||||
options.logger?.info(
|
||||
{
|
||||
event: 'scheduler.reminder.dispatch',
|
||||
reminderType,
|
||||
period,
|
||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||
householdId: target.householdId,
|
||||
householdName: target.householdName,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome,
|
||||
dryRun,
|
||||
...(error ? { error } : {})
|
||||
},
|
||||
'Reminder job processed'
|
||||
)
|
||||
|
||||
dispatches.push({
|
||||
householdId: target.householdId,
|
||||
householdName: target.householdName,
|
||||
telegramChatId: target.telegramChatId,
|
||||
telegramThreadId: target.telegramThreadId,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome,
|
||||
messageText: text,
|
||||
...(error ? { error } : {})
|
||||
})
|
||||
}
|
||||
|
||||
options.logger?.info(logPayload, 'Reminder job processed')
|
||||
const totals = dispatches.reduce(
|
||||
(summary, dispatch) => {
|
||||
summary.targets += 1
|
||||
summary[dispatch.outcome] += 1
|
||||
return summary
|
||||
},
|
||||
{
|
||||
targets: 0,
|
||||
claimed: 0,
|
||||
duplicate: 0,
|
||||
'dry-run': 0,
|
||||
failed: 0
|
||||
}
|
||||
)
|
||||
|
||||
return json({
|
||||
ok: true,
|
||||
jobId: body.jobId ?? schedulerJobName ?? null,
|
||||
reminderType,
|
||||
period,
|
||||
dedupeKey: result.dedupeKey,
|
||||
outcome: result.status,
|
||||
dryRun,
|
||||
messageText: result.messageText
|
||||
totals,
|
||||
dispatches
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown reminder job error'
|
||||
|
||||
Reference in New Issue
Block a user