mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:34:03 +00:00
feat(bot): add anonymous feedback flow
This commit is contained in:
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 {
|
||||
createAnonymousFeedbackService,
|
||||
type AnonymousFeedbackService,
|
||||
type AnonymousFeedbackSubmitResult
|
||||
} from './anonymous-feedback-service'
|
||||
export { createFinanceCommandService, type FinanceCommandService } from './finance-command-service'
|
||||
export {
|
||||
createReminderJobService,
|
||||
|
||||
Reference in New Issue
Block a user