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:
1 parent
43594af3c8
commit
e5304fde29
8 files changed
+393
-2
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user