Compare commits
8 Commits
9ad7d16a17
...
518577d79d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
518577d79d | ||
|
|
d04093ec6e | ||
|
|
05feb697f0 | ||
|
|
a5b5ccf5b0 | ||
|
|
99ec528a03 | ||
|
|
7dedac2094 | ||
|
|
9b211f05cf | ||
|
|
4825dc6d4d |
57
debug-screen.ts
Normal file
57
debug-screen.ts
Normal 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();
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Discord Moderation Dashboard</title>
|
||||
</head>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MessagesPanel } from "./components/messages/MessagesPanel";
|
||||
import { ReviewPanel } from "./components/review/ReviewPanel";
|
||||
import { Tabs, TabsContent } from "./components/ui/tabs";
|
||||
import { VoicePanel } from "./components/voice/VoicePanel";
|
||||
import { AuthOverlay } from "./components/layout/AuthOverlay";
|
||||
import { useDashboardSocket } from "./hooks/useDashboardSocket";
|
||||
import { mergeMessages, useMessages } from "./hooks/useMessages";
|
||||
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 [isListening, setIsListening] = useState(false);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!localStorage.getItem("admin-password"));
|
||||
const audioContextListenRef = useRef<AudioContext | null>(null);
|
||||
const audioContextTransmitRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
@@ -144,16 +146,22 @@ export default function App() {
|
||||
}, [isStreaming, startStreamingLocal, stopStreamingLocal, patchUIState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild) voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
if (selectedVoiceGuild) {
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}
|
||||
}, [selectedVoiceGuild]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextGuild) voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||
}, [selectedTextGuild, voice.loadTextTargets]);
|
||||
if (selectedTextGuild) {
|
||||
voice.loadTextTargets(selectedTextGuild).catch(() => undefined);
|
||||
}
|
||||
}, [selectedTextGuild]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTextChannel) {
|
||||
messages.fetchMessages(selectedTextChannel).catch(() => undefined);
|
||||
}, [selectedTextChannel, messages.fetchMessages]);
|
||||
}
|
||||
}, [selectedTextChannel]);
|
||||
|
||||
const toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
@@ -192,6 +200,9 @@ export default function App() {
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={(value) => patchUIState({ activeTab: value as DashboardTab })}>
|
||||
<TabsContent value="voice">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<VoicePanel
|
||||
guilds={voice.guilds}
|
||||
channels={voice.voiceChannels}
|
||||
@@ -210,8 +221,12 @@ export default function App() {
|
||||
onListenToggle={toggleListening}
|
||||
onStreamingToggle={toggleStreaming}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="media">
|
||||
{!isAuthenticated ? (
|
||||
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<MediaPanel
|
||||
state={media.mediaState}
|
||||
loading={media.loading}
|
||||
@@ -220,6 +235,7 @@ export default function App() {
|
||||
onSkip={media.skip}
|
||||
onStop={media.stop}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="messages">
|
||||
<MessagesPanel
|
||||
|
||||
8
frontend/src/api/auth.ts
Normal file
8
frontend/src/api/auth.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -50,8 +50,12 @@ class ApiError extends Error {
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const password = localStorage.getItem("admin-password");
|
||||
const res = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
...init,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ export function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
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> {
|
||||
return request<VoiceStatus>('/api/status');
|
||||
}
|
||||
|
||||
62
frontend/src/components/layout/AuthOverlay.tsx
Normal file
62
frontend/src/components/layout/AuthOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
||||
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<img
|
||||
src={message.avatar_url ?? "/default-avatar.png"}
|
||||
src={message.avatar_url ?? "https://cdn.discordapp.com/embed/avatars/0.png"}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
|
||||
@@ -18,11 +18,15 @@ export function useMessages() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchMessages = useCallback(async (channelId?: string) => {
|
||||
if (!channelId) {
|
||||
setMessages([]);
|
||||
return [];
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "80" });
|
||||
if (channelId) params.set("channel", channelId);
|
||||
params.set("channel", channelId);
|
||||
const result = await listMessages(params);
|
||||
setMessages(result.data);
|
||||
return result.data;
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getThreads,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../api/voice";
|
||||
@@ -46,13 +45,9 @@ export function useVoiceControl() {
|
||||
setTextChannels([]);
|
||||
return [];
|
||||
}
|
||||
const [channels, threads] = await Promise.all([
|
||||
getTextChannels(guildId),
|
||||
getThreads(guildId).catch(() => []),
|
||||
]);
|
||||
const combined = [...channels, ...threads];
|
||||
setTextChannels(combined);
|
||||
return combined;
|
||||
const channels = await getTextChannels(guildId);
|
||||
setTextChannels(channels);
|
||||
return channels;
|
||||
}, []);
|
||||
|
||||
const joinVoice = useCallback(async (guildId: string, channelId: string) => {
|
||||
|
||||
@@ -76,6 +76,7 @@ const configSchema = z
|
||||
POSTGRES_DB: z.string().optional(),
|
||||
POSTGRES_POOL_MIN: z.coerce.number().int().positive().default(2),
|
||||
POSTGRES_POOL_MAX: z.coerce.number().int().positive().default(10),
|
||||
ADMIN_PASSWORD: z.string().default("admin123"),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.AI_ANALYSIS_ENABLED) {
|
||||
|
||||
@@ -19,7 +19,6 @@ let db:
|
||||
*/
|
||||
export async function initializeDatabase() {
|
||||
if (db !== null) {
|
||||
logger.debug("Database already initialized, skipping");
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./mock-crc";
|
||||
import "libsodium-wrappers";
|
||||
import "@snazzah/davey";
|
||||
import "dotenv/config";
|
||||
|
||||
@@ -52,9 +52,21 @@ export class MediaController {
|
||||
): Promise<MediaState> {
|
||||
const mode = options.mode ?? "music";
|
||||
if (mode === "screen") {
|
||||
// Stop current music if any
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
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();
|
||||
const resolved = await (
|
||||
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?.()) {
|
||||
throw new AppError(
|
||||
"Stop browser microphone streaming before playing media",
|
||||
@@ -122,14 +130,6 @@ export class MediaController {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!screenController) {
|
||||
throw new AppError(
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { WebRtcConnWrapper } from "@dank074/discord-video-stream";
|
||||
import {
|
||||
playStream as defaultPlayStream,
|
||||
prepareStream as defaultPrepareStream,
|
||||
Encoders,
|
||||
Streamer,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import { AppError } from "../errors";
|
||||
import { createChildLogger } from "../logger";
|
||||
import { discordPlayer } from "../player";
|
||||
|
||||
const logger = createChildLogger("screen-share");
|
||||
|
||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||
import { createYtDlp } from "./ytdlp";
|
||||
|
||||
@@ -28,7 +34,7 @@ type PrepareScreenStream = (
|
||||
|
||||
type PlayScreenStream = (
|
||||
output: Readable,
|
||||
streamer: unknown,
|
||||
streamer: Streamer,
|
||||
options: { type: "go-live" },
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -38,7 +44,13 @@ export interface ScreenShareControllerDependencies {
|
||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||
prepareStream?: PrepareScreenStream;
|
||||
playStream?: PlayScreenStream;
|
||||
streamer: unknown;
|
||||
streamer: Streamer;
|
||||
joinVoice?: (
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
) => Promise<WebRtcConnWrapper>;
|
||||
onStreamStart?: () => void;
|
||||
onStreamEnd?: () => void;
|
||||
}
|
||||
|
||||
export function createScreenShareController(
|
||||
@@ -52,11 +64,9 @@ export function createScreenShareController(
|
||||
dependencies.getDirectVideoUrl ??
|
||||
((source) => ytdlp.getDirectVideoUrl(source));
|
||||
const prepareStream =
|
||||
dependencies.prepareStream ??
|
||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
||||
dependencies.prepareStream ?? (defaultPrepareStream as PrepareScreenStream);
|
||||
const playStream =
|
||||
dependencies.playStream ??
|
||||
(defaultPlayStream as unknown as PlayScreenStream);
|
||||
dependencies.playStream ?? (defaultPlayStream as PlayScreenStream);
|
||||
|
||||
return {
|
||||
isActive(): boolean {
|
||||
@@ -65,6 +75,12 @@ export function createScreenShareController(
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = dependencies.getVoiceStatus();
|
||||
|
||||
if (active) {
|
||||
active.stop();
|
||||
}
|
||||
|
||||
// Ensure bot is in the voice channel via Streamer for video streaming
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
@@ -77,11 +93,17 @@ export function createScreenShareController(
|
||||
);
|
||||
}
|
||||
|
||||
if (active || getPlayerOwner() !== "none") {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
try {
|
||||
// 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 { command, output } = prepareStream(directUrl, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
@@ -93,11 +115,23 @@ export function createScreenShareController(
|
||||
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;
|
||||
const done = playStream(output, dependencies.streamer, {
|
||||
type: "go-live",
|
||||
}).finally(() => {
|
||||
active = null;
|
||||
dependencies.onStreamEnd?.();
|
||||
});
|
||||
|
||||
active = {
|
||||
|
||||
@@ -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
|
||||
const CRC_TABLE = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
@@ -39,4 +43,6 @@ Module.prototype.require = function (id: string) {
|
||||
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 {};
|
||||
|
||||
@@ -34,11 +34,22 @@ async function processAnalysisRequest({
|
||||
conversationKey,
|
||||
messages,
|
||||
}: AnalysisWorkerRequest): Promise<AnalysisWorkerResponse> {
|
||||
try {
|
||||
try {
|
||||
if (!dbInitialized) {
|
||||
await initializeDatabase();
|
||||
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];
|
||||
if (!firstMessage) return { ok: true, conversationKey, rows: [] };
|
||||
|
||||
|
||||
@@ -26,35 +26,43 @@ export function parseModerationResponse(
|
||||
content: string,
|
||||
targetIds: string[],
|
||||
): AnalysisResult[] {
|
||||
// Find first opening brace
|
||||
// Find first opening brace and last closing brace
|
||||
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");
|
||||
}
|
||||
|
||||
// Scan from start and try parsing at each closing brace
|
||||
// Attempt to parse the largest possible JSON object
|
||||
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 {
|
||||
parsed = JSON.parse(candidate);
|
||||
// Successfully parsed, break out
|
||||
break;
|
||||
} catch (error) {
|
||||
// Store error and continue scanning
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
// If full substring fails, try scanning backwards from the last }
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
throw new Error(
|
||||
`Failed to parse JSON: ${lastError?.message || "No valid JSON object found"}`,
|
||||
);
|
||||
throw new Error(`Failed to parse JSON: ${lastError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
@@ -72,7 +80,7 @@ export function parseModerationResponse(
|
||||
const targetIdSet = new Set(targetIds);
|
||||
|
||||
// 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;
|
||||
|
||||
// Validate message_id exists and is in target list
|
||||
@@ -80,15 +88,39 @@ export function parseModerationResponse(
|
||||
throw new Error("Result missing 'message_id'");
|
||||
}
|
||||
|
||||
if (!targetIdSet.has(message_id)) {
|
||||
throw new Error(`Unknown message_id: ${message_id}`);
|
||||
let finalId = String(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)) {
|
||||
throw new Error(`Duplicate message_id in results: ${message_id}`);
|
||||
if (!targetIdSet.has(finalId)) {
|
||||
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
|
||||
const validStatuses = ["clean", "warn", "flagged"] as const;
|
||||
@@ -120,7 +152,7 @@ export function parseModerationResponse(
|
||||
const analysisStr = analysis ? String(analysis) : "";
|
||||
|
||||
return {
|
||||
messageId: message_id,
|
||||
messageId: finalId,
|
||||
status: status as "clean" | "warn" | "flagged",
|
||||
flags: flagsArray,
|
||||
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
|
||||
const missingIds = targetIds.filter((id) => !foundIds.has(id));
|
||||
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 {
|
||||
@@ -174,8 +210,11 @@ Context: ${contextText}
|
||||
Messages to analyze:
|
||||
${messagesText}
|
||||
|
||||
For each message, respond with a JSON object containing a "results" array. Each result must have:
|
||||
- message_id: the message ID
|
||||
For each message, respond with a JSON object containing a "results" array.
|
||||
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"
|
||||
- flags: array of violation flags (e.g., ["spam", "hate_speech"])
|
||||
- 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}`);
|
||||
}
|
||||
|
||||
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 {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export async function startRecording(
|
||||
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||
if (userMetadata.bot) return;
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
{ userId, username: userMetadata.username },
|
||||
"Voice activity detected",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Router } from "express";
|
||||
import type { NextFunction, Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors";
|
||||
import type { MediaController } from "../media/mediaController";
|
||||
@@ -9,18 +9,42 @@ export type MediaRouteController = Pick<
|
||||
"getState" | "queue" | "skip" | "stop"
|
||||
>;
|
||||
|
||||
export function createMediaRoutes(controller: MediaRouteController): Router {
|
||||
const router = express.Router();
|
||||
export interface MediaRouteOptions {
|
||||
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 {
|
||||
res.json(controller.getState());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/media/queue", async (req, res, next) => {
|
||||
router.post(
|
||||
"/media/queue",
|
||||
adminAuth,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { source, mode = "music" } = req.body as {
|
||||
source?: string;
|
||||
@@ -40,23 +64,32 @@ export function createMediaRoutes(controller: MediaRouteController): Router {
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/media/skip", async (_req, res, next) => {
|
||||
router.post(
|
||||
"/media/skip",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.skip());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/media/stop", async (_req, res, next) => {
|
||||
router.post(
|
||||
"/media/stop",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.stop());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Router } from "express";
|
||||
import type { NextFunction, Request, Response, Router } from "express";
|
||||
import express from "express";
|
||||
import { AppError } from "../errors";
|
||||
import { createChildLogger } from "../logger";
|
||||
@@ -12,6 +12,7 @@ export interface VoiceRouteOptions {
|
||||
voiceController: VoiceController;
|
||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
||||
broadcaster: ModerationBroadcaster;
|
||||
adminPassword?: string;
|
||||
}
|
||||
|
||||
export function createVoiceRoutes(
|
||||
@@ -25,6 +26,7 @@ export function createVoiceRoutes(
|
||||
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
||||
| undefined;
|
||||
let broadcaster: ModerationBroadcaster | undefined;
|
||||
let adminPassword: string | undefined;
|
||||
|
||||
if ("connect" in options && "disconnect" in options) {
|
||||
// Old signature: just VoiceController
|
||||
@@ -35,10 +37,21 @@ export function createVoiceRoutes(
|
||||
voiceController = opts.voiceController;
|
||||
patchSharedUIState = opts.patchSharedUIState;
|
||||
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
|
||||
router.get("/status", (_req, res, next) => {
|
||||
router.get("/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = voiceController.getStatus();
|
||||
res.json(status);
|
||||
@@ -48,7 +61,7 @@ export function createVoiceRoutes(
|
||||
});
|
||||
|
||||
// GET /api/guilds - List available guilds
|
||||
router.get("/guilds", (_req, res, next) => {
|
||||
router.get("/guilds", (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const guilds = voiceController.listGuilds();
|
||||
res.json(guilds);
|
||||
@@ -58,7 +71,9 @@ export function createVoiceRoutes(
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const { guildId } = req.params;
|
||||
|
||||
@@ -66,15 +81,20 @@ export function createVoiceRoutes(
|
||||
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);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 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 {
|
||||
const { guildId } = req.params;
|
||||
|
||||
@@ -82,31 +102,21 @@ export function createVoiceRoutes(
|
||||
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);
|
||||
} catch (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
|
||||
router.post("/connect", async (req, res, next) => {
|
||||
router.post(
|
||||
"/connect",
|
||||
adminAuth,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
@@ -138,10 +148,14 @@ export function createVoiceRoutes(
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 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 {
|
||||
logger.info("Disconnecting from voice channel");
|
||||
|
||||
@@ -160,7 +174,8 @@ export function createVoiceRoutes(
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -83,46 +83,6 @@ export class VoiceController {
|
||||
.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> {
|
||||
if (!this.client.isReady()) {
|
||||
throw new AppError(
|
||||
|
||||
@@ -13,6 +13,7 @@ import express, {
|
||||
import helmet from "helmet";
|
||||
import * as prism from "prism-media";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { config } from "./config";
|
||||
import { AppError } from "./errors";
|
||||
import { createChildLogger, logger } from "./logger";
|
||||
import { MediaController } from "./media/mediaController";
|
||||
@@ -121,7 +122,9 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||
if (typeof patch.selectedTextChannel === "string") {
|
||||
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
|
||||
| "voice"
|
||||
| "messages"
|
||||
@@ -182,11 +185,14 @@ export async function startWebserver(
|
||||
// Create broadcaster instance
|
||||
const broadcaster = createBroadcaster();
|
||||
(globalThis as VoiceGlobals).moderationBroadcaster = broadcaster;
|
||||
(globalThis as any).ADMIN_PASSWORD = config.ADMIN_PASSWORD;
|
||||
|
||||
const streamer = new Streamer(_client);
|
||||
const screenController = createScreenShareController({
|
||||
getVoiceStatus: () => voiceController.getStatus(),
|
||||
streamer,
|
||||
joinVoice: (guildId: string, channelId: string) =>
|
||||
streamer.joinVoice(guildId, channelId),
|
||||
});
|
||||
|
||||
const mediaController = new MediaController({
|
||||
@@ -257,6 +263,16 @@ export async function startWebserver(
|
||||
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
|
||||
app.use(
|
||||
"/api",
|
||||
@@ -268,12 +284,18 @@ export async function startWebserver(
|
||||
voiceController,
|
||||
patchSharedUIState,
|
||||
broadcaster,
|
||||
adminPassword: config.ADMIN_PASSWORD,
|
||||
}),
|
||||
);
|
||||
app.use("/api", createMessageRoutes());
|
||||
app.use("/api", createAnalysisRoutes());
|
||||
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
|
||||
(globalThis as VoiceGlobals).broadcastPcmToWeb = (
|
||||
|
||||
75
tests/moderation/llmLive.test.ts
Normal file
75
tests/moderation/llmLive.test.ts
Normal 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
|
||||
});
|
||||
2
vendor/Discord-video-stream
vendored
2
vendor/Discord-video-stream
vendored
Submodule vendor/Discord-video-stream updated: fb83645d73...134ae9288c
Reference in New Issue
Block a user