feat: enhance screen share controller with Streamer integration and voice channel management

This commit is contained in:
MythEclipse
2026-05-17 01:01:40 +07:00
parent d04093ec6e
commit 518577d79d
10 changed files with 321 additions and 183 deletions

57
debug-screen.ts Normal file
View File

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

View File

@@ -1,8 +1,10 @@
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";
@@ -10,6 +12,7 @@ import { createChildLogger } from "../logger";
import { discordPlayer } from "../player"; import { discordPlayer } from "../player";
const logger = createChildLogger("screen-share"); 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";
@@ -31,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>;
@@ -41,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(
@@ -55,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 {
@@ -68,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 ||
@@ -80,11 +93,17 @@ export function createScreenShareController(
); );
} }
if (active) {
active.stop();
}
try { 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 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" } }),
@@ -105,11 +124,14 @@ export function createScreenShareController(
}); });
} }
dependencies.onStreamStart?.();
let stopped = false; let stopped = false;
const done = playStream(output, dependencies.streamer, { const done = playStream(output, dependencies.streamer, {
type: "go-live", type: "go-live",
}).finally(() => { }).finally(() => {
active = null; active = null;
dependencies.onStreamEnd?.();
}); });
active = { active = {

View File

@@ -1,4 +1,5 @@
import { createRequire } from "node:module"; import { createRequire } from "node:module";
const require = createRequire(import.meta.url); 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
@@ -43,4 +44,5 @@ Module.prototype.require = function (id: string) {
}; };
console.log("[mock] node-crc has been mocked globally for ESM."); console.log("[mock] node-crc has been mocked globally for ESM.");
export {}; export {};

View File

@@ -42,7 +42,12 @@ async function processAnalysisRequest({
} }
} catch (dbError) { } catch (dbError) {
const msg = dbError instanceof Error ? dbError.message : String(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]; const firstMessage = messages[0];

View File

@@ -42,7 +42,8 @@ export function parseModerationResponse(
parsed = JSON.parse(candidate); parsed = JSON.parse(candidate);
} catch (error) { } catch (error) {
// If full substring fails, try scanning backwards from the last } // 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--) { for (let i = endIdx - 1; i > startIdx; i--) {
if (content[i] === "}") { if (content[i] === "}") {
@@ -50,7 +51,10 @@ export function parseModerationResponse(
parsed = JSON.parse(content.substring(startIdx, i + 1)); parsed = JSON.parse(content.substring(startIdx, i + 1));
break; break;
} catch (innerError) { } catch (innerError) {
lastError = innerError instanceof Error ? innerError : new Error(String(innerError)); lastError =
innerError instanceof Error
? innerError
: new Error(String(innerError));
continue; continue;
} }
} }
@@ -109,7 +113,10 @@ export function parseModerationResponse(
} }
if (foundIds.has(finalId)) { if (foundIds.has(finalId)) {
log.warn({ duplicateId: finalId }, "Skipping duplicate/rounded message_id"); log.warn(
{ duplicateId: finalId },
"Skipping duplicate/rounded message_id",
);
return null; return null;
} }

View File

@@ -30,51 +30,66 @@ export function createMediaRoutes(
} }
}; };
router.get("/media/status", (_req: Request, res: Response, next: NextFunction) => { router.get(
try { "/media/status",
res.json(controller.getState()); (_req: Request, res: Response, next: NextFunction) => {
} catch (error) { try {
next(error); 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,
);
} }
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) => { router.post(
try { "/media/skip",
res.json(await controller.skip()); adminAuth,
} catch (error) { async (_req: Request, res: Response, next: NextFunction) => {
next(error); try {
} res.json(await controller.skip());
}); } catch (error) {
next(error);
}
},
);
router.post("/media/stop", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { router.post(
try { "/media/stop",
res.json(await controller.stop()); adminAuth,
} catch (error) { async (_req: Request, res: Response, next: NextFunction) => {
next(error); try {
} res.json(await controller.stop());
}); } catch (error) {
next(error);
}
},
);
return router; return router;
} }

View File

@@ -71,93 +71,111 @@ 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: Request, res: Response, next: NextFunction) => { router.get(
try { "/guilds/:guildId/voice-channels",
const { guildId } = req.params; async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) { if (!guildId) {
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 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 // GET /api/guilds/:guildId/channels - List text channels in a guild
router.get("/guilds/:guildId/channels", async (req: Request, res: Response, next: NextFunction) => { router.get(
try { "/guilds/:guildId/channels",
const { guildId } = req.params; async (req: Request, res: Response, next: NextFunction) => {
try {
const { guildId } = req.params;
if (!guildId) { if (!guildId) {
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 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 // POST /api/connect - Connect to a voice channel
router.post("/connect", adminAuth, async (req: Request, res: Response, next: NextFunction) => { router.post(
try { "/connect",
const { guildId, channelId } = req.body as { adminAuth,
guildId?: string; async (req: Request, res: Response, next: NextFunction) => {
channelId?: string; try {
}; const { guildId, channelId } = req.body as {
guildId?: string;
channelId?: string;
};
if (!guildId || !channelId) { if (!guildId || !channelId) {
throw new AppError( throw new AppError(
"guildId and channelId are required", "guildId and channelId are required",
"MISSING_CONNECT_FIELDS", "MISSING_CONNECT_FIELDS",
400, 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 // POST /api/disconnect - Disconnect from voice channel
router.post("/disconnect", adminAuth, async (_req: Request, res: Response, next: NextFunction) => { router.post(
try { "/disconnect",
logger.info("Disconnecting from voice channel"); 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 // Update UI state and broadcast to connected clients
if (patchSharedUIState && broadcaster) { if (patchSharedUIState && broadcaster) {
const updatedState = patchSharedUIState({ const updatedState = patchSharedUIState({
selectedVoiceGuild: "", selectedVoiceGuild: "",
selectedVoiceChannel: "", selectedVoiceChannel: "",
}); });
broadcaster.uiState(updatedState); broadcaster.uiState(updatedState);
}
res.json(status);
} catch (error) {
next(error);
} }
},
res.json(status); );
} catch (error) {
next(error);
}
});
return router; return router;
} }

View File

@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import { Streamer } from "@dank074/discord-video-stream"; import { Streamer } from "@dank074/discord-video-stream";
import { AudioPlayerStatus } from "@discordjs/voice"; import { AudioPlayerStatus } from "@discordjs/voice";
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import { config } from "./config";
import express, { import express, {
type NextFunction, type NextFunction,
type Request, type Request,
@@ -14,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";
@@ -122,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"
@@ -189,6 +191,8 @@ export async function startWebserver(
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({

View File

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