Compare commits
3
Commits
43594af3c8
...
e6aa9af283
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6aa9af283 | ||
|
|
4c38d53972 | ||
|
|
e5304fde29 |
No files matched your search
@@ -0,0 +1,81 @@
|
||||
# Spec: Selfbot-Viable Video Capture — manual screen-share watch command (Phase D)
|
||||
|
||||
Status: PLANNED (not yet built)
|
||||
Date: 2026-09-02
|
||||
Author: Hermes
|
||||
Related: `.hermes/plans/2026-08-31_video-receive-phaseC-spec.md` (auto-receive, superseded
|
||||
for selfbot), `gmw-ops/references/selfbot-presence-detection-limits.md`,
|
||||
`gmw-ops/references/discord-voice-fork-video-receive.md`
|
||||
|
||||
## TL;DR — the decisive finding (verified live 2026-09-02)
|
||||
|
||||
User insists on keeping the **selfbot** (no bot-token migration). Live diagnostics prove
|
||||
a selfbot CANNOT auto-detect other members' camera/share because:
|
||||
- It never receives `VOICE_STATE_UPDATE` for other members (only its own).
|
||||
- `guild.members.fetch()` → 403, `GET /channels/{id}/voice-states` → 404.
|
||||
- No `GUILD_CREATE`, no `READY.broadcaster_user_ids` presence.
|
||||
- `scanExistingStreamers` + `handleVoiceStateUpdate` (the only two `startStreamWatch`
|
||||
triggers) are therefore both **dead on a selfbot**.
|
||||
- No manual watch command exists today, so even on-demand capture is impossible.
|
||||
|
||||
→ The ONE selfbot-viable path is a **manual, operator-initiated STREAM_WATCH** on a
|
||||
member known to be screen-sharing. Gateway op 20 (STREAM_WATCH) is **NOT gated on
|
||||
bot-vs-user**; the DAVE handshake to Ready+MLS was already verified live in earlier
|
||||
sessions. The receive/mux/segment/upload pipeline (`streamWatchReceiver.ts`) is already
|
||||
built and only lacks a real streamer to produce its first `.mp4`.
|
||||
|
||||
Camera-of-others is NOT viable on a selfbot even with `unknown-ssrc` fallback:
|
||||
`@discordjs/voice` `parsePacket` calls `daveSession.decrypt(packet, userId)` keyed per
|
||||
REAL userId (vendor fork dist/index.js:2143), so a fake id selects no MLS decryptor →
|
||||
garbage, not H264. (The uncommitted `unknown-ssrc` change was reverted this session.)
|
||||
|
||||
Selfbot CAN capture the OWNER's own video (its own VOICE_STATE_UPDATE + fork op12
|
||||
videoSSRC are attributable), but `videoRecorder.ts` hard-skips its own id — parameterized
|
||||
self-capture is a follow-up, not the default.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a **manual watch command** so an operator can say "record <member>'s screen share"
|
||||
and the gateway `startStreamWatch`s that member → DAVE watch → per-burst `.mp4` segments
|
||||
(mirroring voice silence split) → upload → DB `voice_recordings` → dashboard `<video>`.
|
||||
|
||||
This is the only form of OTHER-member video capture a selfbot can deliver, and it is
|
||||
genuinely buildable with the existing receive pipeline.
|
||||
|
||||
## Scope / files
|
||||
|
||||
Gateway (`services/discord-gateway`):
|
||||
- New command type `VIDEO_WATCH` + handler in `command-handler/` (dedicated
|
||||
`video.handler.ts`), routed via `createHandlerRegistry`.
|
||||
- Handler resolves a VoiceChannel (from persisted `voice_auto_reconnect` / active
|
||||
connections) + target memberId from the command payload, calls
|
||||
`startStreamWatch(channel, memberId)` (already exported).
|
||||
- Idempotent (startStreamWatch early-returns if a watch exists); a `VIDEO_UNWATCH`
|
||||
command calls `stopStreamWatch(guildId, userId)`.
|
||||
- Reply: success/failure via the standard `CommandReply` publish.
|
||||
|
||||
Backend (`services/backend`):
|
||||
- oRPC procedure (or the existing command bridge) that publishes a `VIDEO_WATCH`
|
||||
command to `backend:command` with `{ guildId, channelId, userId }`. Reuse the same
|
||||
bridge the FE already uses for voice commands.
|
||||
|
||||
Frontend (`services/frontend`):
|
||||
- A "Video Watch" control: pick a voice member + a "Record screen" button → calls the
|
||||
backend procedure. Shows live status (watching / recording / segments uploaded).
|
||||
|
||||
(Each layer optional independently; gateway alone gives a Redis-testable path.)
|
||||
|
||||
## Verification
|
||||
1. `pnpm typecheck` + `pnpm build` + `biome check src/` green in discord-gateway.
|
||||
2. Unit test: handler publishes reply + calls startStreamWatch with the right args
|
||||
(mock the module).
|
||||
3. Live: operator invokes `!videorec <member>` while that member screen-shares →
|
||||
journal shows `Sending STREAM_WATCH` → `STREAM_CREATE` → `DAVE watch READY` → `Video
|
||||
burst opened` → `Video muxed to mp4` → a `video-*.mp4` appears under
|
||||
`<recordingsDir>/<uid>/` and a `video-%` row lands in `voice_recordings`.
|
||||
4. `Build & Deploy (Nix)` CI green.
|
||||
|
||||
## Out of scope (documented dead ends on selfbot)
|
||||
- Auto camera/share capture of OTHER members (impossible at detection layer).
|
||||
- Camera-of-others via `unknown-ssrc` (DAVE decrypt needs real userId).
|
||||
- Bot-token migration (user declined).
|
||||
@@ -23,7 +23,10 @@ export const ResultItemSchema = z.object({
|
||||
message_id: z.union([z.string(), z.number()]).transform(String),
|
||||
status: z.enum(["clean", "warn", "flagged"]),
|
||||
flags: z.array(z.string()).optional(),
|
||||
score: z.number(),
|
||||
// score is optional — the LLM occasionally omits it (especially media
|
||||
// batches) and callers already null-coalesce (`result.score ?? 0`).
|
||||
// Requiring it hard-fails the whole batch parse for one missing field.
|
||||
score: z.number().optional(),
|
||||
analysis: z.string().nullable().optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
severity: SeveritySchema.optional(),
|
||||
|
||||
@@ -50,10 +50,7 @@ export interface QdrantVerdictPayload {
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
return (config.QDRANT_URL ?? "http://127.0.0.1:6333").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function collectionName(): string {
|
||||
@@ -166,6 +163,11 @@ export async function ensureQdrantCollection(
|
||||
},
|
||||
"Failed to ensure Qdrant collection",
|
||||
);
|
||||
// The memoised promise is permanently sticky: once it rejects (e.g. a
|
||||
// recreate aborted mid-way — DELETE done, PUT failed), every later call
|
||||
// returns the same rejected promise and the collection is never
|
||||
// re-created until process restart. Reset so the next call retries.
|
||||
ensureCollectionPromise = null;
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -151,10 +151,7 @@ export const configSchema = z
|
||||
.transform((v) => v === "true")
|
||||
.default(false),
|
||||
AI_LLM_API_KEY: z.string().optional(),
|
||||
AI_LLM_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("http://100.121.180.82:20128/api/v1"),
|
||||
AI_LLM_BASE_URL: z.string().url().default("http://127.0.0.1:4014/v1"),
|
||||
AI_LLM_MODEL: z.string().default("text"),
|
||||
// Vision uses the SAME router/base URL as text moderation
|
||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||
|
||||
@@ -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,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseModerationResponse } from "../src/modules/ai-moderation/moderationResponseParser.js";
|
||||
|
||||
const NO_SCORE_BODY = JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
message_id: "1544590005719146557",
|
||||
status: "clean",
|
||||
flags: [],
|
||||
severity: "none",
|
||||
confidence: 1.0,
|
||||
recommended_action: "none",
|
||||
policy_version: "default-2026-05-30",
|
||||
evidence: [],
|
||||
analysis: "Normal conversation, nothing concerning.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("parseModerationResponse — score optional", () => {
|
||||
it("accepts a media-batch response WITHOUT score (LLM omits it)", () => {
|
||||
// Before the fix this threw "Zod validation failed ... expected number,
|
||||
// received undefined" at results[0].score — the exact production error.
|
||||
let rows: ReturnType<typeof parseModerationResponse>;
|
||||
expect(() => {
|
||||
rows = parseModerationResponse(NO_SCORE_BODY, ["1544590005719146557"]);
|
||||
}).not.toThrow();
|
||||
const row = rows![0];
|
||||
expect(row.messageId).toBe("1544590005719146557");
|
||||
expect(row.status).toBe("clean");
|
||||
// clampScore(undefined, 0) coalesces to 0.
|
||||
expect(row.score).toBe(0);
|
||||
expect(row.flags).toEqual([]);
|
||||
expect(row.severity).toBe("none");
|
||||
});
|
||||
|
||||
it("still accepts a response WITH score", () => {
|
||||
const rows = parseModerationResponse(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
message_id: "1",
|
||||
status: "flagged",
|
||||
flags: ["violence"],
|
||||
score: 0.93,
|
||||
severity: "high",
|
||||
recommended_action: "delete",
|
||||
},
|
||||
],
|
||||
}),
|
||||
["1"],
|
||||
);
|
||||
expect(rows[0].messageId).toBe("1");
|
||||
expect(rows[0].status).toBe("flagged");
|
||||
expect(rows[0].score).toBeCloseTo(0.93, 5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The qdrant client reads config at import time — we only need the
|
||||
// reset-on-failure behavior, so mock global fetch and import fresh.
|
||||
import {
|
||||
ensureQdrantCollection,
|
||||
resetQdrantCollectionCache,
|
||||
} from "../src/modules/ai-moderation/qdrantClient.js";
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
text: async () => JSON.stringify(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("ensureQdrantCollection retry-on-failure", () => {
|
||||
beforeEach(() => {
|
||||
resetQdrantCollectionCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetQdrantCollectionCache();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns false on failure and does NOT get stuck — retries on next call", async () => {
|
||||
// First call: GET collection fails hard (not a 404) → ensure rejects.
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValueOnce(new Error("network down"))
|
||||
// Second call: GET returns a matching-size collection → success.
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
result: { config: { params: { vectors: { size: 3072 } } } },
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await ensureQdrantCollection(3072);
|
||||
expect(first).toBe(false);
|
||||
|
||||
// Without the fix, the second call returns the memoised rejected promise
|
||||
// and fetch is never called again. With the fix, it retries.
|
||||
const second = await ensureQdrantCollection(3072);
|
||||
expect(second).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recreates collection when vector size changes (DELETE + PUT)", async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
result: { config: { params: { vectors: { size: 2048 } } } },
|
||||
}),
|
||||
) // GET old size
|
||||
.mockResolvedValueOnce(jsonResponse({ result: true })) // DELETE
|
||||
.mockResolvedValueOnce(jsonResponse({ result: true })); // PUT
|
||||
|
||||
const ok = await ensureQdrantCollection(3072);
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const methods = fetchMock.mock.calls.map(
|
||||
(c) => (c[1] as RequestInit).method,
|
||||
);
|
||||
expect(methods).toEqual(["GET", "DELETE", "PUT"]);
|
||||
});
|
||||
|
||||
it("is idempotent when the collection already matches", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
result: { config: { params: { vectors: { size: 3072 } } } },
|
||||
}),
|
||||
);
|
||||
|
||||
const ok = await ensureQdrantCollection(3072);
|
||||
expect(ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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