feat: add typed moderation broadcaster

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-14 18:40:34 +07:00
parent 49d4bbf781
commit 01dc9b1836
3 changed files with 133 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
import type { WebSocket } from "ws";
import type {
AnalysisQueueStatus,
AttachmentRecord,
MessageRecord,
ModerationWsEvent,
} from "./types";
type ClientLike = Pick<WebSocket, "readyState" | "send">;
function sendJson(clients: Set<ClientLike>, 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<ClientLike>();
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<MessageRecord> & { 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<typeof createBroadcaster>;

View File

@@ -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<T> {
data: T[];
nextCursor: string | null;
}
export interface AnalysisResult {
messageId: string;
status: Exclude<AIStatus, "pending" | "error">;
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<MessageRecord> & { 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;
}

View File

@@ -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();
});
});