mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:44:02 +00:00
feat(bot): add anonymous feedback flow
This commit is contained in:
177
apps/bot/src/anonymous-feedback.test.ts
Normal file
177
apps/bot/src/anonymous-feedback.test.ts
Normal 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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
101
apps/bot/src/anonymous-feedback.ts
Normal file
101
apps/bot/src/anonymous-feedback.ts
Normal 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.')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user