feat: add typed moderation broadcaster
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
58
src/moderation/broadcaster.ts
Normal file
58
src/moderation/broadcaster.ts
Normal 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>;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
|
||||||
|
|
||||||
export interface MessageRecord {
|
export interface MessageRecord {
|
||||||
id: string;
|
id: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
@@ -13,7 +15,7 @@ export interface MessageRecord {
|
|||||||
deleted_at: number | null;
|
deleted_at: number | null;
|
||||||
type: "text" | "edited" | "deleted";
|
type: "text" | "edited" | "deleted";
|
||||||
metadata: string | null;
|
metadata: string | null;
|
||||||
ai_status?: "pending" | "clean" | "warn" | "flagged" | "error" | null;
|
ai_status?: AIStatus | null;
|
||||||
ai_moderation_flags?: string | null;
|
ai_moderation_flags?: string | null;
|
||||||
ai_moderation_score?: number | null;
|
ai_moderation_score?: number | null;
|
||||||
ai_moderation_raw?: string | null;
|
ai_moderation_raw?: string | null;
|
||||||
@@ -61,3 +63,43 @@ export interface DashboardMessage {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
type: "text" | "image" | "voice";
|
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;
|
||||||
|
}
|
||||||
|
|||||||
32
tests/moderation/broadcaster.test.ts
Normal file
32
tests/moderation/broadcaster.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user