Compare commits

..

8 Commits

25 changed files with 631 additions and 283 deletions

57
debug-screen.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Client } from "discord.js-selfbot-v13";
import dotenv from "dotenv";
import { createYtDlp } from "./src/media/ytdlp.js";
import { Streamer } from "./vendor/Discord-video-stream/dist/client/index.js";
import {
playStream,
prepareStream,
} from "./vendor/Discord-video-stream/dist/media/newApi.js";
dotenv.config();
async function test() {
const ytdlp = createYtDlp();
const url = "https://www.youtube.com/watch?v=aqz-KE-bpKQ"; // Small video
console.log("Getting direct video url...");
const directUrl = await ytdlp.getDirectVideoUrl(url);
console.log("Direct URL:", directUrl);
console.log("Preparing stream...");
const { command, output } = prepareStream(directUrl, {
logLevel: "debug",
customInputOptions: [
"-headers",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3\r\nConnection: keep-alive\r\n",
],
});
command.on("stderr", (data) => {
console.log("FFMPEG STDERR:", data);
});
console.log("Testing demux manually...");
const { demux } = await import(
"./vendor/Discord-video-stream/dist/media/LibavDemuxer.js"
);
try {
const demuxPromise = demux(output, { format: "nut" });
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Demux timeout")), 15000),
);
const { video, audio } = (await Promise.race([
demuxPromise,
timeoutPromise,
])) as any;
console.log("Demux success!");
console.log("Video stream:", !!video);
console.log("Audio stream:", !!audio);
} catch (err) {
console.error("Demux failed:", err.message);
}
process.exit(0);
}
test();

View File

@@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Discord Moderation Dashboard</title> <title>Discord Moderation Dashboard</title>
</head> </head>

View File

@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
import { ReviewPanel } from "./components/review/ReviewPanel"; import { ReviewPanel } from "./components/review/ReviewPanel";
import { Tabs, TabsContent } from "./components/ui/tabs"; import { Tabs, TabsContent } from "./components/ui/tabs";
import { VoicePanel } from "./components/voice/VoicePanel"; import { VoicePanel } from "./components/voice/VoicePanel";
import { AuthOverlay } from "./components/layout/AuthOverlay";
import { useDashboardSocket } from "./hooks/useDashboardSocket"; import { useDashboardSocket } from "./hooks/useDashboardSocket";
import { mergeMessages, useMessages } from "./hooks/useMessages"; import { mergeMessages, useMessages } from "./hooks/useMessages";
import { useMediaControl } from "./hooks/useMediaControl"; import { useMediaControl } from "./hooks/useMediaControl";
@@ -26,6 +27,7 @@ export default function App() {
const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04)); const [levels, setLevels] = useState<number[]>(Array.from({ length: 32 }, () => 0.04));
const [isListening, setIsListening] = useState(false); const [isListening, setIsListening] = useState(false);
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
const audioContextListenRef = useRef<AudioContext | null>(null); const audioContextListenRef = useRef<AudioContext | null>(null);
const audioContextTransmitRef = useRef<AudioContext | null>(null); const audioContextTransmitRef = useRef<AudioContext | null>(null);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
@@ -144,16 +146,22 @@ export default function App() {
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]); }, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
useEffect(() => { useEffect(() => {
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined); if (selectedVoiceGuild) {
}, [selectedVoiceGuild, voice.loadVoiceChannels]); voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
}
}, [selectedVoiceGuild]);
useEffect(() => { useEffect(() => {
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined); if (selectedTextGuild) {
}, [selectedTextGuild, voice.loadTextTargets]); voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
}
}, [selectedTextGuild]);
useEffect(() => { useEffect(() => {
if (selectedTextChannel) {
messages.fetchMessages(selectedTextChannel).catch(() => undefined); messages.fetchMessages(selectedTextChannel).catch(() => undefined);
}, [selectedTextChannel, messages.fetchMessages]); }
}, [selectedTextChannel]);
const toggleListening = useCallback(async () => { const toggleListening = useCallback(async () => {
if (isListening) { if (isListening) {
@@ -192,6 +200,9 @@ export default function App() {
</div> </div>
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}> <Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
<TabsContent value="voice"> <TabsContent value="voice">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<VoicePanel <VoicePanel
guilds={voice.guilds} guilds={voice.guilds}
channels={voice.voiceChannels} channels={voice.voiceChannels}
@@ -210,8 +221,12 @@ export default function App() {
onListenToggle={toggleListening} onListenToggle={toggleListening}
onStreamingToggle={toggleStreaming} onStreamingToggle={toggleStreaming}
/> />
)}
</TabsContent> </TabsContent>
<TabsContent value="media"> <TabsContent value="media">
{!isAuthenticated ? (
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
) : (
<MediaPanel <MediaPanel
state={media.mediaState} state={media.mediaState}
loading={media.loading} loading={media.loading}
@@ -220,6 +235,7 @@ export default function App() {
onSkip={media.skip} onSkip={media.skip}
onStop={media.stop} onStop={media.stop}
/> />
)}
</TabsContent> </TabsContent>
<TabsContent value="messages"> <TabsContent value="messages">
<MessagesPanel <MessagesPanel

8
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,8 @@
import { request } from "./client";
export async function login(password: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
});
}

View File

@@ -50,8 +50,12 @@ class ApiError extends Error {
} }
export async function request<T>(path: string, init?: RequestInit): Promise<T> { export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const password = localStorage.getItem("admin-password");
const res = await fetch(path, { const res = await fetch(path, {
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
...(password ? { "X-Admin-Password": password } : {}),
},
...init, ...init,
}); });

View File

@@ -13,10 +13,6 @@ export function getTextChannels(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/channels`); return request<Channel[]>(`/api/guilds/${guildId}/channels`);
} }
export function getThreads(guildId: string): Promise<Channel[]> {
return request<Channel[]>(`/api/guilds/${guildId}/threads`);
}
export function getVoiceStatus(): Promise<VoiceStatus> { export function getVoiceStatus(): Promise<VoiceStatus> {
return request<VoiceStatus>('/api/status'); return request<VoiceStatus>('/api/status');
} }

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { login } from "../../api/auth";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Lock } from "lucide-react";
interface AuthOverlayProps {
onAuthenticated: () => void;
}
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
await login(password);
localStorage.setItem("admin-password", password);
onAuthenticated();
} catch (err) {
setError("Invalid password");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Lock className="h-6 w-6" />
</div>
<CardTitle>Admin Access Required</CardTitle>
<CardDescription>Enter the admin password to access Voice and Media controls.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Enter password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "Authenticating..." : "Unlock Controls"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -23,7 +23,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm"> <article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
<div className="flex gap-3"> <div className="flex gap-3">
<img <img
src={message.avatar_url ?? "/default-avatar.png"} src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
alt="" alt=""
className="h-10 w-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
/> />

View File

@@ -18,11 +18,15 @@ export function useMessages() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fetchMessages = useCallback(async (channelId?: string) => { const fetchMessages = useCallback(async (channelId?: string) => {
if (!channelId) {
setMessages([]);
return [];
}
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const params = new URLSearchParams({ limit: "80" }); const params = new URLSearchParams({ limit: "80" });
if (channelId) params.set("channel", channelId); params.set("channel", channelId);
const result = await listMessages(params); const result = await listMessages(params);
setMessages(result.data); setMessages(result.data);
return result.data; return result.data;

View File

@@ -4,7 +4,6 @@ import {
disconnectVoice, disconnectVoice,
getGuilds, getGuilds,
getTextChannels, getTextChannels,
getThreads,
getVoiceChannels, getVoiceChannels,
getVoiceStatus, getVoiceStatus,
} from "../api/voice"; } from "../api/voice";
@@ -46,13 +45,9 @@ export function useVoiceControl() {
setTextChannels([]); setTextChannels([]);
return []; return [];
} }
const [channels, threads] = await Promise.all([ const channels = await getTextChannels(guildId);
getTextChannels(guildId), setTextChannels(channels);
getThreads(guildId).catch(() => []), return channels;
]);
const combined = [...channels, ...threads];
setTextChannels(combined);
return combined;
}, []); }, []);
const joinVoice = useCallback(async (guildId: string, channelId: string) => { const joinVoice = useCallback(async (guildId: string, channelId: string) => {

View File

@@ -76,6 +76,7 @@ const configSchema = z
POSTGRES_DB: z.string().optional(), POSTGRES_DB: z.string().optional(),
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2), POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10), POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
ADMIN_PASSWORD: z.string().default("admin123"),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) { if (!value.AI_ANALYSIS_ENABLED) {

View File

@@ -19,7 +19,6 @@ let db:
*/ */
export async function initializeDatabase() { export async function initializeDatabase() {
if (db !== null) { if (db !== null) {
logger.debug("Database already initialized, skipping");
return db; return db;
} }

View File

@@ -1,3 +1,4 @@
import "./mock-crc";
import "libsodium-wrappers"; import "libsodium-wrappers";
import "@snazzah/davey"; import "@snazzah/davey";
import "dotenv/config"; import "dotenv/config";

View File

@@ -52,9 +52,21 @@ export class MediaController {
): Promise<MediaState> { ): Promise<MediaState> {
const mode = options.mode ?? "music"; const mode = options.mode ?? "music";
if (mode === "screen") { if (mode === "screen") {
// Stop current music if any
this.playbackToken++;
this.playback?.stop();
this.playback = null;
return this.startScreen(source); return this.startScreen(source);
} }
// mode === "music"
// Stop screen if active
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
}
this.assertCanStartMusic(); this.assertCanStartMusic();
const resolved = await ( const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource this.dependencies.resolveMediaSource ?? resolveMediaSource
@@ -108,10 +120,6 @@ export class MediaController {
); );
} }
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
if (this.dependencies.isBrowserStreaming?.()) { if (this.dependencies.isBrowserStreaming?.()) {
throw new AppError( throw new AppError(
"Stop browser microphone streaming before playing media", "Stop browser microphone streaming before playing media",
@@ -122,14 +130,6 @@ export class MediaController {
} }
private async startScreen(source: string): Promise<MediaState> { private async startScreen(source: string): Promise<MediaState> {
if (
this.screenPlayback ||
this.dependencies.screenController?.isActive() ||
this.playback ||
this.queueStore.snapshot().current
) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
const screenController = this.dependencies.screenController; const screenController = this.dependencies.screenController;
if (!screenController) { if (!screenController) {
throw new AppError( throw new AppError(

View File

@@ -1,12 +1,18 @@
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import type { WebRtcConnWrapper } from "@dank074/discord-video-stream";
import { import {
playStream as defaultPlayStream, playStream as defaultPlayStream,
prepareStream as defaultPrepareStream, prepareStream as defaultPrepareStream,
Encoders, Encoders,
Streamer,
Utils, Utils,
} from "@dank074/discord-video-stream"; } from "@dank074/discord-video-stream";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
const logger = createChildLogger("screen-share");
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes"; import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
import { createYtDlp } from "./ytdlp"; import { createYtDlp } from "./ytdlp";
@@ -28,7 +34,7 @@ type PrepareScreenStream = (
type PlayScreenStream = ( type PlayScreenStream = (
output: Readable, output: Readable,
streamer: unknown, streamer: Streamer,
options: { type: "go-live" }, options: { type: "go-live" },
) => Promise<void>; ) => Promise<void>;
@@ -38,7 +44,13 @@ export interface ScreenShareControllerDependencies {
getDirectVideoUrl?: (source: string) => Promise<string>; getDirectVideoUrl?: (source: string) => Promise<string>;
prepareStream?: PrepareScreenStream; prepareStream?: PrepareScreenStream;
playStream?: PlayScreenStream; playStream?: PlayScreenStream;
streamer: unknown; streamer: Streamer;
joinVoice?: (
guildId: string,
channelId: string,
) => Promise<WebRtcConnWrapper>;
onStreamStart?: () => void;
onStreamEnd?: () => void;
} }
export function createScreenShareController( export function createScreenShareController(
@@ -52,11 +64,9 @@ export function createScreenShareController(
dependencies.getDirectVideoUrl ?? dependencies.getDirectVideoUrl ??
((source) => ytdlp.getDirectVideoUrl(source)); ((source) => ytdlp.getDirectVideoUrl(source));
const prepareStream = const prepareStream =
dependencies.prepareStream ?? dependencies.prepareStream ?? (defaultPrepareStream as PrepareScreenStream);
(defaultPrepareStream as unknown as PrepareScreenStream);
const playStream = const playStream =
dependencies.playStream ?? dependencies.playStream ?? (defaultPlayStream as PlayScreenStream);
(defaultPlayStream as unknown as PlayScreenStream);
return { return {
isActive(): boolean { isActive(): boolean {
@@ -65,6 +75,12 @@ export function createScreenShareController(
async start(source: string): Promise<ScreenSharePlayback> { async start(source: string): Promise<ScreenSharePlayback> {
const status = dependencies.getVoiceStatus(); const status = dependencies.getVoiceStatus();
if (active) {
active.stop();
}
// Ensure bot is in the voice channel via Streamer for video streaming
if ( if (
!status.connected || !status.connected ||
!status.activeGuildId || !status.activeGuildId ||
@@ -77,11 +93,17 @@ export function createScreenShareController(
); );
} }
if (active || getPlayerOwner() !== "none") { try {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409); // Join voice via Streamer if not already connected for streaming
if (dependencies.joinVoice) {
logger.info("Joining voice channel for screen share via Streamer");
await dependencies.joinVoice(
status.activeGuildId,
status.activeChannelId,
);
logger.info("Voice channel joined via Streamer for screen share");
} }
try {
const directUrl = await getDirectVideoUrl(source); const directUrl = await getDirectVideoUrl(source);
const { command, output } = prepareStream(directUrl, { const { command, output } = prepareStream(directUrl, {
encoder: Encoders.software({ x264: { preset: "superfast" } }), encoder: Encoders.software({ x264: { preset: "superfast" } }),
@@ -93,11 +115,23 @@ export function createScreenShareController(
videoCodec: Utils.normalizeVideoCodec("H264"), videoCodec: Utils.normalizeVideoCodec("H264"),
}); });
// Add FFmpeg error logging
if (command && "stderr" in command && (command as any).stderr) {
(command as any).stderr.on("data", (data: Buffer) => {
if (data.toString().includes("Error")) {
logger.error({ error: data.toString() }, "FFmpeg Screen Error");
}
});
}
dependencies.onStreamStart?.();
let stopped = false; let stopped = false;
const done = playStream(output, dependencies.streamer, { const done = playStream(output, dependencies.streamer, {
type: "go-live", type: "go-live",
}).finally(() => { }).finally(() => {
active = null; active = null;
dependencies.onStreamEnd?.();
}); });
active = { active = {

View File

@@ -1,3 +1,7 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// Mock node-crc to provide pure JS implementation and bypass native build issues // Mock node-crc to provide pure JS implementation and bypass native build issues
const CRC_TABLE = new Uint32Array(256); const CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
return originalRequire.apply(this, arguments); return originalRequire.apply(this, arguments);
}; };
console.log("[mock] node-crc has been mocked globally."); console.log("[mock] node-crc has been mocked globally for ESM.");
export {};

View File

@@ -34,11 +34,22 @@ async function processAnalysisRequest({
conversationKey, conversationKey,
messages, messages,
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> { }: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
try {
try { try {
if (!dbInitialized) { if (!dbInitialized) {
await initializeDatabase(); await initializeDatabase();
dbInitialized = true; dbInitialized = true;
} }
} catch (dbError) {
const msg = dbError instanceof Error ? dbError.message : String(dbError);
return {
ok: false,
conversationKey,
rows: [],
error: `Database init failed: ${msg}`,
};
}
const firstMessage = messages[0]; const firstMessage = messages[0];
if (!firstMessage) return { ok: true, conversationKey, rows: [] }; if (!firstMessage) return { ok: true, conversationKey, rows: [] };

View File

@@ -26,35 +26,43 @@ export function parseModerationResponse(
content: string, content: string,
targetIds: string[], targetIds: string[],
): AnalysisResult[] { ): AnalysisResult[] {
// Find first opening brace // Find first opening brace and last closing brace
const startIdx = content.indexOf("{"); const startIdx = content.indexOf("{");
if (startIdx === -1) { const endIdx = content.lastIndexOf("}");
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) {
throw new Error("No JSON object found in response"); throw new Error("No JSON object found in response");
} }
// Scan from start and try parsing at each closing brace // Attempt to parse the largest possible JSON object
let parsed: unknown; let parsed: unknown;
let lastError: Error | null = null; const candidate = content.substring(startIdx, endIdx + 1);
for (let i = startIdx + 1; i < content.length; i++) {
if (content[i] === "}") {
const candidate = content.substring(startIdx, i + 1);
try { try {
parsed = JSON.parse(candidate); parsed = JSON.parse(candidate);
// Successfully parsed, break out
break;
} catch (error) { } catch (error) {
// Store error and continue scanning // If full substring fails, try scanning backwards from the last }
lastError = error instanceof Error ? error : new Error(String(error)); let lastError: Error =
error instanceof Error ? error : new Error(String(error));
for (let i = endIdx - 1; i > startIdx; i--) {
if (content[i] === "}") {
try {
parsed = JSON.parse(content.substring(startIdx, i + 1));
break;
} catch (innerError) {
lastError =
innerError instanceof Error
? innerError
: new Error(String(innerError));
continue; continue;
} }
} }
} }
if (!parsed) { if (!parsed) {
throw new Error( throw new Error(`Failed to parse JSON: ${lastError.message}`);
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`, }
);
} }
// Validate structure // Validate structure
@@ -72,7 +80,7 @@ export function parseModerationResponse(
const targetIdSet = new Set(targetIds); const targetIdSet = new Set(targetIds);
// Parse and validate each result // Parse and validate each result
const results: AnalysisResult[] = response.results.map((result) => { const results: (AnalysisResult | null)[] = response.results.map((result) => {
const { message_id, status, flags, score, analysis } = result; const { message_id, status, flags, score, analysis } = result;
// Validate message_id exists and is in target list // Validate message_id exists and is in target list
@@ -80,15 +88,39 @@ export function parseModerationResponse(
throw new Error("Result missing 'message_id'"); throw new Error("Result missing 'message_id'");
} }
if (!targetIdSet.has(message_id)) { let finalId = String(message_id);
throw new Error(`Unknown message_id: ${message_id}`);
// Precision loss fix: If the ID from LLM is not found,
// try to find the closest match in targets if it looks rounded (ends in 000)
if (!targetIdSet.has(finalId)) {
if (finalId.endsWith("00") || finalId.includes("e+")) {
const roundedPrefix = finalId.substring(0, 10);
const match = targetIds.find((id) => id.startsWith(roundedPrefix));
if (match) {
log.warn(
{ roundedId: finalId, matchedId: match },
"Fixed precision loss in message ID",
);
finalId = match;
}
}
} }
if (foundIds.has(message_id)) { if (!targetIdSet.has(finalId)) {
throw new Error(`Duplicate message_id in results: ${message_id}`); throw new Error(
`Unknown message_id: ${finalId} (original: ${message_id})`,
);
} }
foundIds.add(message_id); if (foundIds.has(finalId)) {
log.warn(
{ duplicateId: finalId },
"Skipping duplicate/rounded message_id",
);
return null;
}
foundIds.add(finalId);
// Validate status // Validate status
const validStatuses = ["clean", "warn", "flagged"] as const; const validStatuses = ["clean", "warn", "flagged"] as const;
@@ -120,7 +152,7 @@ export function parseModerationResponse(
const analysisStr = analysis ? String(analysis) : ""; const analysisStr = analysis ? String(analysis) : "";
return { return {
messageId: message_id, messageId: finalId,
status: status as "clean" | "warn" | "flagged", status: status as "clean" | "warn" | "flagged",
flags: flagsArray, flags: flagsArray,
score: numScore, score: numScore,
@@ -128,13 +160,17 @@ export function parseModerationResponse(
}; };
}); });
const filteredResults = results.filter(
(r): r is AnalysisResult => r !== null,
);
// Check that all target IDs were found // Check that all target IDs were found
const missingIds = targetIds.filter((id) => !foundIds.has(id)); const missingIds = targetIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) { if (missingIds.length > 0) {
throw new Error(`Missing target ids in response: ${missingIds.join(", ")}`); log.warn({ missingIds }, "Some target IDs missing in response");
} }
return results; return filteredResults;
} }
interface ModerationInput { interface ModerationInput {
@@ -174,8 +210,11 @@ Context: ${contextText}
Messages to analyze: Messages to analyze:
${messagesText} ${messagesText}
For each message, respond with a JSON object containing a "results" array. Each result must have: For each message, respond with a JSON object containing a "results" array.
- message_id: the message ID CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
Each result must have:
- message_id: the message ID (STRING, exactly as provided)
- status: "clean", "warn", or "flagged" - status: "clean", "warn", or "flagged"
- flags: array of violation flags (e.g., ["spam", "hate_speech"]) - flags: array of violation flags (e.g., ["spam", "hate_speech"])
- score: confidence score from 0 to 1 - score: confidence score from 0 to 1
@@ -219,7 +258,18 @@ Return ONLY valid JSON, no other text.`;
throw new Error(`LLM API error ${response.status}: ${text}`); throw new Error(`LLM API error ${response.status}: ${text}`);
} }
return response.json(); const bodyText = await response.text();
try {
return JSON.parse(bodyText);
} catch (e) {
// Handle cases where the API provider returns trailing garbage
const start = bodyText.indexOf("{");
const end = bodyText.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
return JSON.parse(bodyText.substring(start, end + 1));
}
throw e;
}
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }

View File

@@ -125,7 +125,7 @@ export async function startRecording(
const userMetadata = await collectUserMetadata(client, userId, channel); const userMetadata = await collectUserMetadata(client, userId, channel);
if (userMetadata.bot) return; if (userMetadata.bot) return;
logger.info( logger.debug(
{ userId, username: userMetadata.username }, { userId, username: userMetadata.username },
"Voice activity detected", "Voice activity detected",
); );

View File

@@ -1,4 +1,4 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { MediaController } from "../media/mediaController"; import type { MediaController } from "../media/mediaController";
@@ -9,18 +9,42 @@ export type MediaRouteController = Pick<
"getState" | "queue" | "skip" | "stop" "getState" | "queue" | "skip" | "stop"
>; >;
export function createMediaRoutes(controller: MediaRouteController): Router { export interface MediaRouteOptions {
const router = express.Router(); adminPassword?: string;
}
router.get("/media/status", (_req, res, next) => { export function createMediaRoutes(
controller: MediaRouteController,
options: MediaRouteOptions = {},
): Router {
const router = express.Router();
const { adminPassword } = options;
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
if (!adminPassword) return next();
const authHeader = req.headers["x-admin-password"];
if (authHeader === adminPassword) {
next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
}
};
router.get(
"/media/status",
(_req: Request, res: Response, next: NextFunction) => {
try { try {
res.json(controller.getState()); res.json(controller.getState());
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
router.post("/media/queue", async (req, res, next) => { router.post(
"/media/queue",
adminAuth,
async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { source, mode = "music" } = req.body as { const { source, mode = "music" } = req.body as {
source?: string; source?: string;
@@ -40,23 +64,32 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
router.post("/media/skip", async (_req, res, next) => { router.post(
"/media/skip",
adminAuth,
async (_req: Request, res: Response, next: NextFunction) => {
try { try {
res.json(await controller.skip()); res.json(await controller.skip());
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
router.post("/media/stop", async (_req, res, next) => { router.post(
"/media/stop",
adminAuth,
async (_req: Request, res: Response, next: NextFunction) => {
try { try {
res.json(await controller.stop()); res.json(await controller.stop());
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
return router; return router;
} }

View File

@@ -1,4 +1,4 @@
import type { Router } from "express"; import type { NextFunction, Request, Response, Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
voiceController: VoiceController; voiceController: VoiceController;
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState; patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
broadcaster: ModerationBroadcaster; broadcaster: ModerationBroadcaster;
adminPassword?: string;
} }
export function createVoiceRoutes( export function createVoiceRoutes(
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
| ((patch: Partial<SharedUIState>) => SharedUIState) | ((patch: Partial<SharedUIState>) => SharedUIState)
| undefined; | undefined;
let broadcaster: ModerationBroadcaster | undefined; let broadcaster: ModerationBroadcaster | undefined;
let adminPassword: string | undefined;
if ("connect" in options && "disconnect" in options) { if ("connect" in options && "disconnect" in options) {
// Old signature: just VoiceController // Old signature: just VoiceController
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
voiceController = opts.voiceController; voiceController = opts.voiceController;
patchSharedUIState = opts.patchSharedUIState; patchSharedUIState = opts.patchSharedUIState;
broadcaster = opts.broadcaster; broadcaster = opts.broadcaster;
adminPassword = opts.adminPassword;
} }
const adminAuth = (req: Request, res: Response, next: NextFunction) => {
if (!adminPassword) return next();
const authHeader = req.headers["x-admin-password"];
if (authHeader === adminPassword) {
next();
} else {
res.status(401).json({ error: "Unauthorized access to admin features" });
}
};
// GET /api/status - Get voice connection status // GET /api/status - Get voice connection status
router.get("/status", (_req, res, next) => { router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const status = voiceController.getStatus(); const status = voiceController.getStatus();
res.json(status); res.json(status);
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds - List available guilds // GET /api/guilds - List available guilds
router.get("/guilds", (_req, res, next) => { router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
try { try {
const guilds = voiceController.listGuilds(); const guilds = voiceController.listGuilds();
res.json(guilds); res.json(guilds);
@@ -58,7 +71,9 @@ export function createVoiceRoutes(
}); });
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild // GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
router.get("/guilds/:guildId/voice-channels", async (req, res, next) => { router.get(
"/guilds/:guildId/voice-channels",
async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { guildId } = req.params; const { guildId } = req.params;
@@ -66,15 +81,20 @@ export function createVoiceRoutes(
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
} }
const channels = await voiceController.listVoiceChannels(guildId); const channels = await voiceController.listVoiceChannels(
guildId as string,
);
res.json(channels); res.json(channels);
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
// GET /api/guilds/:guildId/channels - List text channels in a guild // GET /api/guilds/:guildId/channels - List text channels in a guild
router.get("/guilds/:guildId/channels", async (req, res, next) => { router.get(
"/guilds/:guildId/channels",
async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { guildId } = req.params; const { guildId } = req.params;
@@ -82,31 +102,21 @@ export function createVoiceRoutes(
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
} }
const channels = await voiceController.listWatchableChannels(guildId); const channels = await voiceController.listWatchableChannels(
guildId as string,
);
res.json(channels); res.json(channels);
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
// GET /api/guilds/:guildId/threads - List threads in a guild
router.get("/guilds/:guildId/threads", async (req, res, next) => {
try {
const { guildId } = req.params;
if (!guildId) {
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
}
const threads = await voiceController.listThreads(guildId);
res.json(threads);
} catch (error) {
next(error);
}
});
// POST /api/connect - Connect to a voice channel // POST /api/connect - Connect to a voice channel
router.post("/connect", async (req, res, next) => { router.post(
"/connect",
adminAuth,
async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { guildId, channelId } = req.body as { const { guildId, channelId } = req.body as {
guildId?: string; guildId?: string;
@@ -138,10 +148,14 @@ export function createVoiceRoutes(
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
// POST /api/disconnect - Disconnect from voice channel // POST /api/disconnect - Disconnect from voice channel
router.post("/disconnect", async (_req, res, next) => { router.post(
"/disconnect",
adminAuth,
async (_req: Request, res: Response, next: NextFunction) => {
try { try {
logger.info("Disconnecting from voice channel"); logger.info("Disconnecting from voice channel");
@@ -160,7 +174,8 @@ export function createVoiceRoutes(
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); },
);
return router; return router;
} }

View File

@@ -83,46 +83,6 @@ export class VoiceController {
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
async listThreads(guildId: string): Promise<ChannelSummary[]> {
const guild = this.getGuild(guildId);
await guild.channels.fetch().catch(() => null);
const threads: ChannelSummary[] = [];
type ThreadFetchResult = {
threads: Map<string, { id: string; name: string; type: string }>;
};
for (const channel of guild.channels.cache.values()) {
const threadParent = channel as typeof channel & {
threads?: {
fetch: (options: {
archived: boolean;
limit: number;
}) => Promise<ThreadFetchResult>;
};
};
if (!threadParent.threads?.fetch) continue;
for (const archived of [false, true]) {
const fetched = await threadParent.threads
.fetch({ archived, limit: 100 })
.catch(() => null);
if (!fetched?.threads) continue;
for (const thread of fetched.threads.values()) {
threads.push({
id: thread.id,
name: `${channel.name} / ${thread.name}`,
type: thread.type,
});
}
}
}
return Array.from(
new Map(threads.map((thread) => [thread.id, thread])).values(),
).sort((a, b) => a.name.localeCompare(b.name));
}
async connect(guildId: string, channelId: string): Promise<VoiceStatus> { async connect(guildId: string, channelId: string): Promise<VoiceStatus> {
if (!this.client.isReady()) { if (!this.client.isReady()) {
throw new AppError( throw new AppError(

View File

@@ -13,6 +13,7 @@ import express, {
import helmet from "helmet"; import helmet from "helmet";
import * as prism from "prism-media"; import * as prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { config } from "./config";
import { AppError } from "./errors"; import { AppError } from "./errors";
import { createChildLogger, logger } from "./logger"; import { createChildLogger, logger } from "./logger";
import { MediaController } from "./media/mediaController"; import { MediaController } from "./media/mediaController";
@@ -121,7 +122,9 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
if (typeof patch.selectedTextChannel === "string") { if (typeof patch.selectedTextChannel === "string") {
sharedUIState.selectedTextChannel = patch.selectedTextChannel; sharedUIState.selectedTextChannel = patch.selectedTextChannel;
} }
if (["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")) { if (
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
) {
sharedUIState.activeTab = patch.activeTab as sharedUIState.activeTab = patch.activeTab as
| "voice" | "voice"
| "messages" | "messages"
@@ -182,11 +185,14 @@ export async function startWebserver(
// Create broadcaster instance // Create broadcaster instance
const broadcaster = createBroadcaster(); const broadcaster = createBroadcaster();
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster; (globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
const streamer = new Streamer(_client); const streamer = new Streamer(_client);
const screenController = createScreenShareController({ const screenController = createScreenShareController({
getVoiceStatus: () => voiceController.getStatus(), getVoiceStatus: () => voiceController.getStatus(),
streamer, streamer,
joinVoice: (guildId: string, channelId: string) =>
streamer.joinVoice(guildId, channelId),
}); });
const mediaController = new MediaController({ const mediaController = new MediaController({
@@ -257,6 +263,16 @@ export async function startWebserver(
res.send(await getMetrics()); res.send(await getMetrics());
}); });
// Simple password-based auth
app.post("/api/auth/login", (req: Request, res: Response) => {
const { password } = req.body;
if (password === config.ADMIN_PASSWORD) {
res.json({ ok: true });
} else {
res.status(401).json({ error: "Invalid password" });
}
});
// Register route modules // Register route modules
app.use( app.use(
"/api", "/api",
@@ -268,12 +284,18 @@ export async function startWebserver(
voiceController, voiceController,
patchSharedUIState, patchSharedUIState,
broadcaster, broadcaster,
adminPassword: config.ADMIN_PASSWORD,
}), }),
); );
app.use("/api", createMessageRoutes()); app.use("/api", createMessageRoutes());
app.use("/api", createAnalysisRoutes()); app.use("/api", createAnalysisRoutes());
app.use("/api", createSyncRoutes(_client)); app.use("/api", createSyncRoutes(_client));
app.use("/api", createMediaRoutes(mediaController)); app.use(
"/api",
createMediaRoutes(mediaController, {
adminPassword: config.ADMIN_PASSWORD,
}),
);
// Inbound: Discord PCM → tagged chunks → browser // Inbound: Discord PCM → tagged chunks → browser
(globalThis as VoiceGlobals).broadcastPcmToWeb = ( (globalThis as VoiceGlobals).broadcastPcmToWeb = (

View File

@@ -0,0 +1,75 @@
import { beforeAll, describe, expect, it } from "vitest";
import { config } from "../../src/config";
import { runModerationAnalysis } from "../../src/moderation/llmModerationClient";
import type { MessageRecord } from "../../src/moderation/types";
describe("LLM Live Integration Test", () => {
// Hanya jalankan jika API Key tersedia
const hasApiKey =
!!config.AI_LLM_API_KEY && config.AI_LLM_API_KEY !== "your-api-key";
it.runIf(hasApiKey)(
"should successfully call real LLM API and parse response",
async () => {
console.log(`Using Model: ${config.AI_LLM_MODEL}`);
console.log(`Base URL: ${config.AI_LLM_BASE_URL}`);
const mockMessages: MessageRecord[] = [
{
id: "test-msg-1",
guild_id: "guild-1",
channel_id: "channel-1",
thread_id: null,
user_id: "user-1",
username: "Tester",
avatar_url: null,
content: "This is a clean test message.",
edited_content: null,
created_at: Date.now(),
edited_at: null,
deleted_at: null,
type: "text",
metadata: null,
},
{
id: "test-msg-2",
guild_id: "guild-1",
channel_id: "channel-1",
thread_id: null,
user_id: "user-2",
username: "BadActor",
avatar_url: null,
content: "I will kill you and steal your data! DIE!",
edited_content: null,
created_at: Date.now() + 1000,
edited_at: null,
deleted_at: null,
type: "text",
metadata: null,
},
];
const result = await runModerationAnalysis({
targets: mockMessages,
contextText: "Testing moderation system stability.",
});
console.log(
"Raw Response received (first 100 chars):",
JSON.stringify(result.raw).substring(0, 100),
);
expect(result.results).toHaveLength(2);
const cleanMsg = result.results.find((r) => r.messageId === "test-msg-1");
const badMsg = result.results.find((r) => r.messageId === "test-msg-2");
expect(cleanMsg?.status).toBe("clean");
expect(["warn", "flagged"]).toContain(badMsg?.status);
console.log("Clean Message Result:", cleanMsg);
console.log("Bad Message Result:", badMsg);
},
30000,
); // 30s timeout untuk LLM
});