feat(bot): add anonymous feedback flow

This commit is contained in:
2026-03-08 22:50:55 +04:00
parent c6a9ade586
commit 7ffd81bda9
21 changed files with 2750 additions and 3 deletions

View File

@@ -0,0 +1,177 @@
import { describe, expect, mock, test } from 'bun:test'
import type { AnonymousFeedbackService } from '@household/application'
import { createTelegramBot } from './bot'
import { registerAnonymousFeedback } from './anonymous-feedback'
function anonUpdate(params: {
updateId: number
chatType: 'private' | 'supergroup'
text: string
}) {
const commandToken = params.text.split(' ')[0] ?? params.text
return {
update_id: params.updateId,
message: {
message_id: params.updateId,
date: Math.floor(Date.now() / 1000),
chat: {
id: params.chatType === 'private' ? 123456 : -100123456,
type: params.chatType
},
from: {
id: 123456,
is_bot: false,
first_name: 'Stan'
},
text: params.text,
entities: [
{
offset: 0,
length: commandToken.length,
type: 'bot_command'
}
]
}
}
}
describe('registerAnonymousFeedback', () => {
test('posts accepted feedback into the configured topic', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
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: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: 1,
type: 'private'
},
text: 'ok'
}
} as never
})
const anonymousFeedbackService: AnonymousFeedbackService = {
submit: mock(async () => ({
status: 'accepted' as const,
submissionId: 'submission-1',
sanitizedText: 'Please clean the kitchen tonight.'
})),
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
}
registerAnonymousFeedback({
bot,
anonymousFeedbackService,
householdChatId: '-100222333',
feedbackTopicId: 77
})
await bot.handleUpdate(
anonUpdate({
updateId: 1001,
chatType: 'private',
text: '/anon Please clean the kitchen tonight.'
}) as never
)
expect(calls).toHaveLength(2)
expect(calls[0]?.method).toBe('sendMessage')
expect(calls[0]?.payload).toMatchObject({
chat_id: '-100222333',
message_thread_id: 77,
text: 'Anonymous household note\n\nPlease clean the kitchen tonight.'
})
expect(calls[1]?.payload).toMatchObject({
text: 'Anonymous feedback delivered.'
})
})
test('rejects group usage and keeps feedback private', async () => {
const bot = createTelegramBot('000000:test-token')
const calls: Array<{ method: string; payload: unknown }> = []
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: {
message_id: calls.length,
date: Math.floor(Date.now() / 1000),
chat: {
id: -100123456,
type: 'supergroup'
},
text: 'ok'
}
} as never
})
registerAnonymousFeedback({
bot,
anonymousFeedbackService: {
submit: mock(async () => ({
status: 'accepted' as const,
submissionId: 'submission-1',
sanitizedText: 'unused'
})),
markPosted: mock(async () => {}),
markFailed: mock(async () => {})
},
householdChatId: '-100222333',
feedbackTopicId: 77
})
await bot.handleUpdate(
anonUpdate({
updateId: 1002,
chatType: 'supergroup',
text: '/anon Please clean the kitchen tonight.'
}) as never
)
expect(calls).toHaveLength(1)
expect(calls[0]?.payload).toMatchObject({
text: 'Use /anon in a private chat with the bot.'
})
})
})

View File

@@ -0,0 +1,101 @@
import type { AnonymousFeedbackService } from '@household/application'
import type { Bot, Context } from 'grammy'
function isPrivateChat(ctx: Context): boolean {
return ctx.chat?.type === 'private'
}
function feedbackText(sanitizedText: string): string {
return ['Anonymous household note', '', sanitizedText].join('\n')
}
function rejectionMessage(reason: string): string {
switch (reason) {
case 'not_member':
return 'You are not a member of this household.'
case 'too_short':
return 'Anonymous feedback is too short. Add a little more detail.'
case 'too_long':
return 'Anonymous feedback is too long. Keep it under 500 characters.'
case 'cooldown':
return 'Anonymous feedback cooldown is active. Try again later.'
case 'daily_cap':
return 'Daily anonymous feedback limit reached. Try again tomorrow.'
case 'blocklisted':
return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.'
default:
return 'Anonymous feedback could not be submitted.'
}
}
export function registerAnonymousFeedback(options: {
bot: Bot
anonymousFeedbackService: AnonymousFeedbackService
householdChatId: string
feedbackTopicId: number
}): void {
options.bot.command('anon', async (ctx) => {
if (!isPrivateChat(ctx)) {
await ctx.reply('Use /anon in a private chat with the bot.')
return
}
const rawText = typeof ctx.match === 'string' ? ctx.match.trim() : ''
if (rawText.length === 0) {
await ctx.reply('Usage: /anon <message>')
return
}
const telegramUserId = ctx.from?.id?.toString()
const telegramChatId = ctx.chat?.id?.toString()
const telegramMessageId = ctx.msg?.message_id?.toString()
const telegramUpdateId =
'update_id' in ctx.update ? ctx.update.update_id?.toString() : undefined
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
await ctx.reply('Unable to identify this message for anonymous feedback.')
return
}
const result = await options.anonymousFeedbackService.submit({
telegramUserId,
rawText,
telegramChatId,
telegramMessageId,
telegramUpdateId
})
if (result.status === 'duplicate') {
await ctx.reply('This anonymous feedback message was already processed.')
return
}
if (result.status === 'rejected') {
await ctx.reply(rejectionMessage(result.reason))
return
}
try {
const posted = await ctx.api.sendMessage(
options.householdChatId,
feedbackText(result.sanitizedText),
{
message_thread_id: options.feedbackTopicId
}
)
await options.anonymousFeedbackService.markPosted({
submissionId: result.submissionId,
postedChatId: options.householdChatId,
postedThreadId: options.feedbackTopicId.toString(),
postedMessageId: posted.message_id.toString()
})
await ctx.reply('Anonymous feedback delivered.')
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
await options.anonymousFeedbackService.markFailed(result.submissionId, message)
await ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
}
})
}

View File

@@ -9,7 +9,8 @@ export function createTelegramBot(token: string): Bot {
'Household bot scaffold is live.',
'Available commands:',
'/help - Show command list',
'/household_status - Show placeholder household status'
'/household_status - Show placeholder household status',
'/anon <message> - Send anonymous household feedback in a private chat'
].join('\n')
)
})

View File

@@ -7,8 +7,10 @@ export interface BotRuntimeConfig {
householdId?: string
telegramHouseholdChatId?: string
telegramPurchaseTopicId?: number
telegramFeedbackTopicId?: number
purchaseTopicIngestionEnabled: boolean
financeCommandsEnabled: boolean
anonymousFeedbackEnabled: boolean
miniAppAllowedOrigins: readonly string[]
miniAppAuthEnabled: boolean
schedulerSharedSecret?: string
@@ -46,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined {
const parsed = Number(raw)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`Invalid TELEGRAM_PURCHASE_TOPIC_ID value: ${raw}`)
throw new Error(`Invalid Telegram topic id value: ${raw}`)
}
return parsed
@@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
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)
const schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
@@ -86,6 +89,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramPurchaseTopicId !== undefined
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
const anonymousFeedbackEnabled =
databaseUrl !== undefined &&
householdId !== undefined &&
telegramHouseholdChatId !== undefined &&
telegramFeedbackTopicId !== undefined
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
const reminderJobsEnabled =
@@ -100,6 +108,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
purchaseTopicIngestionEnabled,
financeCommandsEnabled,
anonymousFeedbackEnabled,
miniAppAllowedOrigins,
miniAppAuthEnabled,
schedulerOidcAllowedEmails,
@@ -119,6 +128,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
if (telegramPurchaseTopicId !== undefined) {
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
}
if (telegramFeedbackTopicId !== undefined) {
runtime.telegramFeedbackTopicId = telegramFeedbackTopicId
}
if (schedulerSharedSecret !== undefined) {
runtime.schedulerSharedSecret = schedulerSharedSecret
}

View File

@@ -1,11 +1,17 @@
import { webhookCallback } from 'grammy'
import { createFinanceCommandService, createReminderJobService } from '@household/application'
import {
createAnonymousFeedbackService,
createFinanceCommandService,
createReminderJobService
} from '@household/application'
import {
createDbAnonymousFeedbackRepository,
createDbFinanceRepository,
createDbReminderDispatchRepository
} from '@household/adapters-db'
import { registerAnonymousFeedback } from './anonymous-feedback'
import { createFinanceCommandsService } from './finance-commands'
import { createTelegramBot } from './bot'
import { getBotRuntimeConfig } from './config'
@@ -32,11 +38,21 @@ const financeRepositoryClient =
const financeService = financeRepositoryClient
? createFinanceCommandService(financeRepositoryClient.repository)
: null
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
: null
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
: null
if (financeRepositoryClient) {
shutdownTasks.push(financeRepositoryClient.close)
}
if (anonymousFeedbackRepositoryClient) {
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
}
if (runtime.purchaseTopicIngestionEnabled) {
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
shutdownTasks.push(purchaseRepositoryClient.close)
@@ -90,6 +106,19 @@ if (!runtime.reminderJobsEnabled) {
)
}
if (anonymousFeedbackService) {
registerAnonymousFeedback({
bot,
anonymousFeedbackService,
householdChatId: runtime.telegramHouseholdChatId!,
feedbackTopicId: runtime.telegramFeedbackTopicId!
})
} else {
console.warn(
'Anonymous feedback is disabled. Set DATABASE_URL, HOUSEHOLD_ID, TELEGRAM_HOUSEHOLD_CHAT_ID, and TELEGRAM_FEEDBACK_TOPIC_ID to enable.'
)
}
const server = createBotWebhookServer({
webhookPath: runtime.telegramWebhookPath,
webhookSecret: runtime.telegramWebhookSecret,