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,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'
}
])
})
})

View 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)
}
}
}

View File

@@ -1,4 +1,9 @@
export { calculateMonthlySettlement } from './settlement-engine'
export {
createAnonymousFeedbackService,
type AnonymousFeedbackService,
type AnonymousFeedbackSubmitResult
} from './anonymous-feedback-service'
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
export {
createReminderJobService,