diff --git a/debug-screen.ts b/debug-screen.ts new file mode 100644 index 0000000..564d9dd --- /dev/null +++ b/debug-screen.ts @@ -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(); diff --git a/src/media/screenShareController.ts b/src/media/screenShareController.ts index 5b2c3e9..25ad7f4 100644 --- a/src/media/screenShareController.ts +++ b/src/media/screenShareController.ts @@ -1,8 +1,10 @@ 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"; @@ -10,6 +12,7 @@ import { createChildLogger } from "../logger"; import { discordPlayer } from "../player"; const logger = createChildLogger("screen-share"); + import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes"; import { createYtDlp } from "./ytdlp"; @@ -31,7 +34,7 @@ type PrepareScreenStream = ( type PlayScreenStream = ( output: Readable, - streamer: unknown, + streamer: Streamer, options: { type: "go-live" }, ) => Promise; @@ -41,7 +44,13 @@ export interface ScreenShareControllerDependencies { getDirectVideoUrl?: (source: string) => Promise; prepareStream?: PrepareScreenStream; playStream?: PlayScreenStream; - streamer: unknown; + streamer: Streamer; + joinVoice?: ( + guildId: string, + channelId: string, + ) => Promise; + onStreamStart?: () => void; + onStreamEnd?: () => void; } export function createScreenShareController( @@ -55,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 { @@ -68,6 +75,12 @@ export function createScreenShareController( async start(source: string): Promise { 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 || @@ -80,11 +93,17 @@ export function createScreenShareController( ); } - if (active) { - active.stop(); - } - 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"); + } + const directUrl = await getDirectVideoUrl(source); const { command, output } = prepareStream(directUrl, { encoder: Encoders.software({ x264: { preset: "superfast" } }), @@ -105,11 +124,14 @@ export function createScreenShareController( }); } + dependencies.onStreamStart?.(); + let stopped = false; const done = playStream(output, dependencies.streamer, { type: "go-live", }).finally(() => { active = null; + dependencies.onStreamEnd?.(); }); active = { diff --git a/src/mock-crc.ts b/src/mock-crc.ts index 037c9bf..4eedcdb 100644 --- a/src/mock-crc.ts +++ b/src/mock-crc.ts @@ -1,4 +1,5 @@ import { createRequire } from "node:module"; + const require = createRequire(import.meta.url); // Mock node-crc to provide pure JS implementation and bypass native build issues @@ -43,4 +44,5 @@ Module.prototype.require = function (id: string) { }; console.log("[mock] node-crc has been mocked globally for ESM."); + export {}; diff --git a/src/moderation/aiAnalysisWorker.ts b/src/moderation/aiAnalysisWorker.ts index 1d5fa17..c0bd726 100644 --- a/src/moderation/aiAnalysisWorker.ts +++ b/src/moderation/aiAnalysisWorker.ts @@ -42,7 +42,12 @@ async function processAnalysisRequest({ } } catch (dbError) { const msg = dbError instanceof Error ? dbError.message : String(dbError); - return { ok: false, conversationKey, rows: [], error: `Database init failed: ${msg}` }; + return { + ok: false, + conversationKey, + rows: [], + error: `Database init failed: ${msg}`, + }; } const firstMessage = messages[0]; diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 72bef56..a1c0b99 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -42,7 +42,8 @@ export function parseModerationResponse( parsed = JSON.parse(candidate); } catch (error) { // If full substring fails, try scanning backwards from the last } - let lastError: Error = 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] === "}") { @@ -50,7 +51,10 @@ export function parseModerationResponse( parsed = JSON.parse(content.substring(startIdx, i + 1)); break; } catch (innerError) { - lastError = innerError instanceof Error ? innerError : new Error(String(innerError)); + lastError = + innerError instanceof Error + ? innerError + : new Error(String(innerError)); continue; } } @@ -109,7 +113,10 @@ export function parseModerationResponse( } if (foundIds.has(finalId)) { - log.warn({ duplicateId: finalId }, "Skipping duplicate/rounded message_id"); + log.warn( + { duplicateId: finalId }, + "Skipping duplicate/rounded message_id", + ); return null; } diff --git a/src/routes/mediaRoutes.ts b/src/routes/mediaRoutes.ts index b60bf4e..6c0e4ab 100644 --- a/src/routes/mediaRoutes.ts +++ b/src/routes/mediaRoutes.ts @@ -30,51 +30,66 @@ export function createMediaRoutes( } }; - router.get("/media/status", (_req: Request, res: Response, next: NextFunction) => { - try { - res.json(controller.getState()); - } catch (error) { - next(error); - } - }); - - router.post("/media/queue", adminAuth, async (req: Request, res: Response, next: NextFunction) => { - try { - const { source, mode = "music" } = req.body as { - source?: string; - mode?: MediaMode; - }; - if (!source) { - throw new AppError( - "Media source is required", - "MISSING_MEDIA_SOURCE", - 400, - ); + router.get( + "/media/status", + (_req: Request, res: Response, next: NextFunction) => { + try { + res.json(controller.getState()); + } catch (error) { + next(error); } - if (mode !== "music" && mode !== "screen") { - throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400); + }, + ); + + router.post( + "/media/queue", + adminAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { source, mode = "music" } = req.body as { + source?: string; + mode?: MediaMode; + }; + if (!source) { + throw new AppError( + "Media source is required", + "MISSING_MEDIA_SOURCE", + 400, + ); + } + if (mode !== "music" && mode !== "screen") { + throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400); + } + res.json(await controller.queue(source, { mode })); + } catch (error) { + next(error); } - res.json(await controller.queue(source, { mode })); - } catch (error) { - next(error); - } - }); + }, + ); - 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/skip", + adminAuth, + async (_req: Request, res: Response, next: NextFunction) => { + try { + res.json(await controller.skip()); + } catch (error) { + next(error); + } + }, + ); - router.post("/media/stop", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - res.json(await controller.stop()); - } catch (error) { - next(error); - } - }); + router.post( + "/media/stop", + adminAuth, + async (_req: Request, res: Response, next: NextFunction) => { + try { + res.json(await controller.stop()); + } catch (error) { + next(error); + } + }, + ); return router; } diff --git a/src/routes/voiceRoutes.ts b/src/routes/voiceRoutes.ts index 61b23b3..801ab26 100644 --- a/src/routes/voiceRoutes.ts +++ b/src/routes/voiceRoutes.ts @@ -71,93 +71,111 @@ export function createVoiceRoutes( }); // GET /api/guilds/:guildId/voice-channels - List voice channels in a guild - router.get("/guilds/:guildId/voice-channels", async (req: Request, res: Response, next: NextFunction) => { - try { - const { guildId } = req.params; + router.get( + "/guilds/:guildId/voice-channels", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { guildId } = req.params; - if (!guildId) { - throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); + if (!guildId) { + throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); + } + + const channels = await voiceController.listVoiceChannels( + guildId as string, + ); + res.json(channels); + } catch (error) { + next(error); } - - 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: Request, res: Response, next: NextFunction) => { - try { - const { guildId } = req.params; + router.get( + "/guilds/:guildId/channels", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { guildId } = req.params; - if (!guildId) { - throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); + if (!guildId) { + throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400); + } + + const channels = await voiceController.listWatchableChannels( + guildId as string, + ); + res.json(channels); + } catch (error) { + next(error); } - - const channels = await voiceController.listWatchableChannels(guildId as string); - res.json(channels); - } catch (error) { - next(error); - } - }); + }, + ); // POST /api/connect - Connect to a voice channel - router.post("/connect", adminAuth, async (req: Request, res: Response, next: NextFunction) => { - try { - const { guildId, channelId } = req.body as { - guildId?: string; - channelId?: string; - }; + router.post( + "/connect", + adminAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { guildId, channelId } = req.body as { + guildId?: string; + channelId?: string; + }; - if (!guildId || !channelId) { - throw new AppError( - "guildId and channelId are required", - "MISSING_CONNECT_FIELDS", - 400, - ); + if (!guildId || !channelId) { + throw new AppError( + "guildId and channelId are required", + "MISSING_CONNECT_FIELDS", + 400, + ); + } + + logger.info({ guildId, channelId }, "Connecting to voice channel"); + + const status = await voiceController.connect(guildId, channelId); + + // Update UI state and broadcast to connected clients + if (patchSharedUIState && broadcaster) { + const updatedState = patchSharedUIState({ + selectedVoiceGuild: guildId, + selectedVoiceChannel: channelId, + }); + broadcaster.uiState(updatedState); + } + + res.json(status); + } catch (error) { + next(error); } - - logger.info({ guildId, channelId }, "Connecting to voice channel"); - - const status = await voiceController.connect(guildId, channelId); - - // Update UI state and broadcast to connected clients - if (patchSharedUIState && broadcaster) { - const updatedState = patchSharedUIState({ - selectedVoiceGuild: guildId, - selectedVoiceChannel: channelId, - }); - broadcaster.uiState(updatedState); - } - - res.json(status); - } catch (error) { - next(error); - } - }); + }, + ); // POST /api/disconnect - Disconnect from voice channel - router.post("/disconnect", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { - try { - logger.info("Disconnecting from voice channel"); + router.post( + "/disconnect", + adminAuth, + async (_req: Request, res: Response, next: NextFunction) => { + try { + logger.info("Disconnecting from voice channel"); - const status = await voiceController.disconnect(); + const status = await voiceController.disconnect(); - // Update UI state and broadcast to connected clients - if (patchSharedUIState && broadcaster) { - const updatedState = patchSharedUIState({ - selectedVoiceGuild: "", - selectedVoiceChannel: "", - }); - broadcaster.uiState(updatedState); + // Update UI state and broadcast to connected clients + if (patchSharedUIState && broadcaster) { + const updatedState = patchSharedUIState({ + selectedVoiceGuild: "", + selectedVoiceChannel: "", + }); + broadcaster.uiState(updatedState); + } + + res.json(status); + } catch (error) { + next(error); } - - res.json(status); - } catch (error) { - next(error); - } - }); + }, + ); return router; } diff --git a/src/webserver.ts b/src/webserver.ts index 98e69ad..5eaee67 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { Streamer } from "@dank074/discord-video-stream"; import { AudioPlayerStatus } from "@discordjs/voice"; import type { Client } from "discord.js-selfbot-v13"; -import { config } from "./config"; import express, { type NextFunction, type Request, @@ -14,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"; @@ -122,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" @@ -189,6 +191,8 @@ export async function startWebserver( const screenController = createScreenShareController({ getVoiceStatus: () => voiceController.getStatus(), streamer, + joinVoice: (guildId: string, channelId: string) => + streamer.joinVoice(guildId, channelId), }); const mediaController = new MediaController({ diff --git a/tests/moderation/llmLive.test.ts b/tests/moderation/llmLive.test.ts index d5072b1..7aa1dec 100644 --- a/tests/moderation/llmLive.test.ts +++ b/tests/moderation/llmLive.test.ts @@ -1,67 +1,75 @@ -import { describe, it, expect, beforeAll } from "vitest"; -import { runModerationAnalysis } from "../../src/moderation/llmModerationClient"; +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"; + 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}`); + 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 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." - }); + 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)); + console.log( + "Raw Response received (first 100 chars):", + JSON.stringify(result.raw).substring(0, 100), + ); - expect(result.results).toHaveLength(2); + 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"); + 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); + 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 + console.log("Clean Message Result:", cleanMsg); + console.log("Bad Message Result:", badMsg); + }, + 30000, + ); // 30s timeout untuk LLM }); diff --git a/vendor/Discord-video-stream b/vendor/Discord-video-stream index fb83645..134ae92 160000 --- a/vendor/Discord-video-stream +++ b/vendor/Discord-video-stream @@ -1 +1 @@ -Subproject commit fb83645d7399cbf83d105f6bfc12448580fbb149 +Subproject commit 134ae9288c6b9eac4236545166f602a34aca7d5c