mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(bot): add multi-household setup flow
This commit is contained in:
@@ -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')
|
||||
)
|
||||
|
||||
184
apps/bot/src/household-setup.ts
Normal file
184
apps/bot/src/household-setup.ts
Normal 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}).`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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!)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user