feat(gateway): add manual video-watch command for selfbot screen-share capture

A selfbot (user token) cannot auto-detect other members' camera/share
(no VOICE_STATE_UPDATE for others, 403 on member fetch). The only
selfbot-viable path to capture another member's SCREEN SHARE is an
operator-initiated STREAM_WATCH (gateway op 20, not gated on bot-vs-user).

Add video:watch / video:unwatch Redis commands routed via the existing
command handler to startStreamWatch/stopStreamWatch, which then does the
DAVE handshake + per-burst MP4 segmentation + DB insert + Tele upload
(already implemented in streamWatchReceiver).

- new VideoHandler (command-handler/video.handler.ts)
- register video:watch / video:unwatch in handler-registry + CommandHandler
- command constants COMMAND_VIDEO_WATCH / COMMAND_VIDEO_UNWATCH
- resolve active voice channel from voice controller + client cache
- 8 unit tests (videoHandler.test.ts)
- biome fixes for pre-existing test import ordering

All green: typecheck, build, lint (174 files), 200 tests.
This commit is contained in:
asepharyana
2026-09-02 10:12:03 +07:00
parent 43594af3c8
commit e5304fde29
8 changed files with 393 additions and 2 deletions
@@ -18,6 +18,7 @@ import {
import { MediaHandler } from "./media.handler.js";
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
import { ModerationHandler } from "./moderation.handler.js";
import { VideoHandler } from "./video.handler.js";
import { VoiceHandler } from "./voice.handler.js";
const logger = createChildLogger("command-handler");
@@ -52,6 +53,7 @@ export class CommandHandler {
private mediaHandler!: MediaHandler;
private guildHandler!: GuildHandler;
private moderationHandler!: ModerationHandler;
private videoHandler!: VideoHandler;
constructor() {
// Dedicated Redis connection needed because: Redis requires a dedicated
@@ -86,6 +88,7 @@ export class CommandHandler {
this.mediaHandler = new MediaHandler();
this.guildHandler = new GuildHandler(client);
this.moderationHandler = new ModerationHandler(client);
this.videoHandler = new VideoHandler(client, voiceController);
// Wire the media status sink so MediaHandler can persist status on
// queue advances that happen outside a command (natural track end).
@@ -97,6 +100,7 @@ export class CommandHandler {
this.mediaHandler,
this.guildHandler,
this.moderationHandler,
this.videoHandler,
);
this.redisSub.on("message", (_channel, message) => {
@@ -7,6 +7,8 @@ import {
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
COMMAND_MODERATION_ACTION,
COMMAND_VIDEO_UNWATCH,
COMMAND_VIDEO_WATCH,
COMMAND_VOICE_CHANNELS,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
@@ -19,6 +21,7 @@ import {
import type { GuildHandler } from "./guild.handler.js";
import type { MediaHandler } from "./media.handler.js";
import type { ModerationHandler } from "./moderation.handler.js";
import type { VideoHandler } from "./video.handler.js";
import type { VoiceHandler } from "./voice.handler.js";
// ---------------------------------------------------------------------------
@@ -38,6 +41,7 @@ export function createHandlerRegistry(
mediaHandler: MediaHandler,
guildHandler: GuildHandler,
moderationHandler: ModerationHandler,
videoHandler: VideoHandler,
): Map<string, CommandHandlerFn> {
const registry = new Map<string, CommandHandlerFn>();
@@ -61,6 +65,14 @@ export function createHandlerRegistry(
voiceHandler.handleVoiceTransmitStop(cmd),
);
// Video watch commands
registry.set(COMMAND_VIDEO_WATCH, (cmd) =>
videoHandler.handleVideoWatch(cmd),
);
registry.set(COMMAND_VIDEO_UNWATCH, (cmd) =>
videoHandler.handleVideoUnwatch(cmd),
);
// Media commands
registry.set(COMMAND_MEDIA_QUEUE, (cmd) =>
mediaHandler.handleMediaQueue(cmd),
@@ -0,0 +1,145 @@
import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13";
import type { CommandMessage, CommandReply } from "../../shared/index.js";
import {
startStreamWatch,
stopStreamWatch,
} from "../voice-recording/streamWatchReceiver.js";
import type { VoiceController } from "../voice-recording/voiceController.js";
// ---------------------------------------------------------------------------
// VideoHandler — manual screen-share / camera watch (selfbot-viable capture)
// ---------------------------------------------------------------------------
//
// On a selfbot (user token) there is NO automatic detection of other members'
// video (no VOICE_STATE_UPDATE for them, no member list). The only way to
// capture another member's SCREEN SHARE is an operator-initiated STREAM_WATCH
// (gateway op 20, NOT gated on bot-vs-user) — which this handler exposes as a
// backend→gateway Redis command. `streamWatchReceiver` does the DAVE handshake,
// per-burst `.mp4` segmentation (mirroring voice silence split), DB insert and
// Tele upload exactly like the audio path.
//
// Camera-of-others stays impossible on a selfbot (DAVE decrypt is keyed per real
// userId and a selfbot cannot learn others' ids), so this command targets SCREEN
// SHARE. Watching the operator's OWN camera/share is a separate (parameterized)
// follow-up.
export class VideoHandler {
constructor(
private client: Client | null,
private voiceController: VoiceController | null,
) {}
setClient(client: Client): void {
this.client = client;
}
setVoiceController(vc: VoiceController): void {
this.voiceController = vc;
}
/**
* Resolve the active voice channel for a guild from the voice controller.
* Prefers the controller's live connection; falls back to the client cache.
*/
private async resolveChannel(
guildId: string,
requestedChannelId?: string,
): Promise<{ guild: Guild; channel: VoiceChannel } | null> {
const client = this.client;
if (!client) return null;
const guild =
client.guilds.cache.get(guildId) ??
(await client.guilds.fetch(guildId).catch(() => null));
if (!guild) return null;
// If an explicit channelId was given, use it.
const channelId =
requestedChannelId || this.voiceController?.getStatus()?.activeChannelId;
if (channelId) {
const ch = (guild.channels.cache.get(channelId) ??
(await guild.channels
.fetch(channelId)
.catch(() => null))) as VoiceChannel | null;
if (ch && ch.type === "GUILD_VOICE") return { guild, channel: ch };
}
// Fallback: any voice channel the account is currently in.
const voiceCh = Array.from(guild.channels.cache.values()).find(
(c): c is VoiceChannel =>
c.type === "GUILD_VOICE" && c.members?.has(client.user?.id ?? ""),
);
return voiceCh ? { guild, channel: voiceCh } : null;
}
async handleVideoWatch(cmd: CommandMessage): Promise<CommandReply<unknown>> {
if (!this.client) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
const userId = String(cmd.payload.userId ?? "");
const channelId = String(cmd.payload.channelId ?? "");
if (!guildId || !userId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId and userId are required",
};
}
const resolved = await this.resolveChannel(guildId, channelId || undefined);
if (!resolved) {
return {
id: cmd.id,
success: false,
data: null,
error: "No active voice channel to watch in this guild",
};
}
if (userId === this.client.user?.id) {
// Self-watch is intentionally not enabled by default (see file header).
return {
id: cmd.id,
success: false,
data: null,
error: "Watching the selfbot's own stream is not enabled",
};
}
await startStreamWatch(resolved.channel, userId);
return {
id: cmd.id,
success: true,
data: {
status: "requested",
guildId,
channelId: resolved.channel.id,
userId,
},
};
}
async handleVideoUnwatch(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
const guildId = String(cmd.payload.guildId ?? "");
const userId = String(cmd.payload.userId ?? "");
if (!guildId || !userId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId and userId are required",
};
}
stopStreamWatch(guildId, userId);
return {
id: cmd.id,
success: true,
data: { status: "stopped", guildId, userId },
};
}
}
@@ -57,6 +57,8 @@ export const COMMAND_VOICE_DISCONNECT_GUILD = "voice:disconnect:guild";
export const COMMAND_VOICE_CHANNELS = "voice:channels";
export const COMMAND_VOICE_TRANSMIT_START = "voice:transmit:start";
export const COMMAND_VOICE_TRANSMIT_STOP = "voice:transmit:stop";
export const COMMAND_VIDEO_WATCH = "video:watch";
export const COMMAND_VIDEO_UNWATCH = "video:unwatch";
export const COMMAND_GUILDS_LIST = "guilds:list";
export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels";
export const COMMAND_MEDIA_QUEUE = "media:queue";
@@ -34,7 +34,7 @@ describe("extractChunkText — streaming chunk text extraction", () => {
});
it('falls back to delta.reasoning — mimo via omniroute streams reasoning there with content:""', () => {
// Exact shape seen from omniroute → mimo-v2.5-free (2026-08-11):
// Exact shape seen from omniroute → mimo-v2.5-free (2026-08-11):
// {"choices":[{"delta":{"content":"","reasoning":"The user wants a","role":"assistant"},"finish_reason":null,...}]}
expect(
extractChunkText({
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VideoHandler } from "../src/modules/command-handler/video.handler.js";
import * as streamWatch from "../src/modules/voice-recording/streamWatchReceiver.js";
// ─── mocks ─────────────────────────────────────────────────────────────
function makeClient({ botId = "bot1", channelType = "GUILD_VOICE" } = {}) {
const activeChannel = {
id: "c1",
name: "Lounge",
type: channelType,
members: { has: vi.fn(() => false) },
};
const guild = {
id: "g1",
channels: {
cache: new Map([["c1", activeChannel]]),
fetch: vi.fn().mockResolvedValue(new Map()),
},
};
return {
user: { id: botId },
guilds: {
cache: new Map([["g1", guild]]),
fetch: vi.fn().mockResolvedValue(guild),
},
};
}
function makeVoiceController(activeChannelId = "c1") {
return {
getStatus: vi.fn().mockReturnValue({ activeChannelId, connected: true }),
};
}
function makeCmd(type: string, payload: Record<string, unknown>) {
return { id: "req-1", type, payload, replyChannel: "ch:1" };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(streamWatch, "startStreamWatch").mockResolvedValue();
vi.spyOn(streamWatch, "stopStreamWatch").mockImplementation(() => {});
});
describe("VideoHandler", () => {
it("rejects when gateway not initialized", async () => {
const handler = new VideoHandler(null, null);
const reply = await handler.handleVideoWatch(makeCmd("video:watch", {}));
expect(reply.success).toBe(false);
expect(reply.error).toContain("not initialized");
});
it("rejects when guildId/userId missing", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("guildId and userId are required");
});
it("rejects watching the selfbot's own stream", async () => {
const handler = new VideoHandler(
makeClient({ botId: "bot1" }),
makeVoiceController(),
);
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "bot1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("own stream");
});
it("returns error when no active voice channel found", async () => {
// Freeze the cache so no channel resolves.
const client = makeClient();
client.guilds.cache.get("g1").channels.cache.clear();
const handler = new VideoHandler(client, makeVoiceController());
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("No active voice channel");
});
it("calls startStreamWatch with resolved channel + userId and replies success", async () => {
const client = makeClient();
const handler = new VideoHandler(client, makeVoiceController("c1"));
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(true);
expect(reply.data).toMatchObject({
status: "requested",
guildId: "g1",
channelId: "c1",
userId: "user-x",
});
expect(streamWatch.startStreamWatch).toHaveBeenCalledWith(
expect.objectContaining({ id: "c1" }),
"user-x",
);
});
it("prefers an explicit channelId over the active one", async () => {
const client = makeClient();
// Add a second voice channel.
const guild = client.guilds.cache.get("g1");
guild.channels.cache.set("c9", {
id: "c9",
name: "Other",
type: "GUILD_VOICE",
members: { has: vi.fn(() => false) },
});
const handler = new VideoHandler(client, makeVoiceController("c1"));
const reply = await handler.handleVideoWatch(
makeCmd("video:watch", {
guildId: "g1",
userId: "user-x",
channelId: "c9",
}),
);
expect(reply.success).toBe(true);
expect(streamWatch.startStreamWatch).toHaveBeenCalledWith(
expect.objectContaining({ id: "c9" }),
"user-x",
);
});
it("handleVideoUnwatch calls stopStreamWatch", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoUnwatch(
makeCmd("video:unwatch", { guildId: "g1", userId: "user-x" }),
);
expect(reply.success).toBe(true);
expect(streamWatch.stopStreamWatch).toHaveBeenCalledWith("g1", "user-x");
});
it("handleVideoUnwatch rejects missing args", async () => {
const handler = new VideoHandler(makeClient(), makeVoiceController());
const reply = await handler.handleVideoUnwatch(
makeCmd("video:unwatch", { guildId: "g1" }),
);
expect(reply.success).toBe(false);
expect(reply.error).toContain("guildId and userId are required");
});
});
@@ -3,8 +3,8 @@ import { mkdtempSync, rmSync } from "node:fs";
import { access, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { SSRCMap } from "@discordjs/voice";
import { describe, expect, it } from "vitest";
import {
H264Depacketizer,
muxToMp4,