feat(bot): add multi-household setup flow

This commit is contained in:
2026-03-09 03:40:20 +04:00
parent f3991fe7ce
commit e63d81cda2
21 changed files with 3337 additions and 9 deletions

View File

@@ -11,6 +11,9 @@ export function createTelegramBot(token: string, logger?: Logger): Bot {
'Available commands:',
'/help - Show command list',
'/household_status - Show placeholder household status',
'/setup [household name] - Register this group as a household',
'/bind_purchase_topic - Bind the current topic as the purchase topic',
'/bind_feedback_topic - Bind the current topic as the feedback topic',
'/anon <message> - Send anonymous household feedback in a private chat'
].join('\n')
)

View File

@@ -0,0 +1,184 @@
import type { HouseholdSetupService } from '@household/application'
import type { Logger } from '@household/observability'
import type { Bot, Context } from 'grammy'
function commandArgText(ctx: Context): string {
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
}
function isGroupChat(ctx: Context): ctx is Context & {
chat: NonNullable<Context['chat']> & { type: 'group' | 'supergroup'; title?: string }
} {
return ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
}
function isTopicMessage(ctx: Context): boolean {
const message = ctx.msg
return !!message && 'is_topic_message' in message && message.is_topic_message === true
}
async function isGroupAdmin(ctx: Context): Promise<boolean> {
if (!ctx.chat || !ctx.from) {
return false
}
const member = await ctx.api.getChatMember(ctx.chat.id, ctx.from.id)
return member.status === 'creator' || member.status === 'administrator'
}
function setupRejectionMessage(reason: 'not_admin' | 'invalid_chat_type'): string {
switch (reason) {
case 'not_admin':
return 'Only Telegram group admins can run /setup.'
case 'invalid_chat_type':
return 'Use /setup inside a group or supergroup.'
}
}
function bindRejectionMessage(
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
): string {
switch (reason) {
case 'not_admin':
return 'Only Telegram group admins can bind household topics.'
case 'household_not_found':
return 'Household is not configured for this chat yet. Run /setup first.'
case 'not_topic_message':
return 'Run this command inside the target topic thread.'
}
}
export function registerHouseholdSetupCommands(options: {
bot: Bot
householdSetupService: HouseholdSetupService
logger?: Logger
}): void {
options.bot.command('setup', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /setup inside the household group.')
return
}
const actorIsAdmin = await isGroupAdmin(ctx)
const result = await options.householdSetupService.setupGroupChat({
actorIsAdmin,
telegramChatId: ctx.chat.id.toString(),
telegramChatType: ctx.chat.type,
title: ctx.chat.title,
householdName: commandArgText(ctx)
})
if (result.status === 'rejected') {
await ctx.reply(setupRejectionMessage(result.reason))
return
}
options.logger?.info(
{
event: 'household_setup.chat_registered',
telegramChatId: result.household.telegramChatId,
householdId: result.household.householdId,
actorTelegramUserId: ctx.from?.id?.toString(),
status: result.status
},
'Household group registered'
)
const action = result.status === 'created' ? 'created' : 'already registered'
await ctx.reply(
[
`Household ${action}: ${result.household.householdName}`,
`Chat ID: ${result.household.telegramChatId}`,
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.'
].join('\n')
)
})
options.bot.command('bind_purchase_topic', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /bind_purchase_topic inside the household group topic.')
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(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(
`Purchase topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
)
})
options.bot.command('bind_feedback_topic', async (ctx) => {
if (!isGroupChat(ctx)) {
await ctx.reply('Use /bind_feedback_topic inside the household group topic.')
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(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(
`Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
)
})
}

View File

@@ -3,11 +3,13 @@ import { webhookCallback } from 'grammy'
import {
createAnonymousFeedbackService,
createFinanceCommandService,
createHouseholdSetupService,
createReminderJobService
} from '@household/application'
import {
createDbAnonymousFeedbackRepository,
createDbFinanceRepository,
createDbHouseholdConfigurationRepository,
createDbReminderDispatchRepository
} from '@household/adapters-db'
import { configureLogger, getLogger } from '@household/observability'
@@ -16,10 +18,11 @@ import { registerAnonymousFeedback } from './anonymous-feedback'
import { createFinanceCommandsService } from './finance-commands'
import { createTelegramBot } from './bot'
import { getBotRuntimeConfig } from './config'
import { registerHouseholdSetupCommands } from './household-setup'
import { createOpenAiParserFallback } from './openai-parser-fallback'
import {
createPurchaseMessageRepository,
registerPurchaseTopicIngestion
registerConfiguredPurchaseTopicIngestion
} from './purchase-topic-ingestion'
import { createReminderJobsHandler } from './reminder-jobs'
import { createSchedulerRequestAuthorizer } from './scheduler-auth'
@@ -38,6 +41,9 @@ const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram'))
const webhookHandler = webhookCallback(bot, 'std/http')
const shutdownTasks: Array<() => Promise<void>> = []
const householdConfigurationRepositoryClient = runtime.databaseUrl
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
: null
const financeRepositoryClient =
runtime.financeCommandsEnabled || runtime.miniAppAuthEnabled
? createDbFinanceRepository(runtime.databaseUrl!, runtime.householdId!)
@@ -56,22 +62,22 @@ if (financeRepositoryClient) {
shutdownTasks.push(financeRepositoryClient.close)
}
if (householdConfigurationRepositoryClient) {
shutdownTasks.push(householdConfigurationRepositoryClient.close)
}
if (anonymousFeedbackRepositoryClient) {
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
}
if (runtime.purchaseTopicIngestionEnabled) {
if (runtime.databaseUrl && householdConfigurationRepositoryClient) {
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
shutdownTasks.push(purchaseRepositoryClient.close)
const llmFallback = createOpenAiParserFallback(runtime.openaiApiKey, runtime.parserModel)
registerPurchaseTopicIngestion(
registerConfiguredPurchaseTopicIngestion(
bot,
{
householdId: runtime.householdId!,
householdChatId: runtime.telegramHouseholdChatId!,
purchaseTopicId: runtime.telegramPurchaseTopicId!
},
householdConfigurationRepositoryClient.repository,
purchaseRepositoryClient.repository,
{
...(llmFallback
@@ -88,7 +94,7 @@ if (runtime.purchaseTopicIngestionEnabled) {
event: 'runtime.feature_disabled',
feature: 'purchase-topic-ingestion'
},
'Purchase topic ingestion is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_PURCHASE_TOPIC_ID to enable.'
'Purchase topic ingestion is disabled. Set DATABASE_URL to enable Telegram topic lookups.'
)
}
@@ -106,6 +112,24 @@ if (runtime.financeCommandsEnabled) {
)
}
if (householdConfigurationRepositoryClient) {
registerHouseholdSetupCommands({
bot,
householdSetupService: createHouseholdSetupService(
householdConfigurationRepositoryClient.repository
),
logger: getLogger('household-setup')
})
} else {
logger.warn(
{
event: 'runtime.feature_disabled',
feature: 'household-setup'
},
'Household setup commands are disabled. Set DATABASE_URL to enable.'
)
}
const reminderJobs = runtime.reminderJobsEnabled
? (() => {
const reminderRepositoryClient = createDbReminderDispatchRepository(runtime.databaseUrl!)

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import {
extractPurchaseTopicCandidate,
resolveConfiguredPurchaseTopicRecord,
type PurchaseTopicCandidate
} from './purchase-topic-ingestion'
@@ -60,3 +61,28 @@ describe('extractPurchaseTopicCandidate', () => {
expect(record).toBeNull()
})
})
describe('resolveConfiguredPurchaseTopicRecord', () => {
test('returns record when the configured topic role is purchase', () => {
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
householdId: 'household-1',
role: 'purchase',
telegramThreadId: '777',
topicName: 'Общие покупки'
})
expect(record).not.toBeNull()
expect(record?.householdId).toBe('household-1')
})
test('skips non-purchase topic bindings', () => {
const record = resolveConfiguredPurchaseTopicRecord(candidate(), {
householdId: 'household-1',
role: 'feedback',
telegramThreadId: '777',
topicName: 'Feedback'
})
expect(record).toBeNull()
})
})

View File

@@ -2,6 +2,10 @@ import { parsePurchaseMessage, type PurchaseParserLlmFallback } from '@household
import { and, eq } from 'drizzle-orm'
import type { Bot, Context } from 'grammy'
import type { Logger } from '@household/observability'
import type {
HouseholdConfigurationRepository,
HouseholdTopicBindingRecord
} from '@household/ports'
import { createDbClient, schema } from '@household/db'
@@ -61,6 +65,30 @@ export function extractPurchaseTopicCandidate(
}
}
export function resolveConfiguredPurchaseTopicRecord(
value: PurchaseTopicCandidate,
binding: HouseholdTopicBindingRecord
): PurchaseTopicRecord | null {
if (value.rawText.trim().startsWith('/')) {
return null
}
if (binding.role !== 'purchase') {
return null
}
const normalizedText = value.rawText.trim()
if (normalizedText.length === 0) {
return null
}
return {
...value,
rawText: normalizedText,
householdId: binding.householdId
}
}
function needsReviewAsInt(value: boolean): number {
return value ? 1 : 0
}
@@ -245,3 +273,69 @@ export function registerPurchaseTopicIngestion(
}
})
}
export function registerConfiguredPurchaseTopicIngestion(
bot: Bot,
householdConfigurationRepository: HouseholdConfigurationRepository,
repository: PurchaseMessageIngestionRepository,
options: {
llmFallback?: PurchaseParserLlmFallback
logger?: Logger
} = {}
): void {
bot.on('message:text', async (ctx, next) => {
const candidate = toCandidateFromContext(ctx)
if (!candidate) {
await next()
return
}
const binding = await householdConfigurationRepository.findHouseholdTopicByTelegramContext({
telegramChatId: candidate.chatId,
telegramThreadId: candidate.threadId
})
if (!binding) {
await next()
return
}
const record = resolveConfiguredPurchaseTopicRecord(candidate, binding)
if (!record) {
await next()
return
}
try {
const status = await repository.save(record, options.llmFallback)
if (status === 'created') {
options.logger?.info(
{
event: 'purchase.ingested',
householdId: record.householdId,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
senderTelegramUserId: record.senderTelegramUserId
},
'Purchase topic message ingested'
)
}
} catch (error) {
options.logger?.error(
{
event: 'purchase.ingest_failed',
householdId: record.householdId,
chatId: record.chatId,
threadId: record.threadId,
messageId: record.messageId,
updateId: record.updateId,
error
},
'Failed to ingest purchase topic message'
)
}
})
}