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">
|
<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>
|
||||||
|
|||||||
@@ -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
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> {
|
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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
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">
|
<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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
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