Files
GMW/services/discord-gateway/tests/videoHandler.test.ts
T
asepharyana e5304fde29 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.
2026-09-02 10:12:03 +07:00

148 lines
5.0 KiB
TypeScript

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");
});
});