mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04: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.',
|
'Household bot scaffold is live.',
|
||||||
'Available commands:',
|
'Available commands:',
|
||||||
'/help - Show command list',
|
'/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')
|
].join('\n')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ export interface BotRuntimeConfig {
|
|||||||
householdId?: string
|
householdId?: string
|
||||||
telegramHouseholdChatId?: string
|
telegramHouseholdChatId?: string
|
||||||
telegramPurchaseTopicId?: number
|
telegramPurchaseTopicId?: number
|
||||||
|
telegramFeedbackTopicId?: number
|
||||||
purchaseTopicIngestionEnabled: boolean
|
purchaseTopicIngestionEnabled: boolean
|
||||||
financeCommandsEnabled: boolean
|
financeCommandsEnabled: boolean
|
||||||
|
anonymousFeedbackEnabled: boolean
|
||||||
miniAppAllowedOrigins: readonly string[]
|
miniAppAllowedOrigins: readonly string[]
|
||||||
miniAppAuthEnabled: boolean
|
miniAppAuthEnabled: boolean
|
||||||
schedulerSharedSecret?: string
|
schedulerSharedSecret?: string
|
||||||
@@ -46,7 +48,7 @@ function parseOptionalTopicId(raw: string | undefined): number | undefined {
|
|||||||
|
|
||||||
const parsed = Number(raw)
|
const parsed = Number(raw)
|
||||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
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
|
return parsed
|
||||||
@@ -75,6 +77,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
const householdId = parseOptionalValue(env.HOUSEHOLD_ID)
|
||||||
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
const telegramHouseholdChatId = parseOptionalValue(env.TELEGRAM_HOUSEHOLD_CHAT_ID)
|
||||||
const telegramPurchaseTopicId = parseOptionalTopicId(env.TELEGRAM_PURCHASE_TOPIC_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 schedulerSharedSecret = parseOptionalValue(env.SCHEDULER_SHARED_SECRET)
|
||||||
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
const schedulerOidcAllowedEmails = parseOptionalCsv(env.SCHEDULER_OIDC_ALLOWED_EMAILS)
|
||||||
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
const miniAppAllowedOrigins = parseOptionalCsv(env.MINI_APP_ALLOWED_ORIGINS)
|
||||||
@@ -86,6 +89,11 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramPurchaseTopicId !== undefined
|
telegramPurchaseTopicId !== undefined
|
||||||
|
|
||||||
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
const financeCommandsEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
|
const anonymousFeedbackEnabled =
|
||||||
|
databaseUrl !== undefined &&
|
||||||
|
householdId !== undefined &&
|
||||||
|
telegramHouseholdChatId !== undefined &&
|
||||||
|
telegramFeedbackTopicId !== undefined
|
||||||
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
const miniAppAuthEnabled = databaseUrl !== undefined && householdId !== undefined
|
||||||
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
const hasSchedulerOidcConfig = schedulerOidcAllowedEmails.length > 0
|
||||||
const reminderJobsEnabled =
|
const reminderJobsEnabled =
|
||||||
@@ -100,6 +108,7 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
telegramWebhookPath: env.TELEGRAM_WEBHOOK_PATH ?? '/webhook/telegram',
|
||||||
purchaseTopicIngestionEnabled,
|
purchaseTopicIngestionEnabled,
|
||||||
financeCommandsEnabled,
|
financeCommandsEnabled,
|
||||||
|
anonymousFeedbackEnabled,
|
||||||
miniAppAllowedOrigins,
|
miniAppAllowedOrigins,
|
||||||
miniAppAuthEnabled,
|
miniAppAuthEnabled,
|
||||||
schedulerOidcAllowedEmails,
|
schedulerOidcAllowedEmails,
|
||||||
@@ -119,6 +128,9 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
|
|||||||
if (telegramPurchaseTopicId !== undefined) {
|
if (telegramPurchaseTopicId !== undefined) {
|
||||||
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
runtime.telegramPurchaseTopicId = telegramPurchaseTopicId
|
||||||
}
|
}
|
||||||
|
if (telegramFeedbackTopicId !== undefined) {
|
||||||
|
runtime.telegramFeedbackTopicId = telegramFeedbackTopicId
|
||||||
|
}
|
||||||
if (schedulerSharedSecret !== undefined) {
|
if (schedulerSharedSecret !== undefined) {
|
||||||
runtime.schedulerSharedSecret = schedulerSharedSecret
|
runtime.schedulerSharedSecret = schedulerSharedSecret
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { webhookCallback } from 'grammy'
|
import { webhookCallback } from 'grammy'
|
||||||
|
|
||||||
import { createFinanceCommandService, createReminderJobService } from '@household/application'
|
|
||||||
import {
|
import {
|
||||||
|
createAnonymousFeedbackService,
|
||||||
|
createFinanceCommandService,
|
||||||
|
createReminderJobService
|
||||||
|
} from '@household/application'
|
||||||
|
import {
|
||||||
|
createDbAnonymousFeedbackRepository,
|
||||||
createDbFinanceRepository,
|
createDbFinanceRepository,
|
||||||
createDbReminderDispatchRepository
|
createDbReminderDispatchRepository
|
||||||
} from '@household/adapters-db'
|
} from '@household/adapters-db'
|
||||||
|
|
||||||
|
import { registerAnonymousFeedback } from './anonymous-feedback'
|
||||||
import { createFinanceCommandsService } from './finance-commands'
|
import { createFinanceCommandsService } from './finance-commands'
|
||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { getBotRuntimeConfig } from './config'
|
import { getBotRuntimeConfig } from './config'
|
||||||
@@ -32,11 +38,21 @@ const financeRepositoryClient =
|
|||||||
const financeService = financeRepositoryClient
|
const financeService = financeRepositoryClient
|
||||||
? createFinanceCommandService(financeRepositoryClient.repository)
|
? createFinanceCommandService(financeRepositoryClient.repository)
|
||||||
: null
|
: null
|
||||||
|
const anonymousFeedbackRepositoryClient = runtime.anonymousFeedbackEnabled
|
||||||
|
? createDbAnonymousFeedbackRepository(runtime.databaseUrl!, runtime.householdId!)
|
||||||
|
: null
|
||||||
|
const anonymousFeedbackService = anonymousFeedbackRepositoryClient
|
||||||
|
? createAnonymousFeedbackService(anonymousFeedbackRepositoryClient.repository)
|
||||||
|
: null
|
||||||
|
|
||||||
if (financeRepositoryClient) {
|
if (financeRepositoryClient) {
|
||||||
shutdownTasks.push(financeRepositoryClient.close)
|
shutdownTasks.push(financeRepositoryClient.close)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (anonymousFeedbackRepositoryClient) {
|
||||||
|
shutdownTasks.push(anonymousFeedbackRepositoryClient.close)
|
||||||
|
}
|
||||||
|
|
||||||
if (runtime.purchaseTopicIngestionEnabled) {
|
if (runtime.purchaseTopicIngestionEnabled) {
|
||||||
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
const purchaseRepositoryClient = createPurchaseMessageRepository(runtime.databaseUrl!)
|
||||||
shutdownTasks.push(purchaseRepositoryClient.close)
|
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({
|
const server = createBotWebhookServer({
|
||||||
webhookPath: runtime.telegramWebhookPath,
|
webhookPath: runtime.telegramWebhookPath,
|
||||||
webhookSecret: runtime.telegramWebhookSecret,
|
webhookSecret: runtime.telegramWebhookSecret,
|
||||||
|
|||||||
80
docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md
Normal file
80
docs/specs/HOUSEBOT-050-anonymous-feedback-dm.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# HOUSEBOT-050: Anonymous Feedback DM Flow
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Allow household members to send private `/anon` messages to the bot and have them reposted into a configured household topic without exposing the sender.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Keep sender identity hidden from the group.
|
||||||
|
- Enforce simple anti-abuse policy with cooldown, daily cap, and blocklist checks.
|
||||||
|
- Persist moderation and delivery metadata for audit without any reveal path.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Identity reveal tooling.
|
||||||
|
- LLM rewriting or sentiment analysis.
|
||||||
|
- Admin moderation UI.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- In: DM command handling, persistence, reposting to topic, deterministic sanitization, policy enforcement.
|
||||||
|
- Out: anonymous reactions, editing or deleting previous posts.
|
||||||
|
|
||||||
|
## Interfaces and Contracts
|
||||||
|
|
||||||
|
- Telegram command: `/anon <message>` in private chat only
|
||||||
|
- Runtime config:
|
||||||
|
- `TELEGRAM_HOUSEHOLD_CHAT_ID`
|
||||||
|
- `TELEGRAM_FEEDBACK_TOPIC_ID`
|
||||||
|
- Persistence:
|
||||||
|
- `anonymous_messages`
|
||||||
|
|
||||||
|
## Domain Rules
|
||||||
|
|
||||||
|
- Sender identity is never included in the reposted group message.
|
||||||
|
- Cooldown is six hours between accepted submissions.
|
||||||
|
- Daily cap is three accepted submissions per member in a rolling 24-hour window.
|
||||||
|
- Blocklisted abusive phrases are rejected and recorded.
|
||||||
|
- Links, `@mentions`, and phone-like strings are sanitized before repost.
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
- `anonymous_messages`
|
||||||
|
- household/member linkage
|
||||||
|
- raw text
|
||||||
|
- sanitized text
|
||||||
|
- moderation status and reason
|
||||||
|
- source Telegram message IDs
|
||||||
|
- posted Telegram message IDs
|
||||||
|
- failure reason and timestamps
|
||||||
|
|
||||||
|
## Security and Privacy
|
||||||
|
|
||||||
|
- Household membership is verified before accepting feedback.
|
||||||
|
- Group-facing text contains no sender identity or source metadata.
|
||||||
|
- Duplicate Telegram updates are deduplicated at persistence level.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- Failed reposts are persisted with failure reasons.
|
||||||
|
- Moderation outcomes remain queryable in the database.
|
||||||
|
|
||||||
|
## Edge Cases and Failure Modes
|
||||||
|
|
||||||
|
- Command used outside DM is rejected.
|
||||||
|
- Duplicate webhook delivery does not repost.
|
||||||
|
- Telegram post failure marks the submission as failed without exposing the sender.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- Unit: moderation, cooldown, and delivery state transitions.
|
||||||
|
- Bot tests: DM command path and private-chat enforcement.
|
||||||
|
- Integration: repo quality gates and migration generation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] DM to household topic repost works end-to-end.
|
||||||
|
- [ ] Sender identity is hidden from the reposted message.
|
||||||
|
- [ ] Cooldown, daily cap, and blocklist are enforced.
|
||||||
|
- [ ] Moderation and delivery metadata are persisted.
|
||||||
@@ -72,6 +72,7 @@ Recommended approach:
|
|||||||
- `bot_household_id`
|
- `bot_household_id`
|
||||||
- `bot_household_chat_id`
|
- `bot_household_chat_id`
|
||||||
- `bot_purchase_topic_id`
|
- `bot_purchase_topic_id`
|
||||||
|
- optional `bot_feedback_topic_id`
|
||||||
- optional `bot_parser_model`
|
- optional `bot_parser_model`
|
||||||
- optional `bot_mini_app_allowed_origins`
|
- optional `bot_mini_app_allowed_origins`
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ module "bot_api_service" {
|
|||||||
var.bot_purchase_topic_id == null ? {} : {
|
var.bot_purchase_topic_id == null ? {} : {
|
||||||
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id)
|
TELEGRAM_PURCHASE_TOPIC_ID = tostring(var.bot_purchase_topic_id)
|
||||||
},
|
},
|
||||||
|
var.bot_feedback_topic_id == null ? {} : {
|
||||||
|
TELEGRAM_FEEDBACK_TOPIC_ID = tostring(var.bot_feedback_topic_id)
|
||||||
|
},
|
||||||
var.bot_parser_model == null ? {} : {
|
var.bot_parser_model == null ? {} : {
|
||||||
PARSER_MODEL = var.bot_parser_model
|
PARSER_MODEL = var.bot_parser_model
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mini_app_image = "europe-west1-docker.pkg.dev/my-gcp-project/household-bot/mini
|
|||||||
bot_household_id = "11111111-1111-4111-8111-111111111111"
|
bot_household_id = "11111111-1111-4111-8111-111111111111"
|
||||||
bot_household_chat_id = "-1001234567890"
|
bot_household_chat_id = "-1001234567890"
|
||||||
bot_purchase_topic_id = 777
|
bot_purchase_topic_id = 777
|
||||||
|
bot_feedback_topic_id = 778
|
||||||
bot_parser_model = "gpt-4.1-mini"
|
bot_parser_model = "gpt-4.1-mini"
|
||||||
bot_mini_app_allowed_origins = [
|
bot_mini_app_allowed_origins = [
|
||||||
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
"https://household-dev-mini-app-abc123-ew.a.run.app"
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ variable "bot_purchase_topic_id" {
|
|||||||
nullable = true
|
nullable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "bot_feedback_topic_id" {
|
||||||
|
description = "Optional TELEGRAM_FEEDBACK_TOPIC_ID value for bot runtime"
|
||||||
|
type = number
|
||||||
|
default = null
|
||||||
|
nullable = true
|
||||||
|
}
|
||||||
|
|
||||||
variable "bot_parser_model" {
|
variable "bot_parser_model" {
|
||||||
description = "Optional PARSER_MODEL override for bot runtime"
|
description = "Optional PARSER_MODEL override for bot runtime"
|
||||||
type = string
|
type = string
|
||||||
|
|||||||
171
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal file
171
packages/adapters-db/src/anonymous-feedback-repository.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
import { createDbClient, schema } from '@household/db'
|
||||||
|
import type { AnonymousFeedbackRepository } from '@household/ports'
|
||||||
|
|
||||||
|
const ACCEPTED_STATUSES = ['accepted', 'posted', 'failed'] as const
|
||||||
|
|
||||||
|
export function createDbAnonymousFeedbackRepository(
|
||||||
|
databaseUrl: string,
|
||||||
|
householdId: string
|
||||||
|
): {
|
||||||
|
repository: AnonymousFeedbackRepository
|
||||||
|
close: () => Promise<void>
|
||||||
|
} {
|
||||||
|
const { db, queryClient } = createDbClient(databaseUrl, {
|
||||||
|
max: 5,
|
||||||
|
prepare: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const repository: AnonymousFeedbackRepository = {
|
||||||
|
async getMemberByTelegramUserId(telegramUserId) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: schema.members.id,
|
||||||
|
telegramUserId: schema.members.telegramUserId,
|
||||||
|
displayName: schema.members.displayName
|
||||||
|
})
|
||||||
|
.from(schema.members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.members.householdId, householdId),
|
||||||
|
eq(schema.members.telegramUserId, telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return rows[0] ?? null
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRateLimitSnapshot(memberId, acceptedSince) {
|
||||||
|
const countRows = await db
|
||||||
|
.select({
|
||||||
|
count: sql<string>`count(*)`
|
||||||
|
})
|
||||||
|
.from(schema.anonymousMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.anonymousMessages.householdId, householdId),
|
||||||
|
eq(schema.anonymousMessages.submittedByMemberId, memberId),
|
||||||
|
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES),
|
||||||
|
gte(schema.anonymousMessages.createdAt, acceptedSince)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastRows = await db
|
||||||
|
.select({
|
||||||
|
createdAt: schema.anonymousMessages.createdAt
|
||||||
|
})
|
||||||
|
.from(schema.anonymousMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.anonymousMessages.householdId, householdId),
|
||||||
|
eq(schema.anonymousMessages.submittedByMemberId, memberId),
|
||||||
|
inArray(schema.anonymousMessages.moderationStatus, ACCEPTED_STATUSES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(schema.anonymousMessages.createdAt))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
acceptedCountSince: Number(countRows[0]?.count ?? '0'),
|
||||||
|
lastAcceptedAt: lastRows[0]?.createdAt ?? null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createSubmission(input) {
|
||||||
|
const inserted = await db
|
||||||
|
.insert(schema.anonymousMessages)
|
||||||
|
.values({
|
||||||
|
householdId,
|
||||||
|
submittedByMemberId: input.submittedByMemberId,
|
||||||
|
rawText: input.rawText,
|
||||||
|
sanitizedText: input.sanitizedText,
|
||||||
|
moderationStatus: input.moderationStatus,
|
||||||
|
moderationReason: input.moderationReason,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [schema.anonymousMessages.householdId, schema.anonymousMessages.telegramUpdateId]
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: schema.anonymousMessages.id,
|
||||||
|
moderationStatus: schema.anonymousMessages.moderationStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
if (inserted[0]) {
|
||||||
|
return {
|
||||||
|
submission: {
|
||||||
|
id: inserted[0].id,
|
||||||
|
moderationStatus: inserted[0].moderationStatus as
|
||||||
|
| 'accepted'
|
||||||
|
| 'posted'
|
||||||
|
| 'rejected'
|
||||||
|
| 'failed'
|
||||||
|
},
|
||||||
|
duplicate: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({
|
||||||
|
id: schema.anonymousMessages.id,
|
||||||
|
moderationStatus: schema.anonymousMessages.moderationStatus
|
||||||
|
})
|
||||||
|
.from(schema.anonymousMessages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.anonymousMessages.householdId, householdId),
|
||||||
|
eq(schema.anonymousMessages.telegramUpdateId, input.telegramUpdateId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const row = existing[0]
|
||||||
|
if (!row) {
|
||||||
|
throw new Error('Anonymous feedback insert conflict without stored row')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
submission: {
|
||||||
|
id: row.id,
|
||||||
|
moderationStatus: row.moderationStatus as 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||||
|
},
|
||||||
|
duplicate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async markPosted(input) {
|
||||||
|
await db
|
||||||
|
.update(schema.anonymousMessages)
|
||||||
|
.set({
|
||||||
|
moderationStatus: 'posted',
|
||||||
|
postedChatId: input.postedChatId,
|
||||||
|
postedThreadId: input.postedThreadId,
|
||||||
|
postedMessageId: input.postedMessageId,
|
||||||
|
postedAt: input.postedAt,
|
||||||
|
failureReason: null
|
||||||
|
})
|
||||||
|
.where(eq(schema.anonymousMessages.id, input.submissionId))
|
||||||
|
},
|
||||||
|
|
||||||
|
async markFailed(submissionId, failureReason) {
|
||||||
|
await db
|
||||||
|
.update(schema.anonymousMessages)
|
||||||
|
.set({
|
||||||
|
moderationStatus: 'failed',
|
||||||
|
failureReason
|
||||||
|
})
|
||||||
|
.where(eq(schema.anonymousMessages.id, submissionId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repository,
|
||||||
|
close: async () => {
|
||||||
|
await queryClient.end({ timeout: 5 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export { createDbAnonymousFeedbackRepository } from './anonymous-feedback-repository'
|
||||||
export { createDbFinanceRepository } from './finance-repository'
|
export { createDbFinanceRepository } from './finance-repository'
|
||||||
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
export { createDbReminderDispatchRepository } from './reminder-dispatch-repository'
|
||||||
|
|||||||
217
packages/application/src/anonymous-feedback-service.test.ts
Normal file
217
packages/application/src/anonymous-feedback-service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnonymousFeedbackMemberRecord,
|
||||||
|
AnonymousFeedbackRepository,
|
||||||
|
AnonymousFeedbackSubmissionRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createAnonymousFeedbackService } from './anonymous-feedback-service'
|
||||||
|
|
||||||
|
class AnonymousFeedbackRepositoryStub implements AnonymousFeedbackRepository {
|
||||||
|
member: AnonymousFeedbackMemberRecord | null = {
|
||||||
|
id: 'member-1',
|
||||||
|
telegramUserId: '123',
|
||||||
|
displayName: 'Stan'
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptedCountSince = 0
|
||||||
|
lastAcceptedAt: Date | null = null
|
||||||
|
duplicate = false
|
||||||
|
created: Array<{
|
||||||
|
rawText: string
|
||||||
|
sanitizedText: string | null
|
||||||
|
moderationStatus: string
|
||||||
|
moderationReason: string | null
|
||||||
|
}> = []
|
||||||
|
posted: Array<{ submissionId: string; postedThreadId: string; postedMessageId: string }> = []
|
||||||
|
failed: Array<{ submissionId: string; failureReason: string }> = []
|
||||||
|
|
||||||
|
async getMemberByTelegramUserId() {
|
||||||
|
return this.member
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRateLimitSnapshot() {
|
||||||
|
return {
|
||||||
|
acceptedCountSince: this.acceptedCountSince,
|
||||||
|
lastAcceptedAt: this.lastAcceptedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSubmission(input: {
|
||||||
|
submittedByMemberId: string
|
||||||
|
rawText: string
|
||||||
|
sanitizedText: string | null
|
||||||
|
moderationStatus: 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||||
|
moderationReason: string | null
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
}): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }> {
|
||||||
|
this.created.push({
|
||||||
|
rawText: input.rawText,
|
||||||
|
sanitizedText: input.sanitizedText,
|
||||||
|
moderationStatus: input.moderationStatus,
|
||||||
|
moderationReason: input.moderationReason
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
submission: {
|
||||||
|
id: 'submission-1',
|
||||||
|
moderationStatus: input.moderationStatus
|
||||||
|
},
|
||||||
|
duplicate: this.duplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markPosted(input: {
|
||||||
|
submissionId: string
|
||||||
|
postedChatId: string
|
||||||
|
postedThreadId: string
|
||||||
|
postedMessageId: string
|
||||||
|
postedAt: Date
|
||||||
|
}) {
|
||||||
|
this.posted.push({
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
postedThreadId: input.postedThreadId,
|
||||||
|
postedMessageId: input.postedMessageId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async markFailed(submissionId: string, failureReason: string) {
|
||||||
|
this.failed.push({ submissionId, failureReason })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createAnonymousFeedbackService', () => {
|
||||||
|
test('accepts and sanitizes a valid submission', async () => {
|
||||||
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
telegramUserId: '123',
|
||||||
|
rawText: 'Please clean the kitchen tonight @roommate https://example.com',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramMessageId: 'message-1',
|
||||||
|
telegramUpdateId: 'update-1',
|
||||||
|
now: new Date('2026-03-08T12:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'accepted',
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
sanitizedText: 'Please clean the kitchen tonight [mention removed] [link removed]'
|
||||||
|
})
|
||||||
|
expect(repository.created[0]).toMatchObject({
|
||||||
|
moderationStatus: 'accepted'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects non-members before persistence', async () => {
|
||||||
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
|
repository.member = null
|
||||||
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
telegramUserId: '404',
|
||||||
|
rawText: 'Please wash the dishes tonight',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramMessageId: 'message-1',
|
||||||
|
telegramUpdateId: 'update-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_member'
|
||||||
|
})
|
||||||
|
expect(repository.created).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects blocklisted content and persists moderation outcome', async () => {
|
||||||
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|
||||||
|
const result = await service.submit({
|
||||||
|
telegramUserId: '123',
|
||||||
|
rawText: 'You are an idiot and this is disgusting',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramMessageId: 'message-1',
|
||||||
|
telegramUpdateId: 'update-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'blocklisted',
|
||||||
|
detail: 'idiot'
|
||||||
|
})
|
||||||
|
expect(repository.created[0]).toMatchObject({
|
||||||
|
moderationStatus: 'rejected',
|
||||||
|
moderationReason: 'blocklisted:idiot'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces cooldown and daily cap', async () => {
|
||||||
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|
||||||
|
repository.lastAcceptedAt = new Date('2026-03-08T09:00:00.000Z')
|
||||||
|
|
||||||
|
const cooldownResult = await service.submit({
|
||||||
|
telegramUserId: '123',
|
||||||
|
rawText: 'Please take the trash out tonight',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramMessageId: 'message-1',
|
||||||
|
telegramUpdateId: 'update-1',
|
||||||
|
now: new Date('2026-03-08T12:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(cooldownResult).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'cooldown'
|
||||||
|
})
|
||||||
|
|
||||||
|
repository.lastAcceptedAt = new Date('2026-03-07T00:00:00.000Z')
|
||||||
|
repository.acceptedCountSince = 3
|
||||||
|
|
||||||
|
const dailyCapResult = await service.submit({
|
||||||
|
telegramUserId: '123',
|
||||||
|
rawText: 'Please ventilate the bathroom after showers',
|
||||||
|
telegramChatId: 'chat-1',
|
||||||
|
telegramMessageId: 'message-2',
|
||||||
|
telegramUpdateId: 'update-2',
|
||||||
|
now: new Date('2026-03-08T12:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(dailyCapResult).toEqual({
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'daily_cap'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('marks posted and failed submissions', async () => {
|
||||||
|
const repository = new AnonymousFeedbackRepositoryStub()
|
||||||
|
const service = createAnonymousFeedbackService(repository)
|
||||||
|
|
||||||
|
await service.markPosted({
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
postedChatId: 'group-1',
|
||||||
|
postedThreadId: 'thread-1',
|
||||||
|
postedMessageId: 'post-1'
|
||||||
|
})
|
||||||
|
await service.markFailed('submission-2', 'telegram send failed')
|
||||||
|
|
||||||
|
expect(repository.posted).toEqual([
|
||||||
|
{
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
postedThreadId: 'thread-1',
|
||||||
|
postedMessageId: 'post-1'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
expect(repository.failed).toEqual([
|
||||||
|
{
|
||||||
|
submissionId: 'submission-2',
|
||||||
|
failureReason: 'telegram send failed'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
223
packages/application/src/anonymous-feedback-service.ts
Normal file
223
packages/application/src/anonymous-feedback-service.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type {
|
||||||
|
AnonymousFeedbackRejectionReason,
|
||||||
|
AnonymousFeedbackRepository
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
const MIN_MESSAGE_LENGTH = 12
|
||||||
|
const MAX_MESSAGE_LENGTH = 500
|
||||||
|
const COOLDOWN_HOURS = 6
|
||||||
|
const DAILY_CAP = 3
|
||||||
|
const BLOCKLIST = ['kill yourself', 'сука', 'тварь', 'идиот', 'idiot', 'hate you'] as const
|
||||||
|
|
||||||
|
function collapseWhitespace(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAnonymousText(rawText: string): string {
|
||||||
|
return collapseWhitespace(rawText)
|
||||||
|
.replace(/https?:\/\/\S+/gi, '[link removed]')
|
||||||
|
.replace(/@\w+/g, '[mention removed]')
|
||||||
|
.replace(/\+?\d[\d\s\-()]{8,}\d/g, '[contact removed]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBlocklistedPhrase(value: string): string | null {
|
||||||
|
const normalized = value.toLowerCase()
|
||||||
|
|
||||||
|
for (const phrase of BLOCKLIST) {
|
||||||
|
if (normalized.includes(phrase)) {
|
||||||
|
return phrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnonymousFeedbackSubmitResult =
|
||||||
|
| {
|
||||||
|
status: 'accepted'
|
||||||
|
submissionId: string
|
||||||
|
sanitizedText: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'duplicate'
|
||||||
|
submissionId: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'rejected'
|
||||||
|
reason: AnonymousFeedbackRejectionReason
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnonymousFeedbackService {
|
||||||
|
submit(input: {
|
||||||
|
telegramUserId: string
|
||||||
|
rawText: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
now?: Date
|
||||||
|
}): Promise<AnonymousFeedbackSubmitResult>
|
||||||
|
markPosted(input: {
|
||||||
|
submissionId: string
|
||||||
|
postedChatId: string
|
||||||
|
postedThreadId: string
|
||||||
|
postedMessageId: string
|
||||||
|
postedAt?: Date
|
||||||
|
}): Promise<void>
|
||||||
|
markFailed(submissionId: string, failureReason: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectSubmission(
|
||||||
|
repository: AnonymousFeedbackRepository,
|
||||||
|
input: {
|
||||||
|
memberId: string
|
||||||
|
rawText: string
|
||||||
|
reason: AnonymousFeedbackRejectionReason
|
||||||
|
detail?: string
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
}
|
||||||
|
): Promise<AnonymousFeedbackSubmitResult> {
|
||||||
|
const created = await repository.createSubmission({
|
||||||
|
submittedByMemberId: input.memberId,
|
||||||
|
rawText: input.rawText,
|
||||||
|
sanitizedText: null,
|
||||||
|
moderationStatus: 'rejected',
|
||||||
|
moderationReason: input.detail ? `${input.reason}:${input.detail}` : input.reason,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.duplicate) {
|
||||||
|
return {
|
||||||
|
status: 'duplicate',
|
||||||
|
submissionId: created.submission.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: input.reason,
|
||||||
|
...(input.detail ? { detail: input.detail } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnonymousFeedbackService(
|
||||||
|
repository: AnonymousFeedbackRepository
|
||||||
|
): AnonymousFeedbackService {
|
||||||
|
return {
|
||||||
|
async submit(input) {
|
||||||
|
const member = await repository.getMemberByTelegramUserId(input.telegramUserId)
|
||||||
|
if (!member) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_member'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedText = sanitizeAnonymousText(input.rawText)
|
||||||
|
if (sanitizedText.length < MIN_MESSAGE_LENGTH) {
|
||||||
|
return rejectSubmission(repository, {
|
||||||
|
memberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
reason: 'too_short',
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanitizedText.length > MAX_MESSAGE_LENGTH) {
|
||||||
|
return rejectSubmission(repository, {
|
||||||
|
memberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
reason: 'too_long',
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedPhrase = findBlocklistedPhrase(sanitizedText)
|
||||||
|
if (blockedPhrase) {
|
||||||
|
return rejectSubmission(repository, {
|
||||||
|
memberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
reason: 'blocklisted',
|
||||||
|
detail: blockedPhrase,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = input.now ?? new Date()
|
||||||
|
const acceptedSince = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
const rateLimit = await repository.getRateLimitSnapshot(member.id, acceptedSince)
|
||||||
|
if (rateLimit.acceptedCountSince >= DAILY_CAP) {
|
||||||
|
return rejectSubmission(repository, {
|
||||||
|
memberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
reason: 'daily_cap',
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimit.lastAcceptedAt) {
|
||||||
|
const cooldownBoundary = now.getTime() - COOLDOWN_HOURS * 60 * 60 * 1000
|
||||||
|
if (rateLimit.lastAcceptedAt.getTime() > cooldownBoundary) {
|
||||||
|
return rejectSubmission(repository, {
|
||||||
|
memberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
reason: 'cooldown',
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await repository.createSubmission({
|
||||||
|
submittedByMemberId: member.id,
|
||||||
|
rawText: input.rawText,
|
||||||
|
sanitizedText,
|
||||||
|
moderationStatus: 'accepted',
|
||||||
|
moderationReason: null,
|
||||||
|
telegramChatId: input.telegramChatId,
|
||||||
|
telegramMessageId: input.telegramMessageId,
|
||||||
|
telegramUpdateId: input.telegramUpdateId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (created.duplicate) {
|
||||||
|
return {
|
||||||
|
status: 'duplicate',
|
||||||
|
submissionId: created.submission.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'accepted',
|
||||||
|
submissionId: created.submission.id,
|
||||||
|
sanitizedText
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markPosted(input) {
|
||||||
|
return repository.markPosted({
|
||||||
|
submissionId: input.submissionId,
|
||||||
|
postedChatId: input.postedChatId,
|
||||||
|
postedThreadId: input.postedThreadId,
|
||||||
|
postedMessageId: input.postedMessageId,
|
||||||
|
postedAt: input.postedAt ?? new Date()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
markFailed(submissionId, failureReason) {
|
||||||
|
return repository.markFailed(submissionId, failureReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export { calculateMonthlySettlement } from './settlement-engine'
|
export { calculateMonthlySettlement } from './settlement-engine'
|
||||||
|
export {
|
||||||
|
createAnonymousFeedbackService,
|
||||||
|
type AnonymousFeedbackService,
|
||||||
|
type AnonymousFeedbackSubmitResult
|
||||||
|
} from './anonymous-feedback-service'
|
||||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||||
export {
|
export {
|
||||||
createReminderJobService,
|
createReminderJobService,
|
||||||
|
|||||||
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal file
24
packages/db/drizzle/0004_big_ultimatum.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "anonymous_messages" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"submitted_by_member_id" uuid NOT NULL,
|
||||||
|
"raw_text" text NOT NULL,
|
||||||
|
"sanitized_text" text,
|
||||||
|
"moderation_status" text NOT NULL,
|
||||||
|
"moderation_reason" text,
|
||||||
|
"telegram_chat_id" text NOT NULL,
|
||||||
|
"telegram_message_id" text NOT NULL,
|
||||||
|
"telegram_update_id" text NOT NULL,
|
||||||
|
"posted_chat_id" text,
|
||||||
|
"posted_thread_id" text,
|
||||||
|
"posted_message_id" text,
|
||||||
|
"failure_reason" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"posted_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "anonymous_messages" ADD CONSTRAINT "anonymous_messages_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "anonymous_messages" ADD CONSTRAINT "anonymous_messages_submitted_by_member_id_members_id_fk" FOREIGN KEY ("submitted_by_member_id") REFERENCES "public"."members"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "anonymous_messages_household_tg_update_unique" ON "anonymous_messages" USING btree ("household_id","telegram_update_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anonymous_messages_member_created_idx" ON "anonymous_messages" USING btree ("submitted_by_member_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "anonymous_messages_status_created_idx" ON "anonymous_messages" USING btree ("moderation_status","created_at");
|
||||||
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
1587
packages/db/drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1772671128084,
|
"when": 1772671128084,
|
||||||
"tag": "0003_mature_roulette",
|
"tag": "0003_mature_roulette",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772995779819,
|
||||||
|
"tag": "0004_big_ultimatum",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,46 @@ export const processedBotMessages = pgTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const anonymousMessages = pgTable(
|
||||||
|
'anonymous_messages',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
householdId: uuid('household_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: 'cascade' }),
|
||||||
|
submittedByMemberId: uuid('submitted_by_member_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => members.id, { onDelete: 'restrict' }),
|
||||||
|
rawText: text('raw_text').notNull(),
|
||||||
|
sanitizedText: text('sanitized_text'),
|
||||||
|
moderationStatus: text('moderation_status').notNull(),
|
||||||
|
moderationReason: text('moderation_reason'),
|
||||||
|
telegramChatId: text('telegram_chat_id').notNull(),
|
||||||
|
telegramMessageId: text('telegram_message_id').notNull(),
|
||||||
|
telegramUpdateId: text('telegram_update_id').notNull(),
|
||||||
|
postedChatId: text('posted_chat_id'),
|
||||||
|
postedThreadId: text('posted_thread_id'),
|
||||||
|
postedMessageId: text('posted_message_id'),
|
||||||
|
failureReason: text('failure_reason'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
postedAt: timestamp('posted_at', { withTimezone: true })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
householdUpdateUnique: uniqueIndex('anonymous_messages_household_tg_update_unique').on(
|
||||||
|
table.householdId,
|
||||||
|
table.telegramUpdateId
|
||||||
|
),
|
||||||
|
memberCreatedIdx: index('anonymous_messages_member_created_idx').on(
|
||||||
|
table.submittedByMemberId,
|
||||||
|
table.createdAt
|
||||||
|
),
|
||||||
|
statusCreatedIdx: index('anonymous_messages_status_created_idx').on(
|
||||||
|
table.moderationStatus,
|
||||||
|
table.createdAt
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
export const settlements = pgTable(
|
export const settlements = pgTable(
|
||||||
'settlements',
|
'settlements',
|
||||||
{
|
{
|
||||||
@@ -308,4 +348,5 @@ export type BillingCycle = typeof billingCycles.$inferSelect
|
|||||||
export type UtilityBill = typeof utilityBills.$inferSelect
|
export type UtilityBill = typeof utilityBills.$inferSelect
|
||||||
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
export type PurchaseEntry = typeof purchaseEntries.$inferSelect
|
||||||
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
export type PurchaseMessage = typeof purchaseMessages.$inferSelect
|
||||||
|
export type AnonymousMessage = typeof anonymousMessages.$inferSelect
|
||||||
export type Settlement = typeof settlements.$inferSelect
|
export type Settlement = typeof settlements.$inferSelect
|
||||||
|
|||||||
51
packages/ports/src/anonymous-feedback.ts
Normal file
51
packages/ports/src/anonymous-feedback.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type AnonymousFeedbackModerationStatus = 'accepted' | 'posted' | 'rejected' | 'failed'
|
||||||
|
|
||||||
|
export type AnonymousFeedbackRejectionReason =
|
||||||
|
| 'not_member'
|
||||||
|
| 'too_short'
|
||||||
|
| 'too_long'
|
||||||
|
| 'cooldown'
|
||||||
|
| 'daily_cap'
|
||||||
|
| 'blocklisted'
|
||||||
|
|
||||||
|
export interface AnonymousFeedbackMemberRecord {
|
||||||
|
id: string
|
||||||
|
telegramUserId: string
|
||||||
|
displayName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnonymousFeedbackRateLimitSnapshot {
|
||||||
|
acceptedCountSince: number
|
||||||
|
lastAcceptedAt: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnonymousFeedbackSubmissionRecord {
|
||||||
|
id: string
|
||||||
|
moderationStatus: AnonymousFeedbackModerationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnonymousFeedbackRepository {
|
||||||
|
getMemberByTelegramUserId(telegramUserId: string): Promise<AnonymousFeedbackMemberRecord | null>
|
||||||
|
getRateLimitSnapshot(
|
||||||
|
memberId: string,
|
||||||
|
acceptedSince: Date
|
||||||
|
): Promise<AnonymousFeedbackRateLimitSnapshot>
|
||||||
|
createSubmission(input: {
|
||||||
|
submittedByMemberId: string
|
||||||
|
rawText: string
|
||||||
|
sanitizedText: string | null
|
||||||
|
moderationStatus: AnonymousFeedbackModerationStatus
|
||||||
|
moderationReason: string | null
|
||||||
|
telegramChatId: string
|
||||||
|
telegramMessageId: string
|
||||||
|
telegramUpdateId: string
|
||||||
|
}): Promise<{ submission: AnonymousFeedbackSubmissionRecord; duplicate: boolean }>
|
||||||
|
markPosted(input: {
|
||||||
|
submissionId: string
|
||||||
|
postedChatId: string
|
||||||
|
postedThreadId: string
|
||||||
|
postedMessageId: string
|
||||||
|
postedAt: Date
|
||||||
|
}): Promise<void>
|
||||||
|
markFailed(submissionId: string, failureReason: string): Promise<void>
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ export {
|
|||||||
type ReminderDispatchRepository,
|
type ReminderDispatchRepository,
|
||||||
type ReminderType
|
type ReminderType
|
||||||
} from './reminders'
|
} from './reminders'
|
||||||
|
export type {
|
||||||
|
AnonymousFeedbackMemberRecord,
|
||||||
|
AnonymousFeedbackModerationStatus,
|
||||||
|
AnonymousFeedbackRateLimitSnapshot,
|
||||||
|
AnonymousFeedbackRejectionReason,
|
||||||
|
AnonymousFeedbackRepository,
|
||||||
|
AnonymousFeedbackSubmissionRecord
|
||||||
|
} from './anonymous-feedback'
|
||||||
export type {
|
export type {
|
||||||
FinanceCycleRecord,
|
FinanceCycleRecord,
|
||||||
FinanceMemberRecord,
|
FinanceMemberRecord,
|
||||||
|
|||||||
Reference in New Issue
Block a user