From 01dc9b1836a5e3657b5ff7efb395718a392def79 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 14 May 2026 18:40:34 +0700 Subject: [PATCH] feat: add typed moderation broadcaster Co-Authored-By: Claude Opus 4.7 --- src/moderation/broadcaster.ts | 58 ++++++++++++++++++++++++++++ src/moderation/types.ts | 44 ++++++++++++++++++++- tests/moderation/broadcaster.test.ts | 32 +++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/moderation/broadcaster.ts create mode 100644 tests/moderation/broadcaster.test.ts diff --git a/src/moderation/broadcaster.ts b/src/moderation/broadcaster.ts new file mode 100644 index 0000000..1851a8c --- /dev/null +++ b/src/moderation/broadcaster.ts @@ -0,0 +1,58 @@ +import type { WebSocket } from "ws"; +import type { + AnalysisQueueStatus, + AttachmentRecord, + MessageRecord, + ModerationWsEvent, +} from "./types"; + +type ClientLike = Pick; + +function sendJson(clients: Set, event: ModerationWsEvent): void { + const payload = JSON.stringify({ ...event, timestamp: Date.now() }); + for (const client of clients) { + if (client.readyState === 1) client.send(payload); + } +} + +export function createBroadcaster() { + const clients = new Set(); + + return { + addClient(client: ClientLike) { + clients.add(client); + }, + removeClient(client: ClientLike) { + clients.delete(client); + }, + clientCount() { + return clients.size; + }, + uiState(state: unknown) { + sendJson(clients, { type: "ui_state", state }); + }, + userState(users: unknown[]) { + sendJson(clients, { type: "user_state", users }); + }, + messageCreated(data: MessageRecord) { + sendJson(clients, { type: "message_created", data }); + }, + messageUpdated(data: Partial & { id: string }) { + sendJson(clients, { type: "message_updated", data }); + }, + messageDeleted(data: { id: string; deleted_at: number }) { + sendJson(clients, { type: "message_deleted", data }); + }, + messageAnalyzed(data: MessageRecord) { + sendJson(clients, { type: "message_analyzed", data }); + }, + attachmentCreated(data: AttachmentRecord) { + sendJson(clients, { type: "attachment_created", data }); + }, + analysisQueueStatus(data: AnalysisQueueStatus) { + sendJson(clients, { type: "analysis_queue_status", data }); + }, + }; +} + +export type ModerationBroadcaster = ReturnType; diff --git a/src/moderation/types.ts b/src/moderation/types.ts index 9f1f38a..eeb273a 100644 --- a/src/moderation/types.ts +++ b/src/moderation/types.ts @@ -1,3 +1,5 @@ +export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error"; + export interface MessageRecord { id: string; guild_id: string; @@ -13,7 +15,7 @@ export interface MessageRecord { deleted_at: number | null; type: "text" | "edited" | "deleted"; metadata: string | null; - ai_status?: "pending" | "clean" | "warn" | "flagged" | "error" | null; + ai_status?: AIStatus | null; ai_moderation_flags?: string | null; ai_moderation_score?: number | null; ai_moderation_raw?: string | null; @@ -61,3 +63,43 @@ export interface DashboardMessage { created_at: number; type: "text" | "image" | "voice"; } + +export interface MessageQuery { + guildId?: string; + channelId?: string; + threadId?: string; + status?: AIStatus[]; + userId?: string; + q?: string; + cursor?: string; + limit: number; +} + +export interface PageResult { + data: T[]; + nextCursor: string | null; +} + +export interface AnalysisResult { + messageId: string; + status: Exclude; + flags: string[]; + score: number; + analysis: string; +} + +export type ModerationWsEvent = + | { type: "ui_state"; state: unknown } + | { type: "user_state"; users: unknown[] } + | { type: "message_created"; data: MessageRecord } + | { type: "message_updated"; data: Partial & { id: string } } + | { type: "message_deleted"; data: { id: string; deleted_at: number } } + | { type: "message_analyzed"; data: MessageRecord } + | { type: "attachment_created"; data: AttachmentRecord } + | { type: "analysis_queue_status"; data: AnalysisQueueStatus }; + +export interface AnalysisQueueStatus { + queuedConversations: number; + activeRequests: number; + lastError: string | null; +} diff --git a/tests/moderation/broadcaster.test.ts b/tests/moderation/broadcaster.test.ts new file mode 100644 index 0000000..85ebcfb --- /dev/null +++ b/tests/moderation/broadcaster.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBroadcaster } from "../../src/moderation/broadcaster"; + +function client() { + return { readyState: 1, send: vi.fn() }; +} + +describe("createBroadcaster", () => { + it("sends JSON events to open clients", () => { + const ws = client(); + const broadcaster = createBroadcaster(); + + broadcaster.addClient(ws as any); + broadcaster.messageAnalyzed({ id: "m1", ai_status: "clean" } as any); + + expect(ws.send).toHaveBeenCalledTimes(1); + expect(JSON.parse(ws.send.mock.calls[0][0])).toMatchObject({ + type: "message_analyzed", + data: { id: "m1", ai_status: "clean" }, + }); + }); + + it("skips closed clients", () => { + const ws = { readyState: 3, send: vi.fn() }; + const broadcaster = createBroadcaster(); + + broadcaster.addClient(ws as any); + broadcaster.messageDeleted({ id: "m1", deleted_at: 123 }); + + expect(ws.send).not.toHaveBeenCalled(); + }); +});