Merge branch 'fix/remove-voice-recording'

This commit is contained in:
asepharyana
2026-09-23 22:13:00 +07:00
141 changed files with 410 additions and 25780 deletions
-3
View File
@@ -59,8 +59,6 @@ src/
| messages | Store & query Discord messages | `messages`, `ai_moderations`, `ai_moderation_flags` |
| moderation | Moderation actions & metrics | `ai_moderations`, `moderation_actions` |
| media | Media file management | `media_attachments` |
| voice | Live speakers + recordings | `voice_recordings` |
| recordings | Recordings API | `voice_recordings` |
| dashboard | Stats aggregation | Various (read-only) |
| knowledge | Semantic search | Qdrant vector DB |
| chatbot | AI chatbot with tools | `chatbot_history` |
@@ -91,7 +89,6 @@ The frontend fetches via `src/lib/api/server.ts` (SSR, server-side) and
```
discord:message:{created,updated,deleted,analyzed}
discord:attachment:{created,uploaded}
discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}
discord:analysis:queue_status
discord:reaction:{added,removed}
discord:thread:{created,deleted,updated}
-12
View File
@@ -35,16 +35,6 @@ services/backend/
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ ├── media/
│ │ │ ├── media.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ ├── voice/
│ │ │ ├── voice.service.ts
│ │ │ └── routes/
│ │ │ └── index.ts
│ │ │
│ │ └── health/
│ │ ├── health.schema.ts
│ │ ├── health.repository.ts
@@ -148,8 +138,6 @@ export const messageQuerySchema = z.object({
|--------|---------|--------|
| **messages** | Text message storage & retrieval | GET /api/messages, GET /api/messages/:channelId |
| **analytics** | Moderation statistics & trends | GET /api/analytics/overview, /daily-trend, /hourly-stats |
| **media** | Media file management | GET /api/media/list, POST /api/media/upload |
| **voice** | Voice recording management | GET /api/voice/recordings, POST /api/voice/connect |
| **health** | Service health checks | GET /api/health |
## Data Flow
-1
View File
@@ -20,7 +20,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@discordjs/voice": "^0.19.2",
"@orpc/server": "1.15.2",
"axios": "^1.20.0",
"dotenv": "^18.0.1",
+2 -208
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@orpc/server':
specifier: 1.15.2
version: 1.15.2(@opentelemetry/api@1.9.1)(ws@8.21.3)
@@ -132,10 +129,6 @@ packages:
cpu: [x64]
os: [win32]
'@discordjs/voice@0.19.2':
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
engines: {node: '>=22.12.0'}
'@emnapi/core@2.0.0-alpha.3':
resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==}
@@ -479,97 +472,6 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -857,9 +759,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
discord-api-types@0.38.52:
resolution: {integrity: sha512-uwe9EKfbjsmgWc2fdFjvDbj+dQqx3lp7wqDCmIha0jInuU+xeQjkCK9tMMn+p7RXfdVQORCInq4cD3U2ymDmyg==}
dotenv@18.0.1:
resolution: {integrity: sha512-0eR4m4D/jH5eaI3evo2ZqqMii5mrTCR12v12VChdie6O1gPHs7XAjzTep0CWc1Bd+oNx3vVfbi734dK4zghigw==}
engines: {node: '>=12'}
@@ -1323,23 +1222,6 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -1663,24 +1545,6 @@ snapshots:
'@biomejs/cli-win32-x64@2.5.14':
optional: true
'@discordjs/voice@0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@emnapi/core@2.0.0-alpha.3':
dependencies:
'@emnapi/wasi-threads': 2.0.1
@@ -1941,73 +1805,6 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@standard-schema/spec@1.1.0': {}
'@tybys/wasm-util@0.10.3':
@@ -2233,8 +2030,6 @@ snapshots:
detect-libc@2.1.2: {}
discord-api-types@0.38.52: {}
dotenv@18.0.1: {}
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(pg@8.23.0):
@@ -2618,8 +2413,6 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5: {}
process-warning@5.1.0: {}
prom-client@15.1.3:
@@ -2787,7 +2580,8 @@ snapshots:
toidentifier@1.0.1: {}
tslib@2.8.1: {}
tslib@2.8.1:
optional: true
tsx@4.23.15:
dependencies:
+9 -23
View File
@@ -6,7 +6,13 @@ import { describe, expect, it } from "vitest";
const BASE = process.env.API_BASE ?? "http://localhost:4001/api";
async function api(path: string, init?: RequestInit) {
/** Result of a JSON API call: status + parsed body (or null for 204/empty). */
interface ApiResult {
status: number;
body: Record<string, unknown> | null;
}
async function api(path: string, init?: RequestInit): Promise<ApiResult> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
@@ -23,7 +29,8 @@ describe("API Health", () => {
});
it("GET /metrics returns prometheus text", async () => {
const res = await fetch(`${BASE.replace("/api", "")}/api/metrics`);
const base = BASE.endsWith("/api") ? BASE.slice(0, -4) : BASE;
const res = await fetch(`${base}/api/metrics`);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("nodejs");
@@ -41,20 +48,6 @@ describe("API Dashboard", () => {
});
});
describe("API Recordings", () => {
it("GET /recordings returns items with pagination", async () => {
const { status, body } = await api("/recordings?limit=5");
expect(status).toBe(200);
expect(body).toHaveProperty("items");
expect(Array.isArray(body.items)).toBe(true);
if (body.items.length > 0) {
expect(body.items[0]).toHaveProperty("id");
expect(body.items[0]).toHaveProperty("username");
expect(body.items[0]).toHaveProperty("created_at");
}
});
});
describe("API Config", () => {
it("GET /config returns 200", async () => {
const { status } = await api("/config");
@@ -62,13 +55,6 @@ describe("API Config", () => {
});
});
describe("API Voice", () => {
it("GET /guilds returns 200", async () => {
const { status } = await api("/guilds");
expect(status).toBe(200);
});
});
describe("API Negative", () => {
it("GET /nonexistent returns 404", async () => {
const { status } = await api("/nonexistent");
@@ -1,13 +0,0 @@
import { z } from "zod";
export const mediaQueueSchema = z.object({
source: z.string().min(1, "source is required"),
mode: z.enum(["music", "screen"]).default("music"),
});
export const mediaLoopSchema = z.object({
loop: z.boolean().default(false),
});
export type MediaQueueInput = z.infer<typeof mediaQueueSchema>;
export type MediaLoopInput = z.infer<typeof mediaLoopSchema>;
@@ -1,171 +0,0 @@
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import {
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
MEDIA_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
const logger = createChildLogger("media.service");
// ---------------------------------------------------------------------------
// Types — match frontend exactly
// ---------------------------------------------------------------------------
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
/** null/absent when idle; "music" | "screen" while a track is active. */
activeMode?: "music" | "screen" | null;
musicVolume: number;
loop: boolean;
current: MediaItem | null;
queue: MediaItem[];
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_COMMAND_TIMEOUT_MS = 5000;
const DEFAULT_STATE: MediaState = {
playing: false,
activeMode: null,
musicVolume: 0.3,
loop: false,
current: null,
queue: [],
};
// ---------------------------------------------------------------------------
// Normalisation — handle both boolean (new) and string (legacy) playing values
// ---------------------------------------------------------------------------
function normalizeMediaState(raw: Record<string, unknown>): MediaState {
const rawPlaying = raw.playing;
const playing =
rawPlaying === true ||
rawPlaying === "playing" ||
rawPlaying === "buffering";
const mode = raw.activeMode;
const activeMode: "music" | "screen" | null =
mode === "music" || mode === "screen" ? mode : null;
return {
playing,
activeMode,
musicVolume: Number(raw.musicVolume ?? 0.3),
loop: Boolean(raw.loop ?? false),
current: (raw.current as MediaItem | null) ?? null,
queue: (raw.queue as MediaItem[]) ?? [],
};
}
type MediaReplyData = Record<string, unknown> | MediaState;
function _fromReply(data: MediaReplyData): MediaState {
return normalizeMediaState(data as Record<string, unknown>);
}
async function readStatusFallback(): Promise<MediaState> {
const cached = await readRedisStatus(MEDIA_STATUS_KEY);
return cached ? normalizeMediaState(cached) : DEFAULT_STATE;
}
// ---------------------------------------------------------------------------
// Service methods
// ---------------------------------------------------------------------------
/**
* Read media status from Redis key `media:status` set by discord-gateway.
*/
export async function getStatus(): Promise<MediaState> {
logger.debug("getStatus called");
return readStatusFallback();
}
/**
* Queue a media source via Redis command to discord-gateway.
*/
export async function queue(
source: string,
mode: "music" | "screen" = "music",
): Promise<MediaState> {
logger.info({ source, mode }, "queue called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_QUEUE,
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
// keep the field name aligned or playback silently no-ops.
{ url: source, mode },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"queue",
);
}
/**
* Skip current track via Redis command to discord-gateway.
*/
export async function skip(): Promise<MediaState> {
logger.info("skip called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_SKIP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"skip",
);
}
/**
* Stop playback via Redis command to discord-gateway.
*/
export async function stop(): Promise<MediaState> {
logger.info("stop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_STOP,
{},
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"stop",
);
}
/**
* Toggle loop mode (replay current track on natural end) via Redis command.
*/
export async function setLoop(loop: boolean): Promise<MediaState> {
logger.info({ loop }, "setLoop called");
return tryCommandThenFallback(
() =>
publishCommand<MediaState>(
COMMAND_MEDIA_LOOP,
{ loop },
DEFAULT_COMMAND_TIMEOUT_MS,
),
() => readStatusFallback(),
"setLoop",
);
}
@@ -51,10 +51,51 @@ export interface AttachmentResult {
uploaded_at: number | null;
}
type MessageRow = ReturnType<typeof mapMessageRow>;
export type { MessageRow };
/**
* Build the NULL-safe "exclude spam threads" condition. Non-thread messages
* (NULL thread_id) are always kept; thread messages are kept only when their
* thread is not in the configured exclusion list.
*/
function excludeSpamThreads(): SQL | undefined {
if (EXCLUDED_THREAD_IDS.length === 0) return undefined;
return or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
}
/** Normalize a raw attachment DB row to the API shape. */
function mapAttachmentRow(r: Record<string, unknown>): AttachmentResult {
return {
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
};
}
/** Select the first `limit + 1` rows so the caller can derive the next cursor. */
function cursorLimit(limit: number): number {
return limit + 1;
}
export class MessagesRepository {
async findMany(
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
async findMany(query: MessageQuery): Promise<PageResult<MessageRow>> {
const db = getDatabase();
const limit = query.limit ?? 50;
const conditions: SQL[] = [];
@@ -76,13 +117,8 @@ export class MessagesRepository {
}
// Exclude spam threads (NULL-safe: non-thread messages are kept)
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const where = conditions.length > 0 ? and(...conditions) : undefined;
const rows = await db
@@ -90,7 +126,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(where)
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -138,7 +174,7 @@ export class MessagesRepository {
async findByChannel(
channelId: string,
query: MessageQuery,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
): Promise<PageResult<MessageRow>> {
const db = getDatabase();
const limit = query.limit ?? 50;
const conditions: SQL[] = [eq(pgMessagesTable.channel_id, channelId)];
@@ -148,20 +184,15 @@ export class MessagesRepository {
}
// Exclude spam threads (NULL-safe)
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const rows = await db
.select()
.from(pgMessagesTable)
.where(and(...conditions))
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -181,7 +212,7 @@ export class MessagesRepository {
async *streamMany(
query: MessageQuery,
pageSize = 50,
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
): AsyncGenerator<MessageRow, void, unknown> {
const conditions: SQL[] = [];
if (query.guildId) {
@@ -196,13 +227,8 @@ export class MessagesRepository {
if (query.status) {
conditions.push(eq(pgMessagesTable.ai_status, query.status));
}
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const excludeThreads = excludeSpamThreads();
if (excludeThreads) conditions.push(excludeThreads);
const where = conditions.length > 0 ? and(...conditions) : undefined;
let cursor: string | undefined = query.cursor;
@@ -221,7 +247,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(pageWhere)
.orderBy(desc(pgMessagesTable.created_at))
.limit(pageSize + 1);
.limit(cursorLimit(pageSize));
if (rows.length === 0) return;
@@ -366,31 +392,30 @@ export class MessagesRepository {
async getImageMessages(
guildId: string,
limit: number = 50,
): Promise<PageResult<ReturnType<typeof mapMessageRow>>> {
): Promise<PageResult<MessageRow>> {
const db = getDatabase();
// Subquery: find distinct message_ids from attachments with image MIME type
const attachmentConditions: SQL[] = [
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
];
// Exclude spam threads (NULL-safe for non-thread messages)
const excludeThreads =
EXCLUDED_THREAD_IDS.length > 0
? or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
)
: undefined;
if (excludeThreads) attachmentConditions.push(excludeThreads);
const imageMsgIds = db
.select({ id: pgAttachmentsTable.message_id })
.from(pgAttachmentsTable)
.where(
and(
eq(pgAttachmentsTable.guild_id, guildId),
like(pgAttachmentsTable.type, "image/%"),
// Exclude spam threads (NULL-safe for non-thread messages)
...(EXCLUDED_THREAD_IDS.length > 0
? (() => {
const excludeThreads = or(
isNull(pgAttachmentsTable.thread_id),
notInArray(pgAttachmentsTable.thread_id, EXCLUDED_THREAD_IDS),
);
return excludeThreads ? [excludeThreads] : [];
})()
: []),
),
)
.where(and(...attachmentConditions))
.orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
// Fetch full message rows for those IDs
const rows = await db
@@ -398,7 +423,7 @@ export class MessagesRepository {
.from(pgMessagesTable)
.where(inArray(pgMessagesTable.id, imageMsgIds))
.orderBy(desc(pgMessagesTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows
.slice(0, limit)
@@ -433,28 +458,16 @@ export class MessagesRepository {
.from(pgAttachmentsTable)
.where(and(...conditions))
.orderBy(desc(pgAttachmentsTable.created_at))
.limit(limit + 1);
.limit(cursorLimit(limit));
const data = rows.map((r) => ({
id: String(r.id ?? ""),
message_id: String(r.message_id ?? ""),
guild_id: String(r.guild_id ?? ""),
channel_id: String(r.channel_id ?? ""),
thread_id: (r.thread_id as string | null) ?? null,
user_id: String(r.user_id ?? ""),
filename: String(r.filename ?? ""),
size: Number(r.size ?? 0),
type: String(r.type ?? ""),
discord_url: String(r.discord_url ?? ""),
uploaded_url: (r.uploaded_url as string | null) ?? null,
upload_status: String(r.upload_status ?? "pending"),
upload_error: (r.upload_error as string | null) ?? null,
created_at: Number(r.created_at ?? 0),
uploaded_at: (r.uploaded_at as number | null) ?? null,
}));
const data = rows.map((r) =>
mapAttachmentRow(r as Record<string, unknown>),
);
// nextCursor derives from the fetched-but-untrimmed overflow row (index
// `limit`), matching the other cursor-paginated queries.
const nextCursor =
data.length > limit ? String(data[limit].created_at) : null;
rows.length > limit ? String(rows[limit].created_at) : null;
const trimmed = data.slice(0, limit);
return { data: trimmed, nextCursor };
@@ -522,6 +535,44 @@ export class MessagesRepository {
username: r.username ? String(r.username) : null,
}));
}
/**
* Distinct guilds present in the message archive (drives the guild picker).
*/
async listGuilds(): Promise<
Array<{ id: string; name: string; icon: string | null }>
> {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
}
/**
* Text channels for a guild, derived from the message archive
* (drives the channel picker).
*/
async listTextChannels(
guildId: string,
): Promise<Array<{ id: string; name: string; type: "text" }>> {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
}
}
export const messagesRepository = new MessagesRepository();
@@ -2,14 +2,16 @@ import { config } from "@/shared/config/index";
import { NotFoundError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import { embedQuery } from "./embed.js";
import { messagesRepository } from "./messages.repository.js";
import { type MessageRow, messagesRepository } from "./messages.repository.js";
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
import { searchArchive } from "./qdrant.js";
const logger = createChildLogger("messages.service");
export class MessagesService {
async listMessages(query: MessageQuery) {
async listMessages(
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findMany>>> {
if (!query.channelId && !query.guildId) {
throw new ValidationError("Either channelId or guildId is required");
}
@@ -26,7 +28,10 @@ export class MessagesService {
return messagesRepository.streamMany(query, pageSize);
}
async getMessagesByChannel(channelId: string, query: MessageQuery) {
async getMessagesByChannel(
channelId: string,
query: MessageQuery,
): Promise<Awaited<ReturnType<typeof messagesRepository.findByChannel>>> {
if (!channelId) {
throw new ValidationError("channelId is required");
}
@@ -35,7 +40,14 @@ export class MessagesService {
return messagesRepository.findByChannel(channelId, query);
}
async getMessageById(id: string) {
async getMessageById(id: string): Promise<
NonNullable<Awaited<ReturnType<typeof messagesRepository.findById>>> & {
edit_count: number;
edit_history: Awaited<
ReturnType<typeof messagesRepository.getEditHistory>
>;
}
> {
if (!id) {
throw new ValidationError("message ID is required");
}
@@ -56,7 +68,12 @@ export class MessagesService {
};
}
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
async getAttachmentsByChannel(
channelId: string,
query: MessageQuery,
): Promise<
Awaited<ReturnType<typeof messagesRepository.getAttachmentsByChannel>>
> {
if (!channelId) {
throw new ValidationError("channelId is required");
}
@@ -111,14 +128,33 @@ export class MessagesService {
return { results, nextCursor: null };
}
async getActivity(days = 30) {
async getActivity(
days = 30,
): Promise<Awaited<ReturnType<typeof messagesRepository.getActivity>>> {
return messagesRepository.getActivity(days);
}
async getRecentEdits(limit = 50, channelId?: string) {
async getRecentEdits(
limit = 50,
channelId?: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.getRecentEdits>>> {
logger.debug({ limit, channelId }, "Getting recent message edits");
return messagesRepository.getRecentEdits(limit, channelId);
}
/** Distinct guilds present in the message archive (guild picker). */
async getGuilds(): Promise<
Awaited<ReturnType<typeof messagesRepository.listGuilds>>
> {
return messagesRepository.listGuilds();
}
/** Text channels for a guild (channel picker). */
async getTextChannels(
guildId: string,
): Promise<Awaited<ReturnType<typeof messagesRepository.listTextChannels>>> {
return messagesRepository.listTextChannels(guildId);
}
}
/** Shape returned to the frontend (text + rich metadata from the archive payload). */
@@ -1,184 +0,0 @@
import {
and,
desc,
eq,
gte,
ilike,
lt,
lte,
or,
type SQL,
sql,
} from "drizzle-orm";
import { getDatabase } from "../../shared/database/index.js";
import { pgVoiceRecordingsTable } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
const logger = createChildLogger("recordings.service");
export interface RecordingRow {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string | null;
upload_status: string;
upload_error: string | null;
created_at: number;
uploaded_at: number | null;
transcription: string | null;
}
export interface PaginatedRecordings {
items: RecordingRow[];
nextCursor: string | null;
hasMore: boolean;
}
export interface RecordingFilters {
channelId?: string;
userId?: string;
cursor?: string;
/** keyword search against transcription + username (ILIKE) */
q?: string;
/** created_at lower bound (epoch ms) */
startDate?: number;
/** created_at upper bound (epoch ms) */
endDate?: number;
}
export interface SpeakerSummary {
user_id: string;
username: string;
avatar_url: string | null;
clips: number;
/** summed MP3 bytes; ~128kbps → est duration in seconds */
est_duration_s: number;
words: number;
transcribed: number;
last_at: number;
}
export class RecordingsService {
async getRecent(
limit = 50,
filters: RecordingFilters = {},
): Promise<PaginatedRecordings> {
logger.info({ limit, filters }, "getRecent called");
const db = getDatabase();
const conditions: SQL[] = [];
if (filters.cursor) {
conditions.push(
lt(pgVoiceRecordingsTable.created_at, Number(filters.cursor)),
);
}
if (filters.channelId) {
conditions.push(eq(pgVoiceRecordingsTable.channel_id, filters.channelId));
}
if (filters.userId) {
conditions.push(eq(pgVoiceRecordingsTable.user_id, filters.userId));
}
if (filters.q) {
const like = `%${filters.q}%`;
const qOr = or(
ilike(pgVoiceRecordingsTable.transcription, like),
ilike(pgVoiceRecordingsTable.username, like),
);
if (qOr) conditions.push(qOr);
}
if (filters.startDate) {
conditions.push(
gte(pgVoiceRecordingsTable.created_at, filters.startDate),
);
}
if (filters.endDate) {
conditions.push(lte(pgVoiceRecordingsTable.created_at, filters.endDate));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const allRows = await db
.select({
id: pgVoiceRecordingsTable.id,
user_id: pgVoiceRecordingsTable.user_id,
username: pgVoiceRecordingsTable.username,
avatar_url: pgVoiceRecordingsTable.avatar_url,
guild_id: pgVoiceRecordingsTable.guild_id,
channel_id: pgVoiceRecordingsTable.channel_id,
channel_name: pgVoiceRecordingsTable.channel_name,
filename: pgVoiceRecordingsTable.filename,
size_bytes: pgVoiceRecordingsTable.size_bytes,
download_url: pgVoiceRecordingsTable.download_url,
upload_status: pgVoiceRecordingsTable.upload_status,
upload_error: pgVoiceRecordingsTable.upload_error,
created_at: pgVoiceRecordingsTable.created_at,
uploaded_at: pgVoiceRecordingsTable.uploaded_at,
transcription: pgVoiceRecordingsTable.transcription,
})
.from(pgVoiceRecordingsTable)
.where(where)
.orderBy(desc(pgVoiceRecordingsTable.created_at))
.limit(limit + 1);
const items = allRows.slice(0, limit) as unknown as RecordingRow[];
const hasMore = allRows.length > limit;
const nextCursor = hasMore
? String(items[items.length - 1]?.created_at)
: null;
return { items, nextCursor, hasMore };
}
async deleteById(id: string): Promise<void> {
const db = getDatabase();
await db
.delete(pgVoiceRecordingsTable)
.where(eq(pgVoiceRecordingsTable.id, id));
}
/**
* Speaker leaderboard: aggregate clips / est. duration / transcribed words
* per user. `est_duration_s` derives from summed MP3 bytes at the uploader's
* 128 kbps transcode rate; `words` sums transcription word counts.
*/
async getSummary(): Promise<SpeakerSummary[]> {
const db = getDatabase();
const t = pgVoiceRecordingsTable;
const rows = await db
.select({
user_id: t.user_id,
username: t.username,
avatar_url: t.avatar_url,
clips: sql<number>`count(*)::int`,
total_bytes: sql<number>`coalesce(sum(${t.size_bytes}), 0)`,
words: sql<number>`coalesce(sum(array_length(string_to_array(${t.transcription}, ' '), 1)), 0)::int`,
transcribed: sql<number>`count(${t.transcription})::int`,
last_at: sql<number>`max(${t.created_at})`,
})
.from(t)
.groupBy(t.user_id, t.username, t.avatar_url)
.orderBy(sql`clips desc, last_at desc`);
return rows.map((r) => ({
user_id: r.user_id,
username: r.username,
avatar_url: r.avatar_url,
clips: r.clips,
// 128 kbps = 128000 bps → bytes*8/128000 = seconds
est_duration_s: Math.round((r.total_bytes * 8) / 128000),
words: r.words,
transcribed: r.transcribed,
last_at: r.last_at,
}));
}
}
export const recordingsService = new RecordingsService();
@@ -1,111 +0,0 @@
/**
* Authoritative live-voice store.
*
* Single source of truth for who is present / speaking in voice. The backend
* WebSocket server is the one relay every frontend client connects to, so it
* is the correct place to aggregate the gateway's `voice_active_user` deltas
* into a shared snapshot. A late-joining browser must be able to see the same
* state as everyone else — this store makes that possible (seeded into the WS
* initial states and served via GET /api/voice/status).
*/
export interface LiveSpeaker {
userId: string;
username: string;
avatar?: string | null;
speaking: boolean;
/** Epoch ms of the most recent activity (start OR end of speech). */
lastActiveAt: number;
}
const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Speakers inactive for longer than this are auto-expired from the snapshot.
* This handles the case where the gateway disconnects abruptly and never
* sends `speaking: false` for active users.
*/
const SPEAKER_TTL_MS = 30_000;
/** Purge speakers that haven't been active recently. */
function purgeStale(): void {
const cutoff = Date.now() - SPEAKER_TTL_MS;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < cutoff) {
speakers.delete(id);
}
}
}
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them briefly
* for the activity timeline (until TTL expiry).
*/
export function recordSpeaker(data: {
userId: string;
username?: string;
avatar?: string | null;
speaking: boolean;
}): void {
const { userId, speaking } = data;
const existing = speakers.get(userId);
const speaker: LiveSpeaker = {
userId,
username: data.username ?? existing?.username ?? "Unknown",
avatar: data.avatar ?? existing?.avatar ?? null,
speaking,
lastActiveAt: Date.now(),
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < oldestTs) {
oldestTs = s.lastActiveAt;
oldestId = id;
}
}
if (oldestId) speakers.delete(oldestId);
else return;
}
speakers.set(userId, speaker);
}
/**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/**
* Mark ALL tracked speakers as not-speaking and purge stale ones.
* Called when the gateway disconnects from voice — ensures the authoritative
* snapshot doesn't carry ghost speakers.
*/
export function clearAllSpeakers(): void {
for (const [, s] of speakers) {
s.speaking = false;
}
purgeStale();
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
}
@@ -1,13 +0,0 @@
import { z } from "zod";
export const connectVoiceSchema = z.object({
guildId: z.string().min(1, "guildId is required"),
channelId: z.string().min(1, "channelId is required"),
});
export const voiceCommandSchema = z.object({
command: z.string().min(1, "command is required"),
});
export type ConnectVoiceInput = z.infer<typeof connectVoiceSchema>;
export type VoiceCommandInput = z.infer<typeof voiceCommandSchema>;
@@ -1,193 +0,0 @@
import { eq } from "drizzle-orm";
import {
createChildLogger,
tryCommandThenFallback,
} from "../../shared/commandHelper.js";
import { getDatabase } from "../../shared/database/index.js";
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
COMMAND_VOICE_CHANNELS,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
type CommandReply,
pgMessagesTable,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { publishCommand, readRedisStatus } from "../../shared/redis/index.js";
import { getActiveSpeakers, type LiveSpeaker } from "./live-speaker.js";
const logger = createChildLogger("voice.service");
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type: "voice" | "text";
/** Whether the selfbot account can actually join this voice channel. */
joinable?: boolean;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
export interface VoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers: LiveSpeaker[];
}
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
activeSpeakers: [],
};
/** Attach the live speaker snapshot to any voice status payload. */
function withActiveSpeakers<T extends Partial<VoiceStatus>>(
status: T,
): T & { activeSpeakers: LiveSpeaker[] } {
return { ...status, activeSpeakers: getActiveSpeakers() };
}
/**
* Wraps tryCommandThenFallback with a cleaner signature for use within this module.
* Attempts a Redis command first; on failure, falls back to the provided function.
*/
async function withFallback<T>(
commandFn: () => Promise<CommandReply<T> | null>,
fallbackFn: () => Promise<T>,
name: string,
): Promise<T> {
return tryCommandThenFallback(commandFn, fallbackFn, name);
}
function readVoiceStatusFallback(): Promise<VoiceStatus> {
return readRedisStatus(VOICE_STATUS_KEY).then((cached) =>
withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
),
);
}
/**
* Get guilds — query from discord-gateway via Redis command for real names.
* Falls back to database (distinct guild_id from messages) if gateway unreachable.
*/
export async function getGuilds(): Promise<Guild[]> {
logger.info("getGuilds called");
return withFallback(
() => publishCommand<Guild[]>(COMMAND_GUILDS_LIST, {}),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ guild_id: pgMessagesTable.guild_id })
.from(pgMessagesTable)
.orderBy(pgMessagesTable.guild_id);
return rows.map((row) => ({
id: String(row.guild_id ?? ""),
name: `Guild ${String(row.guild_id).slice(0, 8)}`,
icon: null,
}));
},
"getGuilds",
);
}
/**
* Get text channels — query from discord-gateway via Redis command for real names.
* Falls back to database if gateway unreachable.
*/
export async function getTextChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getTextChannels called");
return withFallback(
() => publishCommand<Channel[]>(COMMAND_GUILDS_TEXT_CHANNELS, { guildId }),
async () => {
const db = getDatabase();
const rows = await db
.selectDistinct({ channel_id: pgMessagesTable.channel_id })
.from(pgMessagesTable)
.where(eq(pgMessagesTable.guild_id, guildId))
.orderBy(pgMessagesTable.channel_id);
return rows.map((row) => ({
id: String(row.channel_id ?? ""),
name: `Channel ${String(row.channel_id).slice(0, 8)}`,
type: "text" as const,
}));
},
"getTextChannels",
);
}
/**
* Get voice channels — query from discord-gateway via Redis command.
*/
export async function getVoiceChannels(guildId: string): Promise<Channel[]> {
logger.info({ guildId }, "getVoiceChannels called");
const reply = await publishCommand<Channel[]>(COMMAND_VOICE_CHANNELS, {
guildId,
});
return reply?.success && reply.data ? reply.data : [];
}
/**
* Get current voice connection status from Redis cache set by discord-gateway.
*/
export async function getVoiceStatus(): Promise<VoiceStatus> {
logger.debug("getVoiceStatus called");
const cached = await readRedisStatus(VOICE_STATUS_KEY);
return withActiveSpeakers(
(cached as unknown as VoiceStatus) ?? DEFAULT_VOICE_STATUS,
);
}
/**
* Connect to a voice channel via Redis command to discord-gateway.
*/
export async function connectVoice(
guildId: string,
channelId: string,
): Promise<VoiceStatus> {
logger.info({ guildId, channelId }, "connectVoice called");
return withFallback(
() =>
publishCommand<VoiceStatus>(COMMAND_VOICE_CONNECT, {
guildId,
channelId,
}),
() => readVoiceStatusFallback(),
"connectVoice",
);
}
/**
* Disconnect from voice via Redis command to discord-gateway.
*/
export async function disconnectVoice(): Promise<VoiceStatus> {
logger.info("disconnectVoice called");
return withFallback(
() => publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT, {}),
() => readVoiceStatusFallback(),
"disconnectVoice",
);
}
+4 -106
View File
@@ -5,33 +5,13 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
import { chatbotService } from "../modules/chatbot/chatbot.service";
import { dashboardService } from "../modules/dashboard/dashboard.service";
import { knowledgeService } from "../modules/knowledge/knowledge.service";
import {
mediaLoopSchema,
mediaQueueSchema,
} from "../modules/media/media.schema";
import {
getStatus,
queue,
setLoop,
skip,
stop,
} from "../modules/media/media.service";
import {
messageQuerySchema,
semanticSearchSchema,
} from "../modules/messages/messages.schema";
import { messagesService } from "../modules/messages/messages.service";
import { moderationService } from "../modules/moderation/moderation.service";
import { recordingsService } from "../modules/recordings/recordings.service";
import { uiStateService } from "../modules/ui-state/ui-state.service";
import {
connectVoice,
disconnectVoice,
getGuilds,
getTextChannels,
getVoiceChannels,
getVoiceStatus,
} from "../modules/voice/voice.service";
import { config } from "../shared/config/index";
import { publishCommandNoReply } from "../shared/redis/index";
@@ -89,6 +69,10 @@ const dashboardRouter = {
// ── Messages ─────────────────────────────────────────────────────
const messagesRouter = {
guilds: os.handler(() => messagesService.getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => messagesService.getTextChannels(input.guildId)),
list: os
.input(messageQuerySchema)
.handler(({ input }) => messagesService.listMessages(input)),
@@ -238,86 +222,6 @@ const moderationRouter = {
.handler(({ input }) => moderationService.getCoverage(input.days)),
};
// ── Media ────────────────────────────────────────────────────────
const mediaRouter = {
status: os.handler(() => getStatus()),
queue: os.input(mediaQueueSchema).handler(async ({ input }) => {
await queue(input.source, input.mode);
return getStatus();
}),
skip: os.handler(async () => {
await skip();
return getStatus();
}),
stop: os.handler(async () => {
await stop();
return getStatus();
}),
loop: os.input(mediaLoopSchema).handler(async ({ input }) => {
await setLoop(input.loop);
return getStatus();
}),
};
// ── Voice ─────────────────────────────────────────────────────────
const voiceRouter = {
guilds: os.handler(() => getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getTextChannels(input.guildId)),
voiceChannels: os
.input(z.object({ guildId: z.string() }))
.handler(({ input }) => getVoiceChannels(input.guildId)),
status: os.handler(() => getVoiceStatus()),
connect: os
.input(z.object({ guildId: z.string(), channelId: z.string() }))
.handler(async ({ input }) => {
await connectVoice(input.guildId, input.channelId);
return getVoiceStatus();
}),
disconnect: os.handler(async () => {
await disconnectVoice();
return getVoiceStatus();
}),
command: os
.input(z.object({ command: z.string().min(1) }))
.handler(async ({ input }) => {
await publishCommandNoReply(input.command);
return { success: true, command: input.command };
}),
};
// ── Recordings ───────────────────────────────────────────────────
const recordingsRouter = {
list: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(50),
channelId: z.string().optional(),
userId: z.string().optional(),
cursor: z.string().optional(),
q: z.string().optional(),
startDate: z.coerce.number().int().optional(),
endDate: z.coerce.number().int().optional(),
}),
)
.handler(({ input }) =>
recordingsService.getRecent(input.limit, {
channelId: input.channelId,
userId: input.userId,
cursor: input.cursor,
q: input.q,
startDate: input.startDate,
endDate: input.endDate,
}),
),
delete: os.input(z.object({ id: z.string() })).handler(async ({ input }) => {
await recordingsService.deleteById(input.id);
return { ok: true };
}),
summary: os.handler(async () => recordingsService.getSummary()),
};
// ── Analysis (search) ──────────────────────────────────────────────
const analysisRouter = {
search: os
@@ -416,11 +320,8 @@ const configRouter = {
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
voiceGuildId: config.VOICE_GUILD_ID || null,
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
})),
};
@@ -438,9 +339,6 @@ export const appRouter = {
dashboard: dashboardRouter,
messages: messagesRouter,
moderation: moderationRouter,
media: mediaRouter,
voice: voiceRouter,
recordings: recordingsRouter,
analysis: analysisRouter,
chatbot: chatbotRouter,
config: configRouter,
+2 -45
View File
@@ -33,29 +33,6 @@ export const configSchema = z
.transform((v) => v.split(",").filter(Boolean))
.describe("Thread IDs to exclude from capture"),
// ── Legacy voice ─────────────────────────────────────────────────────
VOICE_GUILD_ID: z.string().min(1).optional(),
VOICE_CHANNEL_ID: z.string().min(1).optional(),
// ── Recording ────────────────────────────────────────────────────────
RECORDINGS_DIR: z.string().default("./recordings"),
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
// ── Decoder ──────────────────────────────────────────────────────────
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
// ── Audio ────────────────────────────────────────────────────────────
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce
.number()
.positive()
.default(3000),
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
AVATAR_SIZE: z.coerce.number().positive().default(64),
// ── Server ───────────────────────────────────────────────────────────
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
NODE_ENV: z
@@ -92,17 +69,8 @@ export const configSchema = z
// ── Redis ────────────────────────────────────────────────────────────
REDIS_URL: z.string().default("redis://localhost:6379"),
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
VOICE_PCM_WS_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(true),
BACKEND_WS_URL: z.string().default("ws://backend:3000/ws"),
BACKEND_WS_TOKEN: z.string().optional().default(""),
// ── Connection ───────────────────────────────────────────────────────
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
// ── Attachments ─────────────────────────────────────────────────────
@@ -203,13 +171,6 @@ export const configSchema = z
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
PISCINA_MEDIA_MAX_THREADS: z.coerce.number().int().positive().optional(),
// ── Voice Transcription ────────────────────────────────────────────────
AI_VOICE_TRANSCRIPTION_ENABLED: z
.string()
.optional()
.transform((v) => v === "true")
.default(false),
// ── OpenAI Moderation ───────────────────────────────────────────────
OPENAI_MODERATION_API_KEY: z.string().optional(),
OPENAI_MODERATION_BASE_URL: z
@@ -252,7 +213,6 @@ export const configSchema = z
// ── Retention ───────────────────────────────────────────────────────
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_ATTACHMENTS_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_VOICE_DAYS: z.coerce.number().int().min(0).default(0),
RETENTION_CLEANUP_INTERVAL_MS: z.coerce
.number()
.positive()
@@ -269,9 +229,8 @@ export const configSchema = z
.default(true),
})
.superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) {
// skip: AI analysis not enabled
} else if (!value.AI_LLM_API_KEY) {
// AI analysis requires credentials to run.
if (value.AI_ANALYSIS_ENABLED && !value.AI_LLM_API_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["AI_LLM_API_KEY"],
@@ -291,7 +250,6 @@ export const configSchema = z
export type AppConfig = z.infer<typeof configSchema> & {
EFFECTIVE_TEXT_GUILD_ID?: string;
EFFECTIVE_VOICE_GUILD_ID?: string;
EFFECTIVE_MONITOR_GUILD_IDS: string[];
};
@@ -301,7 +259,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return {
...parsed,
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
EFFECTIVE_MONITOR_GUILD_IDS:
parsed.MONITOR_GUILD_IDS.length > 0
? parsed.MONITOR_GUILD_IDS
@@ -24,9 +24,6 @@ export interface BroadcasterClient {
messageAnalyzed: (data: unknown) => void;
attachmentCreated: (data: unknown) => void;
attachmentUploaded: (data: unknown) => void;
voiceRecordingStarted: (data: unknown) => void;
voiceRecordingStopped: (data: unknown) => void;
voiceRecordingUploaded: (data: unknown) => void;
analysisQueueStatus: (data: unknown) => void;
}
@@ -102,17 +99,6 @@ export interface AttachmentRecord {
uploaded_at: number | null;
}
export interface VoiceSegmentRecord {
id: string;
user_id: string;
session_id: string;
guild_id: string;
channel_id: string;
filename: string;
duration_ms: number;
created_at: number;
}
export interface DashboardMessage {
id: string;
channel_id: string;
@@ -121,7 +107,7 @@ export interface DashboardMessage {
avatar_url: string | null;
content: string;
created_at: number;
type: "text" | "image" | "voice";
type: "text" | "image";
}
export interface MessageQuery {
@@ -154,23 +140,6 @@ export interface AnalysisResult {
evidence?: string[];
}
export interface VoiceRecordingUploadData {
id: string;
user_id: string;
username: string;
avatar_url: string | null;
guild_id: string | null;
channel_id: string | null;
channel_name: string | null;
filename: string;
size_bytes: number;
download_url: string;
upload_status: string;
created_at: number;
uploaded_at: number;
transcription?: string | null;
}
export interface AnalysisQueueStatus {
queuedConversations: number;
activeRequests: number;
@@ -221,7 +190,6 @@ export interface RetentionPolicy {
channel_id: string | null;
retention_days: number;
apply_to_media: boolean;
apply_to_voice: boolean;
enabled: boolean;
created_at: number;
updated_at: number;
@@ -15,11 +15,6 @@ export const DISCORD_MESSAGE_DELETED = "discord:message:deleted";
export const DISCORD_MESSAGE_ANALYZED = "discord:message:analyzed";
export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created";
export const DISCORD_ATTACHMENT_UPLOADED = "discord:attachment:uploaded";
export const DISCORD_VOICE_STARTED = "discord:voice:started";
export const DISCORD_VOICE_STOPPED = "discord:voice:stopped";
export const DISCORD_VOICE_UPLOADED = "discord:voice:uploaded";
export const DISCORD_VOICE_ACTIVE_USER = "discord:voice:active_user";
export const DISCORD_VOICE_PCM = "discord:voice:pcm";
export const DISCORD_ANALYSIS_QUEUE_STATUS = "discord:analysis:queue_status";
export const DISCORD_REACTION_ADDED = "discord:reaction:added";
export const DISCORD_REACTION_REMOVED = "discord:reaction:removed";
@@ -37,35 +32,15 @@ export const DISCORD_MODERATION_ACTION = "discord:moderation:action";
// ---------------------------------------------------------------------------
export const BACKEND_COMMAND = "backend:command";
export const BACKEND_VOICE_TRANSMIT = "backend:voice:transmit";
export const BACKEND_COMMAND_REPLY_PREFIX = "backend:command:reply:";
// ---------------------------------------------------------------------------
// Status keys (set by discord-gateway, read by backend via Redis GET)
// ---------------------------------------------------------------------------
export const VOICE_STATUS_KEY = "voice:status";
export const MEDIA_STATUS_KEY = "media:status";
// ---------------------------------------------------------------------------
// Command types (used as the `type` field in CommandMessage envelopes)
// ---------------------------------------------------------------------------
export const COMMAND_VOICE_CONNECT = "voice:connect";
export const COMMAND_VOICE_DISCONNECT = "voice:disconnect";
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_GUILDS_LIST = "guilds:list";
export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels";
export const COMMAND_MEDIA_QUEUE = "media:queue";
export const COMMAND_MEDIA_SKIP = "media:skip";
export const COMMAND_MEDIA_STOP = "media:stop";
export const COMMAND_MEDIA_VOLUME = "media:volume";
export const COMMAND_MEDIA_LOOP = "media:loop";
export const COMMAND_MODERATION_ACTION = "moderation:action";
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
// ---------------------------------------------------------------------------
// Event envelope — used by discord-gateway when publishing to Redis
@@ -111,13 +86,7 @@ export const DISCORD_CHANNEL_TO_WS_EVENT: Record<string, string> = {
[DISCORD_MESSAGE_ANALYZED]: "message_analyzed",
[DISCORD_ATTACHMENT_CREATED]: "attachment_created",
[DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded",
[DISCORD_VOICE_STARTED]: "voice_recording_started",
[DISCORD_VOICE_STOPPED]: "voice_recording_stopped",
[DISCORD_VOICE_UPLOADED]: "voice_recording_uploaded",
[DISCORD_ANALYSIS_QUEUE_STATUS]: "analysis_queue_status",
[DISCORD_VOICE_ACTIVE_USER]: "voice_active_user",
[DISCORD_VOICE_PCM]: "voice_pcm_data",
[DISCORD_VOICE_ANALYZED]: "voice_analyzed",
[DISCORD_REACTION_ADDED]: "reaction_added",
[DISCORD_REACTION_REMOVED]: "reaction_removed",
[DISCORD_THREAD_CREATED]: "thread_created",
+1 -8
View File
@@ -46,18 +46,11 @@ export function clearBroadcastFunctions(): void {
logger.info("Broadcast functions cleared");
}
function shouldLog(type: string): boolean {
if (!_enabled) return false;
// Avoid logging high-volume events
if (type === "voice_pcm_data") return false;
return true;
}
/**
* Broadcast a JSON event to all connected WebSocket clients.
*/
export function broadcastEvent(type: string, data: unknown): void {
if (shouldLog(type)) {
if (_enabled) {
logger.debug({ event: type }, "Broadcasting event");
}
_broadcast?.(type, data);
+12 -75
View File
@@ -1,23 +1,22 @@
import Redis from "ioredis";
import {
clearAllSpeakers,
recordSpeaker,
} from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} from "../shared/index.js";
import { DISCORD_CHANNEL_TO_WS_EVENT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
import { broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
/** Channels we subscribe to = all keys in DISCORD_CHANNEL_TO_WS_EVENT */
const SUBSCRIPTION_CHANNELS = Object.keys(DISCORD_CHANNEL_TO_WS_EVENT);
/** Shape of the DiscordGatewayEvent envelope published by the gateway. */
interface GatewayEnvelope {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
}
let subscriber: Redis | null = null;
function createSubscriber(): Redis {
@@ -31,14 +30,9 @@ function handleSubscriptionMessage(channel: string, message: string): void {
return;
}
let envelope: {
type?: string;
data?: unknown;
timestamp?: number;
source?: string;
};
let envelope: GatewayEnvelope;
try {
envelope = JSON.parse(message);
envelope = JSON.parse(message) as GatewayEnvelope;
} catch (err) {
logger.error({ channel, err }, "Failed to parse Redis message as JSON");
return;
@@ -49,67 +43,10 @@ function handleSubscriptionMessage(channel: string, message: string): void {
// We only want <actual payload>, not the full envelope.
const data = envelope.data !== undefined ? envelope.data : envelope;
// Voice PCM: decode base64 → binary broadcast instead of JSON
if (channel === DISCORD_VOICE_PCM) {
const pcmPayload = data as { userId?: string; pcm?: string };
if (pcmPayload?.pcm && pcmPayload?.userId) {
try {
const pcmBuffer = Buffer.from(pcmPayload.pcm, "base64");
// Prepend userId as 4-byte FNV-1a hash
const userIdHash = hashUserId(pcmPayload.userId);
const binary = Buffer.alloc(4 + pcmBuffer.length);
binary.writeUInt32LE(userIdHash, 0);
pcmBuffer.copy(binary, 4);
broadcastBinary(binary);
return;
} catch {
// fallback to JSON broadcast on error
}
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
// When the gateway stops voice recording (disconnects from voice channel),
// clear all speakers from the authoritative snapshot so frontends don't
// show ghost participants.
if (channel === DISCORD_VOICE_STOPPED) {
clearAllSpeakers();
logger.info("Voice recording stopped — cleared all live speakers");
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
/** Simple 32-bit FNV-1a hash for userId → 4-byte identifier */
function hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export async function startRedisBridge(): Promise<void> {
if (!config.REDIS_URL) {
logger.info("Redis not configured, skipping Redis bridge");
+36 -168
View File
@@ -1,9 +1,8 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws";
import type { MessageQuery } from "../modules/messages/messages.schema.js";
import { messagesService } from "../modules/messages/messages.service.js";
import { config } from "../shared/config/index.js";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { setBroadcastFunctions } from "./broadcast.js";
@@ -17,11 +16,17 @@ interface BroadcastEvent {
interface JsonMessage {
type: string;
buffer?: string;
command?: string;
payload?: Record<string, unknown>;
}
/** Payload accepted by the `stream_messages` JSON command. */
interface StreamMessagesPayload {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
}
// Track the active WebSocket server for lifecycle management
let _wss: WebSocketServer | null = null;
@@ -54,36 +59,6 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) {
logger.warn({ err }, "Failed to send initial ui_state");
}
// Send initial media state
try {
const { getStatus } = await import("../modules/media/media.service.js");
const mediaState = await getStatus();
ws.send(
JSON.stringify({
type: "media_state",
state: mediaState,
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
}
export function closeWebSocketServer(): void {
@@ -94,9 +69,7 @@ export function closeWebSocketServer(): void {
}
export function createWebSocketServer(server: Server): WebSocketServer {
// Separate tracking: gateway sends PCM → forwarded to frontend only
const frontendClients = new Set<WebSocket>();
const gatewayClients = new Set<WebSocket>();
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
_wss = wss;
@@ -115,43 +88,9 @@ export function createWebSocketServer(server: Server): WebSocketServer {
// Map-based dispatcher for JSON WebSocket message types
const jsonHandlers = new Map<string, MessageHandler>();
jsonHandlers.set("voice_transmit", async (_ws, message) => {
if (!message.buffer) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
await publisher.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({ type: "pcm", buffer: message.buffer }),
);
});
jsonHandlers.set("voice_command", async (_ws, message) => {
if (!message.command) return;
const { getCommandPublisher } = await import("../shared/redis/index.js");
const publisher = getCommandPublisher();
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
await publisher.publish(
BACKEND_COMMAND,
JSON.stringify({
id: commandId,
type: message.command,
payload: message.payload ?? {},
replyChannel: `reply:${commandId}`,
}),
);
});
// Stream historical messages one-by-one over WS (no 50-row batch).
// The frontend requests it once per channel switch; the backend emits one
// `message_snapshot` frame per message so the UI renders progressively.
jsonHandlers.set("stream_messages", async (ws, message) => {
if (ws.readyState !== WebSocket.OPEN) return;
const payload = (message.payload ?? {}) as {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
};
const payload = (message.payload ?? {}) as StreamMessagesPayload;
const guildId = payload.guildId;
const channelId = payload.channelId;
if (!guildId && !channelId) {
@@ -162,6 +101,17 @@ export function createWebSocketServer(server: Server): WebSocketServer {
const pageSize = 50; // internal DB page size; still emitted one frame at a time
const maxFrames = Math.min(payload.limit ?? 200, 500);
/** Send the end-of-stream frame, reporting sent count + next cursor. */
function sendEnd(data: Record<string, unknown>): void {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data,
}),
);
}
let sent = 0;
let nextCursor: string | null = null;
try {
@@ -170,7 +120,7 @@ export function createWebSocketServer(server: Server): WebSocketServer {
guildId,
channelId,
cursor: payload.cursor,
} as never,
} as MessageQuery,
pageSize,
)) {
if (ws.readyState !== WebSocket.OPEN) break;
@@ -187,95 +137,22 @@ export function createWebSocketServer(server: Server): WebSocketServer {
sent++;
if (sent >= maxFrames) break;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor },
}),
);
}
sendEnd({ sent, nextCursor });
} catch (err) {
logger.error({ err }, "stream_messages failed");
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor, error: true },
}),
);
}
sendEnd({ sent, nextCursor, error: true });
}
});
wss.on("connection", (ws: WebSocket, req) => {
// Parse auth token from query string
const rawUrl = req.url ?? "/";
let isGateway = false;
try {
const url = new URL(rawUrl, "http://localhost");
const token = url.searchParams.get("token");
isGateway =
token !== null &&
config.BACKEND_WS_TOKEN !== "" &&
token === config.BACKEND_WS_TOKEN;
} catch {
// Malformed URL — treat as frontend
}
if (isGateway) {
gatewayClients.add(ws);
logger.info("Discord gateway WebSocket client authenticated");
// Gateway doesn't need initial states
} else {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui, media) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
}
wss.on("connection", (ws: WebSocket) => {
frontendClients.add(ws);
logger.info(`Frontend client connected (${frontendClients.size} total)`);
// Send initial states (user, ui) — fire-and-forget
sendInitialStates(ws).catch((err) =>
logger.error({ err }, "sendInitialStates failed"),
);
ws.on("message", (data: Buffer) => {
// Gateway PCM forward — broadcast raw binary to frontend clients only
if (isGateway && Buffer.isBuffer(data)) {
broadcastBinary(data);
return;
}
// Handle binary PCM from browser (FE→Discord transmit)
// Format: 4-byte magic "PCM\0" + raw PCM Int16 LE
if (
Buffer.isBuffer(data) &&
data.length > 4 &&
data[0] === 0x50 && // 'P'
data[1] === 0x43 && // 'C'
data[2] === 0x4d && // 'M'
data[3] === 0x00 // '\0'
) {
const pcmBuffer = data.subarray(4);
const base64 = pcmBuffer.toString("base64");
import("../shared/redis/index.js").then(({ getCommandPublisher }) => {
const publisher = getCommandPublisher();
publisher
.publish(
BACKEND_VOICE_TRANSMIT,
JSON.stringify({
type: "pcm",
buffer: base64,
}),
)
.catch((err: Error) => {
logger.error(
{ err },
"Failed to publish voice transmit to Redis",
);
});
});
return;
}
// Handle JSON messages from browser
if (
typeof data === "string" ||
@@ -296,24 +173,15 @@ export function createWebSocketServer(server: Server): WebSocketServer {
});
ws.on("close", () => {
if (isGateway) {
gatewayClients.delete(ws);
logger.info("Discord gateway WebSocket disconnected");
} else {
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
}
frontendClients.delete(ws);
logger.info(
`Frontend client disconnected (${frontendClients.size} total)`,
);
});
ws.on("error", (err: Error) => {
logger.error({ err }, "WebSocket client error");
if (isGateway) {
gatewayClients.delete(ws);
} else {
frontendClients.delete(ws);
}
frontendClients.delete(ws);
});
});
+1 -25
View File
@@ -36,9 +36,6 @@ src/
├── modules/
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
│ ├── message-capture/ # Discord event listeners + DB store
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
│ ├── attachment-upload/ # Download + sharp resize + upload
│ ├── event-broadcaster/ # Redis pub/sub publisher
│ ├── command-handler/ # Backend→gateway Redis commands
@@ -76,24 +73,6 @@ textBatchProcessor.ts mediaBatchProcessor.ts llmClient.ts
- Piscina: text pool (4 threads) + media pool (2 threads)
- **Each worker thread has its own pg Pool** (min 0, grows to `POSTGRES_POOL_MAX`)
## Voice recording pipeline
```
receiver.speaking "start" → speakingHandler(userId)
→ collectUserMetadata → receiver.subscribe → PacketFilter → oggPacketStream
→ SegmentManager.open → OggLogicalBitstream → .ogg file
→ data: rotateIfNeeded + decoder.write
→ end: finalizeSegment → upload + transcribe
```
Key files:
- `voiceController.ts` — connect/disconnect/list
- `recorder.ts` — orchestration
- `recorder/segment.ts` — segment rotation
- `recorder/sessionRecording.ts` — session management
- `recorder/uploader.ts` — upload to storage
- `voiceTranscriber.ts` — Whisper transcription (if enabled)
## Module: message-capture
- `messageCapture.ts` — Discord event listeners (messageCreate/Update/Delete)
@@ -107,7 +86,7 @@ Key files:
See `src/shared/redis-channels.ts` for canonical names. Examples:
```
discord:message:created, discord:voice:active_user, discord:attachment:uploaded
discord:message:created, discord:moderation:action, discord:attachment:uploaded
```
## Config (env vars)
@@ -120,7 +99,6 @@ All validated via Zod in `shared/config/index.ts`. Critical:
- `REDIS_URL` — pub/sub to backend
- `AI_ANALYSIS_ENABLED` — master toggle for AI moderation
- `AI_LLM_BASE_URL` / `AI_LLM_API_KEY` — LLM router
- `AI_VOICE_TRANSCRIPTION_ENABLED` — toggle Whisper transcription
- `PISCINA_MAX_THREADS` / `PISCINA_MEDIA_MAX_THREADS` — worker pool sizing
## Concurrency model
@@ -143,8 +121,6 @@ All validated via Zod in `shared/config/index.ts`. Critical:
- **Piscina pool isolation**: worker threads are NOT the main thread. Cannot
share state via module-level variables. Use DB or Redis for cross-thread state.
- **AfterSilence race**: `@discordjs/voice` AfterSilence can fail to emit "end"
on disconnect. Always have a watchdog/timeout.
- **Cache eviction**: LRU caches (user metadata, term glossary) evict at max size.
Don't assume cache hit after eviction.
- **Circuit breaker**: per-conversation CB opens after repeated failures.
+5 -9
View File
@@ -1,7 +1,7 @@
# Discord Gateway — Architecture
Pure event-driven microservice (no HTTP server). Captures Discord
messages/voice/attachments/reactions/threads/presence, runs LLM-based AI
messages/attachments/reactions/threads/presence, runs LLM-based AI
moderation, and publishes everything to Redis pub/sub for the backend to
consume. The backend serves the HTTP/WS API to the frontend.
@@ -24,9 +24,9 @@ services/discord-gateway/
│ │ ├── config/ # Zod-validated env (index.ts = schema+loader)
│ │ ├── database/ # Drizzle ORM + pg Pool + migrations
│ │ │ ├── init.ts drizzle.ts pool.ts migrate.ts migrateCli.ts
│ │ │ └── schema/ # messages, cache, voice, analytics, meta
│ │ │ └── schema/ # messages, cache, meta, analytics
│ │ ├── logger/ # pino wrapper + createChildLogger()
│ │ ├── errors/ # AppError / ConfigError / AudioError ...
│ │ ├── errors/ # AppError / ConfigError ...
│ │ ├── utils/ # retry, pagination
│ │ ├── discord/clientOptions.ts # discord.js-selfbot-v13 client options
│ │ ├── uploader.ts # Shared attachment upload helper
@@ -35,9 +35,6 @@ services/discord-gateway/
│ └── modules/
│ ├── message-capture/ # Discord event listeners + DB store
│ ├── ai-moderation/ # LLM moderation pipeline (see below)
│ ├── voice-recording/ # Voice connect + Opus→OGG recording
│ │ └── recorder/ # decoder, segment, session, uploader, oggCrc
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket (bypasses Redis)
│ ├── attachment-upload/ # Download + (sharp) resize + upload
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
│ ├── command-handler/ # Redis-subscribed backend→gateway commands
@@ -102,7 +99,6 @@ grows on demand up to `POSTGRES_POOL_MAX`.
`discord:message:{created,updated,deleted,analyzed}`,
`discord:attachment:{created,uploaded}`,
`discord:voice:{started,stopped,uploaded,active_user,pcm,analyzed}`,
`discord:analysis:queue_status`,
`discord:reaction:{added,removed}`,
`discord:thread:{created,deleted,updated}`,
@@ -124,8 +120,8 @@ See `src/shared/redis-channels.ts` for the canonical names.
`SIGINT`/`SIGTERM` (and uncaught transient stream errors: EPIPE / ECONNRESET /
ERR_STREAM_DESTROYED / ERR_STREAM_WRITE_AFTER_END are treated as non-fatal):
stop metrics → stop muxer → disconnect voice → close PCM WS → close Redis →
close command handler → close DB → destroy client → exit.
stop metrics → close event broadcaster (Redis) → close command handler →
close DB → destroy client → exit.
## Observability
+1 -8
View File
@@ -18,8 +18,6 @@ services/discord-gateway/
│ └── modules/
│ ├── message-capture/ # Discord listeners + DB store + metadata
│ ├── ai-moderation/ # LLM moderation pipeline (largest module)
│ ├── voice-recording/ # Voice connect + Opus→OGG recording (+ recorder/)
│ ├── voice-pcm-ws/ # Real-time PCM → backend WebSocket
│ ├── attachment-upload/ # Download + sharp resize + upload
│ ├── event-broadcaster/ # RedisEventPublisher + EventBroadcaster
│ ├── command-handler/ # Backend→gateway Redis commands
@@ -51,11 +49,6 @@ semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts`
`embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus
`channelCultureStore.ts` / `userProfileStore.ts`.
### voice-recording
`voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration)
+ `recorder/` (decoder, segment, session, uploader, oggCrc). Publishes
`discord:voice:*` events. Real-time audio also streamed via `voice-pcm-ws`.
### attachment-upload
`attachmentUploader.ts` (download → upload to storage) + `imageResizer.ts`
(sharp resize). Emits `discord:attachment:*`.
@@ -72,7 +65,7 @@ per scrape; live pipeline gauges registered in `bootstrap.ts`.
- **config** — Zod schema in `shared/config/index.ts` (single source of truth).
- **database** — Drizzle ORM over `pg`; pool `min:0` (`shared/config`).
- **logger** — `pino` wrapper, `createChildLogger()` for context loggers.
- **errors** — `AppError` hierarchy (`ConfigError`, `AudioError`, …).
- **errors** — `AppError` hierarchy (`ConfigError`, …).
## Notes
- No HTTP server (other than the metrics endpoint). Pure event-driven.
+29 -73
View File
@@ -19,7 +19,6 @@ services/discord-gateway/
│ │ │ ├── schema.ts # Drizzle ORM schema
│ │ │ ├── drizzle.ts # PostgreSQL connection
│ │ │ ├── migrate.ts # Migration runner
│ │ │ └── voiceRecordingRepo.ts
│ │ ├── errors/
│ │ │ └── errors.ts # Custom error classes
│ │ ├── logger/
@@ -43,17 +42,6 @@ services/discord-gateway/
│ │ │ ├── indonesianTextNormalizer.ts # Service: Text normalization
│ │ │ ├── moderationPrompt.ts # Service: Prompt generation
│ │ │ └── index.ts # Module exports
│ │ ├── voice-recording/ # Controller-Service-Repository
│ │ │ ├── voiceController.ts # Controller: Voice connection mgmt
│ │ │ ├── recorder.ts # Service: Recording orchestration
│ │ │ ├── recorder/ # Sub-services
│ │ │ │ ├── audioStream.ts # Audio stream subscription
│ │ │ │ ├── decoder.ts # Opus decoding
│ │ │ │ ├── segment.ts # OGG segment rotation
│ │ │ │ ├── metadata.ts # Segment metadata
│ │ │ │ ├── sessionRecording.ts # Session management
│ │ │ │ └── uploader.ts # Segment upload
│ │ │ └── index.ts # Module exports
│ │ ├── attachment-upload/ # Controller-Service-Repository
│ │ │ ├── attachmentUploader.ts # Service: Upload orchestration
│ │ │ ├── imageResizer.ts # Service: Image resizing
@@ -85,11 +73,6 @@ Each feature module follows **Controller-Service-Repository** pattern:
- **Service** (`aiAnalysisWorker.ts`): Worker pool management
- **Service** (`indonesianTextNormalizer.ts`): Text preprocessing
**Voice Recording Module**:
- **Controller** (`voiceController.ts`): Voice channel connection management
- **Service** (`recorder.ts`): Recording orchestration
- **Sub-services** (`recorder/*`): Audio stream, decoding, segmentation, upload
**Attachment Upload Module**:
- **Service** (`attachmentUploader.ts`): Upload orchestration
- **Service** (`imageResizer.ts`): Image processing
@@ -107,9 +90,6 @@ Discord Events → Discord Gateway Service → Redis Pub/Sub → Backend Service
- discord:message:analyzed
- discord:attachment:created
- discord:attachment:uploaded
- discord:voice:started
- discord:voice:stopped
- discord:voice:uploaded
- discord:analysis:queue_status
```
@@ -146,20 +126,6 @@ Centralized, reusable components:
5. `eventBroadcaster.messageAnalyzed()` publishes results
6. Backend service receives and updates UI
### Voice Recording
1. `voiceController.connect()` joins voice channel
2. `recorder.ts` subscribes to user audio streams
3. For each speaking user:
- `audioStream.ts` subscribes to Opus packets
- `decoder.ts` decodes Opus to PCM
- `segment.ts` rotates OGG files (5s default)
- `metadata.ts` collects user info
4. On silence (3s):
- `sessionRecording.ts` finalizes segment
- `uploader.ts` uploads to storage
- `eventBroadcaster.voiceRecordingUploaded()` publishes
5. Backend service indexes recording
### Attachment Upload
1. `messageCapture.ts` detects attachments
2. `attachmentUploader.ts` downloads from Discord
@@ -194,21 +160,16 @@ Centralized, reusable components:
On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
1. Close PostgreSQL connection
2. Disconnect from voice channels
3. Close Redis connection
4. Destroy Discord client
5. Exit process (code 0 for clean, 1 for error)
2. Close Redis connection
3. Destroy Discord client
4. Exit process (code 0 for clean, 1 for error)
## Dependencies
**Core Discord**:
- `discord.js-selfbot-v13` — Discord client (selfbot variant)
- `@discordjs/voice` — Voice connection management
- `@discordjs/opus` — Native Opus codec
**Audio Processing**:
- `prism-media` — Opus encoding/decoding
- `opusscript` — Opus fallback for Node v26+
**Media Processing**:
- `sharp` — Image resizing
**Data & Config**:
@@ -269,7 +230,6 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
### Modules (28 files)
- `src/modules/message-capture/` (5 files)
- `src/modules/ai-moderation/` (6 files)
- `src/modules/voice-recording/` (9 files)
- `src/modules/attachment-upload/` (3 files)
- `src/modules/event-broadcaster/` (3 files)
@@ -289,7 +249,6 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
✅ Shared infrastructure migrated
✅ Message capture module migrated
✅ AI moderation module migrated
✅ Voice recording module migrated
✅ Attachment upload module migrated
✅ Event broadcaster module created (Redis pub/sub)
✅ Bootstrap and entry point created
@@ -304,36 +263,33 @@ On SIGINT/SIGTERM/uncaughtException/unhandledRejection:
```
┌─────────────────────────────────────────────────────────────────┐
│ Discord Gateway Service │
│ Discord Gateway Service │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ Message Capture │ │ AI Moderation │ │ Voice Record │ │
│ │ (Controller) │ │ (Controller) │ │ (Controller) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └──────┬───────┘ │
│ │ │ │ │
│ ├─────────────────────┼───────────────────┤ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Event Broadcaster (Redis Pub/Sub) │ │
│ │ - discord:message:created │ │
│ │ - discord:message:updated │ │
│ │ - discord:message:deleted │ │
│ │ - discord:message:analyzed │ │
│ │ - discord:attachment:created │ │
│ │ - discord:attachment:uploaded │ │
│ │ - discord:voice:started │ │
│ │ - discord:voice:stopped │ │
│ │ - discord:voice:uploaded │ │
│ │ - discord:analysis:queue_status │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
└───────────┼────────────────────────────────────────────────────┘
│
│ Redis Pub/Sub
│
▼
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ Message Capture │ │ AI Moderation │ │
│ │ (Controller) │ │ (Controller) │ │
│ └──────────────┬─────────────┘ └──────────────┬─────────────┘ │
│ │ │ │
│ ├───────────────────────────────┤ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Event Broadcaster (Redis Pub/Sub) │ │
│ │ - discord:message:created │ │
│ │ - discord:message:updated │ │
│ │ - discord:message:deleted │ │
│ │ - discord:message:analyzed │ │
│ │ - discord:attachment:created │ │
│ │ - discord:attachment:uploaded │ │
│ │ - discord:analysis:queue_status │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │ │
└────────────────────────────────┼────────────────────────────────┘
│
│ Redis Pub/Sub
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Backend Service │
│ (Subscribes to events, serves HTTP API, manages WebSocket) │
@@ -1 +0,0 @@
ALTER TABLE "voice_recordings" ADD COLUMN "transcription" text;
@@ -1,19 +0,0 @@
-- Voice auto-reconnect: persist desired voice state per guild.
-- The gateway rejoins the same channel after restart/reboot and after an
-- unexpected voice drop (kick / server move / voice server restart).
-- Idempotent: skips if the table already exists.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'voice_auto_reconnect'
) THEN
CREATE TABLE "voice_auto_reconnect" (
"guild_id" text PRIMARY KEY NOT NULL,
"channel_id" text NOT NULL,
"channel_name" text,
"connected_at" bigint NOT NULL,
"updated_at" bigint NOT NULL
);
END IF;
END $$;
@@ -79,13 +79,6 @@
"tag": "0010_add_reactions_and_edit_history",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1785600000000,
"tag": "0011_add_voice_transcription",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
@@ -141,13 +134,6 @@
"when": 1787830000000,
"tag": "0019_rename_server_name_to_server_nick",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1787860000000,
"tag": "0020_add_voice_auto_reconnect",
"breakpoints": true
}
]
}
+1 -7
View File
@@ -1,12 +1,11 @@
{
"name": "@bete/discord-gateway",
"version": "1.0.0",
"description": "Discord Gateway service - handles message capture, voice recording, and AI moderation",
"description": "Discord Gateway service - handles message capture and AI moderation",
"type": "module",
"main": "dist/index.js",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
"esbuild",
"sharp"
]
@@ -24,16 +23,12 @@
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
},
"dependencies": {
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "file:vendor/discord-voice-fork",
"@snazzah/davey": "^0.1.11",
"@typesafe-ai/sdk": "^0.6.0",
"axios": "^1.20.0",
"discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^18.0.0",
"drizzle-orm": "^0.45.2",
"ioredis": "^6.0.0",
"libsodium-wrappers": "^0.8.4",
"lru-cache": "^11.5.3",
"openai": "^7.20.0",
"opusscript": "^0.1.1",
@@ -42,7 +37,6 @@
"pg": "^8.23.0",
"pino": "^10.3.1",
"piscina": "^5.3.2",
"prism-media": "2.0.0-alpha.0",
"sharp": "^0.35.4",
"tiktoken": "^1.0.22",
"ws": "^8.21.3",
+74 -277
View File
@@ -8,15 +8,6 @@ importers:
.:
dependencies:
'@discordjs/opus':
specifier: ^0.10.0
version: 0.10.0(supports-color@7.2.0)
'@discordjs/voice':
specifier: file:vendor/discord-voice-fork
version: file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)
'@snazzah/davey':
specifier: ^0.1.11
version: 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@typesafe-ai/sdk':
specifier: ^0.6.0
version: 0.6.0
@@ -25,7 +16,7 @@ importers:
version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
discord.js-selfbot-v13:
specifier: ^3.7.1
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0)
version: 3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0)
dotenv:
specifier: ^18.0.0
version: 18.0.1
@@ -35,9 +26,6 @@ importers:
ioredis:
specifier: ^6.0.0
version: 6.0.0(supports-color@7.2.0)
libsodium-wrappers:
specifier: ^0.8.4
version: 0.8.4
lru-cache:
specifier: ^11.5.3
version: 11.5.3
@@ -62,9 +50,6 @@ importers:
piscina:
specifier: ^5.3.2
version: 5.3.2
prism-media:
specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@26.4.0)
@@ -186,19 +171,12 @@ packages:
resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==}
engines: {node: '>=18'}
'@discordjs/voice@file:vendor/discord-voice-fork':
resolution: {directory: vendor/discord-voice-fork, type: directory}
engines: {node: '>=22.12.0'}
'@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/core@1.11.3':
resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
@@ -208,9 +186,6 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@emnapi/wasi-threads@1.2.3':
resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==}
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.hirok.io'
@@ -1088,97 +1063,6 @@ packages:
resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==}
engines: {node: '>=v16'}
'@snazzah/davey-android-arm-eabi@0.1.12':
resolution: {integrity: sha512-6VC/an+Sx5dI5skb+90rYcIB1jhm48Rl0nDaw0UNT4bz1rMjpVfmmZqeocYXMq96IdbBMlE6OTKGcBm2C3gkQg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [android]
'@snazzah/davey-android-arm64@0.1.12':
resolution: {integrity: sha512-0Bwd03/JsTFhlPhF4q/LW0RxSzntFpQdhz+TBdFljYSg8IEyA38saPJeTNjpIgDfhAumPzvhCdfS6O5qT8yXDw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@snazzah/davey-darwin-arm64@0.1.12':
resolution: {integrity: sha512-lKMV6ITi9BQLt0fx/pAT7M8xcojVK7bryVJGdaW3bq8gABFslS3ti/KzrWabQvhpEV71FZe5mV0UcKHFVaTsZw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@snazzah/davey-darwin-x64@0.1.12':
resolution: {integrity: sha512-vXXc/eW/e3TQeb7VsdtrPqs3/22j0aSqiP1ZXmZtDjQRBwgSxwItWYa6sh5MELP2EHB2igNlGzB6Hc0XlbGi4g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@snazzah/davey-freebsd-x64@0.1.12':
resolution: {integrity: sha512-G1gas5HrC4Xp3mRY0+OeAqXS6fG2tRgBEc8gQh69Hw4YK9RV9mzQKcmoKMkBM72U1+2C+2u57dolnKKzwozByQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
resolution: {integrity: sha512-97Fujh82r2Ll7dPZeNuoZ3yKfsqycf3c93OWXOo/ThNL/18Onl2Ht4SIvpX6VHhfeS9bDpHJ1lHCaz1b/i5ocw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@snazzah/davey-linux-arm64-gnu@0.1.12':
resolution: {integrity: sha512-FWyAOv52cHKDM4BOsmImKKogHFvqNFoXmZcicNJbX3XpVl8Mas88ZoXQ+IA5V+qc9pNtAl1MbWwEZ9JrqAQtbg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-arm64-musl@0.1.12':
resolution: {integrity: sha512-ptRbLSQxtV6EjXppS5z7qaPDI0NRKhrkJYsTlAjEghmOvlAObozSCYYnMO6nbkt6Ab3+lWqyahClzcRNcD2ouw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@snazzah/davey-linux-x64-gnu@0.1.12':
resolution: {integrity: sha512-w86fZvhJn0ErOoAQHt2UbQ95V/cgwvfvQ4GlTPQLCzt58nn+rLlXLgPn90qYlSQrZxFW38rKXwqVOMbm9p+pwQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@snazzah/davey-linux-x64-musl@0.1.12':
resolution: {integrity: sha512-LLNnO+hfG41ymeI+O1YHo5/0h3aKaetUNLdkBwpdJsjoyKMXZaeCnB+aHNkkupJCMPmWS0g6iPMCHUOqZSBcTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@snazzah/davey-wasm32-wasi@0.1.12':
resolution: {integrity: sha512-MPKFuqYVkDFXheR7qmtEY4FWxQ/ADfgsCojQWHi13sibUqCTR9q2F1LqNn2i9IVh3sh1sxeg87fdFMCH63pl7g==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@snazzah/davey-win32-arm64-msvc@0.1.12':
resolution: {integrity: sha512-Uf4OYHyfbXpzyaOqIV8/h6kv166Qni5+Bxmc1E/ov4uhhKO8qXbOky8zbVOzu0U4cv5ll3s4IQ8jDAJkx/K75Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@snazzah/davey-win32-ia32-msvc@0.1.12':
resolution: {integrity: sha512-nRVbKTsb2ldcPI8D4BDA7P/UeiMEMvR+wYuUMp7H1pRD/3dF2hKo+MzUU+Pn78EhuYA3CWHItiAcS/GsPld67A==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@snazzah/davey-win32-x64-msvc@0.1.12':
resolution: {integrity: sha512-AgUA3itPDVkxQq7RIkgE1thCiWePwWjyfOZefBBxGIlMWpRUOSwF4vY9kNLnrScyJcFULdR+Zm8PwiwV8/RKnw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@snazzah/davey@0.1.12':
resolution: {integrity: sha512-V+NlX5931RwVamZhhEfZekMdcvXDKdMAmHW1AuGaykVQsNyBOq3bpmGpoKRBDCYgFWKIufJ0Dcg3m4cYhvUy6g==}
engines: {node: '>= 10'}
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -1601,9 +1485,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplex-child-process@1.0.1:
resolution: {integrity: sha512-tWbt4tyioDjyK5nh+qicbdvBvNjSXsTUF5zKUwSauuKPg1mokjwn/HezwfvWhh6hXoLdgetY+ZlzU/sMwUMJkg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1772,12 +1653,6 @@ packages:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
libsodium-wrappers@0.8.4:
resolution: {integrity: sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==}
libsodium@0.8.4:
resolution: {integrity: sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==}
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@@ -1978,9 +1853,6 @@ packages:
zod:
optional: true
opusscript@0.1.1:
resolution: {integrity: sha512-mL0fZZOUnXdZ78woRXp18lApwpp0lF5tozJOD1Wut0dgrA9WuQTgSels/CSmFleaAZrJi/nci5KOVtbuxeWoQA==}
otplib@12.0.1:
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
@@ -2108,9 +1980,6 @@ packages:
opusscript:
optional: true
prism-media@2.0.0-alpha.0:
resolution: {integrity: sha512-QL9rnO4xo0grgj7ptsA+AzSCYLirGWM4+ZcyboFmbkYHSgaXIESzHq/SXNizz2iHIfuM2og0cPhmSnTVMeFjKg==}
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
@@ -2539,6 +2408,7 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/opus@0.10.0(supports-color@7.2.0)':
dependencies:
@@ -2547,29 +2417,12 @@ snapshots:
transitivePeerDependencies:
- encoding
- supports-color
optional: true
'@discordjs/util@1.2.0':
dependencies:
discord-api-types: 0.38.52
'@discordjs/voice@file:vendor/discord-voice-fork(@discordjs/opus@0.10.0(supports-color@7.2.0))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@types/ws': 8.18.1
discord-api-types: 0.38.52
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
tslib: 2.8.1
ws: 8.21.3
transitivePeerDependencies:
- '@discordjs/opus'
- '@emnapi/core'
- '@emnapi/runtime'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@drizzle-team/brocli@0.10.2': {}
'@emnapi/core@1.11.1':
@@ -2578,12 +2431,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/core@1.11.3':
dependencies:
'@emnapi/wasi-threads': 1.2.3
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
@@ -2599,11 +2446,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2':
dependencies:
esbuild: 0.18.20
@@ -3032,13 +2874,6 @@ snapshots:
'@tybys/wasm-util': 0.10.3
optional: true
'@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@emnapi/core': 1.11.3
'@emnapi/runtime': 1.11.3
'@tybys/wasm-util': 0.10.3
optional: true
'@otplib/core@12.0.1': {}
'@otplib/plugin-crypto@12.0.1':
@@ -3124,73 +2959,6 @@ snapshots:
fast-deep-equal: 3.1.3
lodash: 4.18.1
'@snazzah/davey-android-arm-eabi@0.1.12':
optional: true
'@snazzah/davey-android-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-arm64@0.1.12':
optional: true
'@snazzah/davey-darwin-x64@0.1.12':
optional: true
'@snazzah/davey-freebsd-x64@0.1.12':
optional: true
'@snazzah/davey-linux-arm-gnueabihf@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-arm64-musl@0.1.12':
optional: true
'@snazzah/davey-linux-x64-gnu@0.1.12':
optional: true
'@snazzah/davey-linux-x64-musl@0.1.12':
optional: true
'@snazzah/davey-wasm32-wasi@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
dependencies:
'@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@snazzah/davey-win32-arm64-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-ia32-msvc@0.1.12':
optional: true
'@snazzah/davey-win32-x64-msvc@0.1.12':
optional: true
'@snazzah/davey@0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)':
optionalDependencies:
'@snazzah/davey-android-arm-eabi': 0.1.12
'@snazzah/davey-android-arm64': 0.1.12
'@snazzah/davey-darwin-arm64': 0.1.12
'@snazzah/davey-darwin-x64': 0.1.12
'@snazzah/davey-freebsd-x64': 0.1.12
'@snazzah/davey-linux-arm-gnueabihf': 0.1.12
'@snazzah/davey-linux-arm64-gnu': 0.1.12
'@snazzah/davey-linux-arm64-musl': 0.1.12
'@snazzah/davey-linux-x64-gnu': 0.1.12
'@snazzah/davey-linux-x64-musl': 0.1.12
'@snazzah/davey-wasm32-wasi': 0.1.12(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)
'@snazzah/davey-win32-arm64-msvc': 0.1.12
'@snazzah/davey-win32-ia32-msvc': 0.1.12
'@snazzah/davey-win32-x64-msvc': 0.1.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -3298,7 +3066,8 @@ snapshots:
'@vitest/spy@5.0.1': {}
abbrev@1.1.1: {}
abbrev@1.1.1:
optional: true
agent-base@6.0.2(supports-color@7.2.0):
dependencies:
@@ -3312,12 +3081,14 @@ snapshots:
dependencies:
color-convert: 2.0.1
aproba@2.1.0: {}
aproba@2.1.0:
optional: true
are-we-there-yet@2.0.0:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
optional: true
assertion-error@2.0.1: {}
@@ -3335,7 +3106,8 @@ snapshots:
- debug
- supports-color
balanced-match@1.0.2: {}
balanced-match@1.0.2:
optional: true
base64-js@1.5.1: {}
@@ -3343,6 +3115,7 @@ snapshots:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
optional: true
buffer-from@1.1.2: {}
@@ -3365,7 +3138,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
chownr@2.0.0: {}
chownr@2.0.0:
optional: true
cliui@6.0.0:
dependencies:
@@ -3381,7 +3155,8 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
color-support@1.1.3:
optional: true
combined-stream@1.0.8:
dependencies:
@@ -3389,9 +3164,11 @@ snapshots:
commander@14.0.3: {}
concat-map@0.0.1: {}
concat-map@0.0.1:
optional: true
console-control-strings@1.1.0: {}
console-control-strings@1.1.0:
optional: true
debug@4.4.0(supports-color@7.2.0):
dependencies:
@@ -3409,7 +3186,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
delegates@1.0.0:
optional: true
denque@2.1.0: {}
@@ -3419,7 +3197,7 @@ snapshots:
discord-api-types@0.38.52: {}
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)(supports-color@7.2.0):
discord.js-selfbot-v13@3.7.1(@discordjs/opus@0.10.0(supports-color@7.2.0))(supports-color@7.2.0):
dependencies:
'@discordjs/builders': 1.14.1
'@discordjs/collection': 2.1.1
@@ -3429,7 +3207,7 @@ snapshots:
fetch-cookie: 3.2.0
find-process: 2.1.1
otplib: 12.0.1
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1)
prism-media: 1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))
qrcode: 1.5.4
tough-cookie: 5.1.2
tree-kill: 1.2.2
@@ -3465,8 +3243,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplex-child-process@1.0.1: {}
emoji-regex@8.0.0: {}
es-define-property@1.0.1: {}
@@ -3612,8 +3388,10 @@ snapshots:
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fs.realpath@1.0.0: {}
fs.realpath@1.0.0:
optional: true
fsevents@2.3.3:
optional: true
@@ -3631,6 +3409,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
optional: true
get-caller-file@2.0.5: {}
@@ -3664,6 +3443,7 @@ snapshots:
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
optional: true
gopd@1.2.0: {}
@@ -3675,7 +3455,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
has-unicode@2.0.1:
optional: true
hasown@2.0.4:
dependencies:
@@ -3694,8 +3475,10 @@ snapshots:
dependencies:
once: 1.4.0
wrappy: 1.0.2
optional: true
inherits@2.0.4: {}
inherits@2.0.4:
optional: true
ioredis@6.0.0(supports-color@7.2.0):
dependencies:
@@ -3712,12 +3495,6 @@ snapshots:
is-network-error@1.3.2: {}
libsodium-wrappers@0.8.4:
dependencies:
libsodium: 0.8.4
libsodium@0.8.4: {}
lightningcss-android-arm64@1.33.0:
optional: true
@@ -3784,6 +3561,7 @@ snapshots:
make-dir@3.1.0:
dependencies:
semver: 6.3.1
optional: true
math-intrinsics@1.1.0: {}
@@ -3801,33 +3579,41 @@ snapshots:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.18
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0: {}
minipass@5.0.0:
optional: true
minizlib@2.1.2:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
mkdirp@1.0.4: {}
mkdirp@1.0.4:
optional: true
ms@2.1.3: {}
nanoid@3.3.16: {}
node-addon-api@8.9.0: {}
node-addon-api@8.9.0:
optional: true
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
optional: true
nopt@5.0.0:
dependencies:
abbrev: 1.1.1
optional: true
npmlog@5.0.1:
dependencies:
@@ -3835,8 +3621,10 @@ snapshots:
console-control-strings: 1.1.0
gauge: 3.0.2
set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {}
object-assign@4.1.1:
optional: true
obug@2.1.4: {}
@@ -3845,6 +3633,7 @@ snapshots:
once@1.4.0:
dependencies:
wrappy: 1.0.2
optional: true
openai@7.20.0(undici@7.29.0)(ws@8.21.3)(zod@4.6.5):
optionalDependencies:
@@ -3852,8 +3641,6 @@ snapshots:
ws: 8.21.3
zod: 4.6.5
opusscript@0.1.1: {}
otplib@12.0.1:
dependencies:
'@otplib/core': 12.0.1
@@ -3880,7 +3667,8 @@ snapshots:
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-is-absolute@1.0.1:
optional: true
pg-cloudflare@1.4.0:
optional: true
@@ -3963,14 +3751,9 @@ snapshots:
dependencies:
xtend: 4.0.2
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0))(opusscript@0.1.1):
prism-media@1.3.5(@discordjs/opus@0.10.0(supports-color@7.2.0)):
optionalDependencies:
'@discordjs/opus': 0.10.0(supports-color@7.2.0)
opusscript: 0.1.1
prism-media@2.0.0-alpha.0:
dependencies:
duplex-child-process: 1.0.1
process-warning@5.1.0: {}
@@ -3989,6 +3772,7 @@ snapshots:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
real-require@0.2.0: {}
@@ -4005,6 +3789,7 @@ snapshots:
rimraf@3.0.2:
dependencies:
glob: 7.2.3
optional: true
rolldown@1.1.5:
dependencies:
@@ -4027,11 +3812,13 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
safe-buffer@5.2.1: {}
safe-buffer@5.2.1:
optional: true
safe-stable-stringify@2.5.0: {}
semver@6.3.1: {}
semver@6.3.1:
optional: true
semver@7.8.5: {}
@@ -4074,7 +3861,8 @@ snapshots:
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
signal-exit@3.0.7:
optional: true
sonic-boom@4.2.1:
dependencies:
@@ -4106,6 +3894,7 @@ snapshots:
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
optional: true
strip-ansi@6.0.1:
dependencies:
@@ -4123,6 +3912,7 @@ snapshots:
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
thirty-two@1.0.2: {}
@@ -4161,7 +3951,8 @@ snapshots:
dependencies:
tldts: 7.4.9
tr46@0.0.3: {}
tr46@0.0.3:
optional: true
tree-kill@1.2.2: {}
@@ -4202,7 +3993,8 @@ snapshots:
undici@7.29.0: {}
util-deprecate@1.0.2: {}
util-deprecate@1.0.2:
optional: true
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15):
dependencies:
@@ -4238,7 +4030,8 @@ snapshots:
transitivePeerDependencies:
- msw
webidl-conversions@3.0.1: {}
webidl-conversions@3.0.1:
optional: true
werift-rtp@0.8.9(supports-color@7.2.0):
dependencies:
@@ -4252,6 +4045,7 @@ snapshots:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
optional: true
which-module@2.0.1: {}
@@ -4263,6 +4057,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
optional: true
wrap-ansi@6.2.0:
dependencies:
@@ -4270,7 +4065,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
wrappy@1.0.2:
optional: true
ws@8.21.3: {}
@@ -4278,7 +4074,8 @@ snapshots:
y18n@4.0.3: {}
yallist@4.0.0: {}
yallist@4.0.0:
optional: true
yargs-parser@18.1.3:
dependencies:
+2 -180
View File
@@ -1,6 +1,4 @@
import { Client } from "discord.js-selfbot-v13";
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { ConfigError, DatabaseError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import {
@@ -33,144 +31,18 @@ import { startDigestScheduler } from "../modules/monitor/digestScheduler.js";
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
import { registerPresenceCapture } from "../modules/user-presence/index.js";
import { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { startMuxerWorker } from "../modules/voice-recording/muxer.js";
import {
registerSelfVoiceStateGuard,
setPcmWsClient,
setEventBroadcaster as setRecorderEventBroadcaster,
} from "../modules/voice-recording/recorder.js";
import {
setVideoRecorderClient,
setVideoRecordingsDir,
} from "../modules/voice-recording/videoRecorder.js";
import { VoiceController } from "../modules/voice-recording/voiceController.js";
import { config } from "../shared/config/config.js";
import {
closeDatabase,
getDatabase,
initializeDatabase,
} from "../shared/database/drizzle.js";
import { runMigrations } from "../shared/database/migrate.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
import { startRetentionCleanup } from "./retention.js";
import { createGracefulShutdown } from "./shutdown.js";
const logger = createChildLogger("discord-gateway");
// ─── Retention Cleanup ─────────────────────────────────────────────────────
async function deleteExpiredRecords(
table: any,
timestampField: any,
days: number | undefined,
dryRun: boolean,
label: string,
): Promise<void> {
if (!days || days <= 0) {
logger.debug({ label }, `Retention disabled for ${label}`);
return;
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const expired = await db
.select({ id: table.id })
.from(table)
.where(lt(timestampField, cutoff))
.limit(1000);
if (expired.length === 0) {
logger.debug({ label }, `No expired ${label} found`);
return;
}
logger.info({ count: expired.length, label }, `Found expired ${label}`);
if (dryRun) {
logger.info(
{ count: expired.length, label },
`[DRY RUN] Would delete ${expired.length} ${label}`,
);
return;
}
try {
await db.delete(table).where(
inArray(
table.id,
expired.map((r) => r.id),
),
);
logger.info({ count: expired.length, label }, `Deleted expired ${label}`);
} catch (err) {
logger.error({ err, label }, `Failed to delete expired ${label}`);
}
}
function startRetentionCleanup(): void {
const intervalMs = config.RETENTION_CLEANUP_INTERVAL_MS;
const dryRun = config.RETENTION_DRY_RUN;
logger.info(
{
intervalMs,
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
async function runCleanupTick(): Promise<void> {
await deleteExpiredRecords(
messagesTable,
messagesTable.created_at,
config.RETENTION_MESSAGES_DAYS,
dryRun,
"messages",
);
await deleteExpiredRecords(
attachmentsTable,
attachmentsTable.created_at,
config.RETENTION_ATTACHMENTS_DAYS,
dryRun,
"attachments",
);
await deleteExpiredRecords(
voiceRecordingsTable,
voiceRecordingsTable.created_at,
config.RETENTION_VOICE_DAYS,
dryRun,
"voice recordings",
);
}
// Run immediately on start, then schedule
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Initial retention cleanup tick failed",
);
});
setInterval(() => {
runCleanupTick().catch((error) => {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Retention cleanup tick failed",
);
});
}, intervalMs);
}
// ─── Bootstrap ─────────────────────────────────────────────────────────────
export async function initializeDiscordGateway() {
@@ -188,13 +60,6 @@ export async function initializeDiscordGateway() {
logger.info("Creating Discord client");
const client = new Client(createDiscordClientOptions());
const voiceController = new VoiceController(client);
// Wire the video recorder (others' camera/screen share) to the selfbot's
// native watch/receive stack + its recordings dir. Best-effort: failures are
// logged inside, never fatal.
setVideoRecorderClient(client);
setVideoRecordingsDir(config.RECORDINGS_DIR);
// Initialize Redis event broadcaster
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
@@ -203,33 +68,13 @@ export async function initializeDiscordGateway() {
// Initialize Redis command handler for backend→gateway commands
const commandHandler = new CommandHandler();
// Initialize Voice PCM WebSocket client (bypasses Redis for real-time audio)
let pcmWsClient: VoicePcmWsClient | undefined;
if (config.VOICE_PCM_WS_ENABLED && config.BACKEND_WS_TOKEN) {
pcmWsClient = new VoicePcmWsClient(
config.BACKEND_WS_URL,
config.BACKEND_WS_TOKEN,
);
pcmWsClient.connect();
setPcmWsClient(pcmWsClient);
logger.info({ url: config.BACKEND_WS_URL }, "Voice PCM WS client enabled");
} else if (config.VOICE_PCM_WS_ENABLED && !config.BACKEND_WS_TOKEN) {
logger.warn(
"VOICE_PCM_WS_ENABLED=true but BACKEND_WS_TOKEN is empty — falling back to Redis for PCM",
);
} else {
logger.info("Voice PCM WS disabled — using Redis for PCM");
}
const gracefulShutdown = createGracefulShutdown({
logger,
closeDatabase,
voiceController,
client,
eventBroadcaster,
commandHandler,
stopMetricsServer,
pcmWsClient,
});
try {
@@ -255,8 +100,6 @@ export async function initializeDiscordGateway() {
client.on("debug", (msg) => {
if (
msg.includes("[VOICE") ||
msg.includes("[ffmpeg") ||
msg.toLowerCase().includes("error") ||
msg.toLowerCase().includes("stream")
) {
@@ -269,7 +112,6 @@ export async function initializeDiscordGateway() {
client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in");
setMessageCaptureEventBroadcaster(eventBroadcaster);
setRecorderEventBroadcaster(eventBroadcaster);
setModerationEventBroadcaster(eventBroadcaster);
registerMessageCapture(client);
startPendingAIAnalysisWorker(client, eventBroadcaster);
@@ -281,30 +123,10 @@ export async function initializeDiscordGateway() {
registerChannelTopicCapture(client, eventBroadcaster);
registerGuildMemberEvents(client, eventBroadcaster);
// Start background workers
startMuxerWorker();
// Start command handler after Discord is ready
commandHandler.start(client, voiceController);
commandHandler.start(client);
logger.info("Command handler started");
// Rejoin persisted voice channels (auto-reconnect on restart/reboot).
// Non-fatal: failures are logged inside autoReconnect.
void voiceController
.autoReconnect()
.catch((err) =>
logger.warn(
{ err: err instanceof Error ? err.message : String(err) },
"Voice auto-reconnect on startup failed",
),
);
// Attach the immediate self-undeafen/self-unmute guard so the bot reacts
// INSTANTLY when an admin server-mutes or server-deafens it (previously
// only re-asserted on video-watch/reconnect, leaving the bot muted for
// minutes).
registerSelfVoiceStateGuard(client);
// Start retention cleanup scheduler
startRetentionCleanup();
// Start weekly moderation digest (public, automated)
+23 -17
View File
@@ -1,22 +1,36 @@
import { inArray, lt } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type {
NodePgDatabase,
NodePgQueryResultHKT,
} from "drizzle-orm/node-postgres";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../shared/config/config.js";
import { getDatabase } from "../shared/database/drizzle.js";
import type * as schema from "../shared/database/schema.js";
import {
attachmentsTable,
messagesTable,
voiceRecordingsTable,
} from "../shared/database/schema.js";
import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
const logger = createChildLogger("discord-gateway");
/** DB handle typed with the full schema so table/column refs resolve. */
type GatewayDatabase = NodePgDatabase<typeof schema>;
/** Tables eligible for retention cleanup: string `id` + numeric `created_at`. */
type RetentionTable = typeof messagesTable | typeof attachmentsTable;
type RetentionTimestampColumn =
| typeof messagesTable.created_at
| typeof attachmentsTable.created_at;
// ─── Retention Cleanup ─────────────────────────────────────────────────────
/**
* Delete rows older than `days` in `table`, in batches of up to 1000 ids.
* When `dryRun` is set, logs what would be deleted without deleting.
* Returns immediately (no-op) when `days` is unset or <= 0.
*/
async function deleteExpiredRecords(
table: any,
timestampField: any,
table: RetentionTable,
timestampField: RetentionTimestampColumn,
days: number | undefined,
dryRun: boolean,
label: string,
@@ -27,7 +41,7 @@ async function deleteExpiredRecords(
}
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const db = getDatabase() as unknown as GatewayDatabase;
const expired = await db
.select({ id: table.id })
@@ -73,7 +87,6 @@ function startRetentionCleanup(): void {
dryRun,
messagesDays: config.RETENTION_MESSAGES_DAYS,
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
voiceDays: config.RETENTION_VOICE_DAYS,
},
"Starting retention cleanup scheduler",
);
@@ -93,13 +106,6 @@ function startRetentionCleanup(): void {
dryRun,
"attachments",
);
await deleteExpiredRecords(
voiceRecordingsTable,
voiceRecordingsTable.created_at,
config.RETENTION_VOICE_DAYS,
dryRun,
"voice recordings",
);
}
// Run immediately on start, then schedule
+9 -20
View File
@@ -3,9 +3,6 @@ import type { createChildLogger } from "@/shared/logger/index";
import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
import type { stopMetricsServer } from "../modules/gateway-metrics/index.js";
import type { VoicePcmWsClient } from "../modules/voice-pcm-ws/index.js";
import { stopMuxerWorker } from "../modules/voice-recording/muxer.js";
import type { VoiceController } from "../modules/voice-recording/voiceController.js";
import type { closeDatabase } from "../shared/database/drizzle.js";
type Logger = ReturnType<typeof createChildLogger>;
@@ -15,18 +12,21 @@ type StopMetricsServer = typeof stopMetricsServer;
export interface GracefulShutdownOptions {
logger: Logger;
closeDatabase: CloseDatabase;
voiceController: VoiceController;
client: Client;
eventBroadcaster: EventBroadcaster;
commandHandler: CommandHandler;
stopMetricsServer?: StopMetricsServer;
pcmWsClient?: VoicePcmWsClient;
}
export function createGracefulShutdown(options: GracefulShutdownOptions) {
export type GracefulShutdown = (signal: string) => Promise<void>;
/** Create a shutdown handler that can only be triggered once. */
export function createGracefulShutdown(
options: GracefulShutdownOptions,
): GracefulShutdown {
let isShuttingDown = false;
return async function gracefulShutdown(signal: string) {
return async function gracefulShutdown(signal: string): Promise<void> {
if (isShuttingDown) {
options.logger.warn(`Already shutting down, ignoring ${signal}`);
return;
@@ -37,26 +37,15 @@ export function createGracefulShutdown(options: GracefulShutdownOptions) {
try {
options.stopMetricsServer?.();
stopMuxerWorker();
// 1. Voice disconnect (may write final DB records)
options.logger.info("Stopping voice connection...");
await options.voiceController.disconnect();
// 1b. Close PCM WebSocket client (after voice, before Redis)
if (options.pcmWsClient) {
options.logger.info("Closing voice PCM WS client...");
await options.pcmWsClient.close();
}
// 2. Close Redis/pubsub after voice is done
// 1. Close Redis/pubsub
options.logger.info("Closing event broadcaster...");
await options.eventBroadcaster.close();
options.logger.info("Closing command handler...");
await options.commandHandler.close();
// 3. DB pool LAST – voice disconnect may finalize recordings
// 2. DB pool
options.logger.info("Closing database...");
await options.closeDatabase();
options.logger.info("Database closed");
-2
View File
@@ -1,5 +1,3 @@
import "libsodium-wrappers";
import "@snazzah/davey";
import "dotenv/config";
import { createChildLogger } from "@/shared/logger/index";
import { initializeDiscordGateway } from "./app/bootstrap.js";
@@ -5,41 +5,17 @@ import {
BACKEND_COMMAND,
type CommandMessage,
type CommandReply,
MEDIA_STATUS_KEY,
VOICE_STATUS_KEY,
} from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import type { VoiceController } from "../voice-recording/voiceController.js";
import { GuildHandler } from "./guild.handler.js";
import {
type CommandHandlerFn,
createHandlerRegistry,
} from "./handler-registry.js";
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");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface VoiceStatusPayload {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: Array<{
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}>;
}
// ---------------------------------------------------------------------------
// CommandHandler
// ---------------------------------------------------------------------------
@@ -47,22 +23,18 @@ interface VoiceStatusPayload {
export class CommandHandler {
private redisSub: Redis;
private redisPub: Redis;
private voiceController: VoiceController | null = null;
private registry: Map<string, CommandHandlerFn> = new Map();
private voiceHandler!: VoiceHandler;
private mediaHandler!: MediaHandler;
private guildHandler!: GuildHandler;
private moderationHandler!: ModerationHandler;
private videoHandler!: VideoHandler;
constructor() {
// Dedicated Redis connection needed because: Redis requires a dedicated
// connection for SUBSCRIBE mode — a subscribed connection cannot perform
// publish/set operations. This connection listens on backend:command for
// inbound requests from the backend.
this.redisSub = new Redis(config.REDIS_URL); // Dedicated Redis connection needed because: Redis requires a dedicated
// PUBLISH connection (cannot share with redisSub which is in SUBSCRIBE mode).
// Handles command reply publishing and voice/media status key updates.
this.redisSub = new Redis(config.REDIS_URL);
// A second dedicated connection for PUBLISH — a connection in SUBSCRIBE
// mode cannot publish, so replies go out on this one.
this.redisPub = new Redis(config.REDIS_URL);
this.redisSub.on("error", (err) => {
@@ -77,30 +49,18 @@ export class CommandHandler {
// ---- Lifecycle ----
/**
* Attach the Discord client and VoiceController, then subscribe to the Redis
* command channel. Must be called *after* the Discord client is created.
* Attach the Discord client, then subscribe to the Redis command channel.
* Must be called *after* the Discord client is created.
*/
start(client: Client, voiceController: VoiceController): void {
this.voiceController = voiceController;
start(client: Client): void {
// Create domain-specific handlers with their dependencies
this.voiceHandler = new VoiceHandler(client, voiceController);
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).
wireMediaStatusWriter(this.redisPub);
// Build the command registry
this.registry = createHandlerRegistry(
this.voiceHandler,
this.mediaHandler,
this.guildHandler,
this.moderationHandler,
this.videoHandler,
);
this.redisSub.on("message", (_channel, message) => {
@@ -117,10 +77,6 @@ export class CommandHandler {
logger.info(`Subscribed to Redis channel "${BACKEND_COMMAND}"`);
}
});
// Publish initial status snapshots so the backend knows the starting state.
this.publishVoiceStatus();
this.publishMediaStatus();
}
async close(): Promise<void> {
@@ -129,6 +85,11 @@ export class CommandHandler {
// ---- Command dispatch ----
/** Normalize an unknown thrown value to a readable message. */
private static errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
private async handleCommand(raw: string): Promise<void> {
let cmd: CommandMessage;
try {
@@ -156,7 +117,7 @@ export class CommandHandler {
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = CommandHandler.errorMessage(err);
logger.error(
{ commandId: cmd.id, error: message },
"Command execution failed",
@@ -175,51 +136,5 @@ export class CommandHandler {
} catch (err) {
logger.error({ err }, "Failed to publish command reply");
}
// Always refresh status keys after every command so the backend has
// the latest snapshot without polling.
this.publishVoiceStatus();
this.publishMediaStatus();
}
// ---- Status publishing ----
private publishVoiceStatus(): void {
const raw = this.voiceController
? this.voiceController.getStatus()
: {
ready: false,
connected: false,
activeGuildId: null,
activeChannelId: null,
activeChannelName: null,
connections: [],
};
const status: VoiceStatusPayload = {
connected: raw.connected,
activeGuildId: raw.activeGuildId,
activeChannelId: raw.activeChannelId,
activeChannelName: raw.activeChannelName,
connections: raw.connections ?? [],
};
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
}
private publishMediaStatus(): void {
this.setKey(
MEDIA_STATUS_KEY,
JSON.stringify(this.mediaHandler.getCurrentMediaStatus()),
);
}
/**
* Fire-and-forget SET using the persistent Redis publisher connection.
*/
private setKey(key: string, value: string): void {
this.redisPub.set(key, value).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ key, error: msg }, "Failed to update Redis status key");
});
}
}
@@ -1,28 +1,12 @@
import {
COMMAND_GUILDS_LIST,
COMMAND_GUILDS_TEXT_CHANNELS,
COMMAND_MEDIA_LOOP,
COMMAND_MEDIA_QUEUE,
COMMAND_MEDIA_SKIP,
COMMAND_MEDIA_STOP,
COMMAND_MEDIA_VOLUME,
COMMAND_MODERATION_ACTION,
COMMAND_VIDEO_UNWATCH,
COMMAND_VIDEO_WATCH,
COMMAND_VOICE_CHANNELS,
COMMAND_VOICE_CONNECT,
COMMAND_VOICE_DISCONNECT,
COMMAND_VOICE_DISCONNECT_GUILD,
COMMAND_VOICE_TRANSMIT_START,
COMMAND_VOICE_TRANSMIT_STOP,
type CommandMessage,
type CommandReply,
} from "../../shared/index.js";
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";
// ---------------------------------------------------------------------------
// Types
@@ -37,53 +21,11 @@ export type CommandHandlerFn = (
// ---------------------------------------------------------------------------
export function createHandlerRegistry(
voiceHandler: VoiceHandler,
mediaHandler: MediaHandler,
guildHandler: GuildHandler,
moderationHandler: ModerationHandler,
videoHandler: VideoHandler,
): Map<string, CommandHandlerFn> {
const registry = new Map<string, CommandHandlerFn>();
// Voice commands
registry.set(COMMAND_VOICE_CONNECT, (cmd) =>
voiceHandler.handleVoiceConnect(cmd),
);
registry.set(COMMAND_VOICE_DISCONNECT, (cmd) =>
voiceHandler.handleVoiceDisconnect(cmd),
);
registry.set(COMMAND_VOICE_DISCONNECT_GUILD, (cmd) =>
voiceHandler.handleVoiceDisconnectGuild(cmd),
);
registry.set(COMMAND_VOICE_CHANNELS, (cmd) =>
voiceHandler.handleVoiceChannels(cmd),
);
registry.set(COMMAND_VOICE_TRANSMIT_START, (cmd) =>
voiceHandler.handleVoiceTransmitStart(cmd),
);
registry.set(COMMAND_VOICE_TRANSMIT_STOP, (cmd) =>
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),
);
registry.set(COMMAND_MEDIA_SKIP, (cmd) => mediaHandler.handleMediaSkip(cmd));
registry.set(COMMAND_MEDIA_STOP, (cmd) => mediaHandler.handleMediaStop(cmd));
registry.set(COMMAND_MEDIA_VOLUME, (cmd) =>
mediaHandler.handleMediaVolume(cmd),
);
registry.set(COMMAND_MEDIA_LOOP, (cmd) => mediaHandler.handleMediaLoop(cmd));
// Guild commands
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
guildHandler.handleListGuilds(cmd),
@@ -1,342 +0,0 @@
import { randomUUID } from "node:crypto";
import { StreamType } from "@discordjs/voice";
import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import {
extractMediaInfo,
resolveMediaUrl,
transcodeToHighQualityOgg,
} from "../voice-recording/mediaSource.js";
import type {
MediaMode,
MediaQueueItem,
} from "../voice-recording/mediaTypes.js";
import { discordPlayer } from "../voice-recording/player.js";
import { setMediaStatusKey } from "./mediaStatusSink.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface MediaStatusItem {
id: string;
source: string;
title: string;
mode: MediaMode;
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaStatusPayload {
playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
loop: boolean;
current: MediaStatusItem | null;
queue: MediaStatusItem[];
}
// ---------------------------------------------------------------------------
// Module-level queue
// ---------------------------------------------------------------------------
const mediaQueue: MediaQueueItem[] = [];
let currentTrackItem: MediaQueueItem | null = null;
let loopEnabled = false;
/** Active ffmpeg transcode (killed on stop/skip). */
let currentTranscodeCleanup: (() => void) | null = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mapToStatusItem(item: MediaQueueItem): MediaStatusItem {
return {
id: item.id,
source: item.source,
title: item.title,
mode: item.mode,
durationMs: item.duration != null ? item.duration * 1000 : null,
thumbnailUrl: item.thumbnailUrl ?? null,
};
}
function buildStatusPayload(): MediaStatusPayload {
return {
playing:
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
activeMode: currentTrackItem?.mode ?? null,
musicVolume: discordPlayer.getMusicVolume(),
loop: loopEnabled,
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
queue: mediaQueue.map(mapToStatusItem),
};
}
// ---------------------------------------------------------------------------
// MediaHandler
// ---------------------------------------------------------------------------
export class MediaHandler {
private logger = createChildLogger("media-handler");
constructor() {
// Register auto-advance on natural track end. advanceQueue mutates the
// module-level currentTrackItem/queue, so we must re-publish the status
// key afterward: otherwise the backend's Redis `media:status` cache (and
// the frontend's 10s polling) stays stuck on the finished track.
discordPlayer.onIdle(() => {
this.advanceQueue()
.then(() => this.publishStatus())
.catch((err) => {
this.logger.error({ err }, "Auto-advance failed");
});
});
}
/**
* Persist the latest media state to Redis so the backend/frontend see queue
* advances that happen outside a command (natural track end). CommandHandler
* owns the Redis status-key writes for command-triggered changes; this
* covers the side-effect-only path.
*/
private publishStatus(): void {
try {
setMediaStatusKey(this.getCurrentMediaStatus());
} catch (err: unknown) {
this.logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"Failed to publish media status on track end",
);
}
}
getCurrentMediaStatus(): MediaStatusPayload {
return buildStatusPayload();
}
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
if (!url) {
this.logger.warn("media:queue received without a URL");
return {
id: cmd.id,
success: false,
data: null,
error: "url is required",
};
}
if (!discordPlayer.isConnected()) {
this.logger.warn("media:queue attempted without active voice connection");
return {
id: cmd.id,
success: false,
data: null,
error: "Not connected to a voice channel. Connect to voice first.",
};
}
// Lightweight metadata fetch for display — the full resolve happens in playNext
let title: string = url;
let duration: number | undefined;
let thumbnailUrl: string | undefined;
try {
const info = await extractMediaInfo(url);
title = info.title ?? url;
duration = info.duration;
thumbnailUrl = info.thumbnail;
} catch {
this.logger.warn(
{ url },
"Could not pre-resolve media metadata, queueing blind",
);
}
const item: MediaQueueItem = {
id: randomUUID(),
source: url,
title,
kind: "url" as const,
mode: "music",
requestedBy,
addedAt: Date.now(),
status: "queued",
duration,
thumbnailUrl,
};
mediaQueue.push(item);
this.logger.info(
{ url, title, queueLength: mediaQueue.length },
"Media queued",
);
// If nothing is playing, start immediately (next tick so status publishes)
if (currentTrackItem === null) {
setImmediate(() => {
this.playNext().catch((err) => {
this.logger.error({ err }, "playNext after queue failed");
});
});
}
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
if (currentTrackItem) {
discordPlayer.stop("music");
currentTrackItem = null;
}
await this.playNext();
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
discordPlayer.stop("music");
currentTrackItem = null;
mediaQueue.length = 0; // Clear entire queue
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
async handleMediaVolume(cmd: CommandMessage): Promise<CommandReply<unknown>> {
const volume = Number(cmd.payload.volume);
if (!Number.isFinite(volume)) {
return {
id: cmd.id,
success: false,
data: null,
error: "volume must be a number",
};
}
discordPlayer.setMusicVolume(volume);
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
async handleMediaLoop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
loopEnabled = Boolean(cmd.payload.loop);
this.logger.info({ loop: loopEnabled }, "Media loop toggled");
this.publishStatus();
return {
id: cmd.id,
success: true,
data: buildStatusPayload(),
};
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/**
* Advance to the next item in the queue. Called after natural track end
* (via onIdle) or after manual skip.
*/
private async playNext(): Promise<void> {
// Stop any currently playing track (without triggering idle callback)
if (currentTrackItem) {
discordPlayer.stop("music");
currentTrackItem = null;
}
currentTranscodeCleanup?.();
currentTranscodeCleanup = null;
const next = mediaQueue.shift();
if (!next) {
this.logger.info("Queue empty — nothing to play");
return;
}
currentTrackItem = next;
next.status = "playing";
try {
this.logger.info(
{ url: next.source, title: next.title },
"Playing next from queue",
);
const resolution = await resolveMediaUrl(next.source);
// Update title/duration with actual resolved values
next.title = resolution.title ?? next.title;
next.duration = resolution.duration ?? next.duration;
// Music playback: transcode once to high-quality OggOpus (48kHz stereo,
// 192kbps) with volume baked into the encode. This avoids the double
// lossy encode that inlineVolume would cause and gives Discord the
// cleanest possible stream.
const transcoded = transcodeToHighQualityOgg(
resolution.stream,
discordPlayer.getMusicVolume(),
);
discordPlayer.playStream(transcoded.stream, "music", {
inputType: StreamType.OggOpus,
inlineVolume: false,
});
currentTranscodeCleanup = transcoded.cleanup;
this.logger.info({ title: next.title }, "Playback started");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(
{ error: message, url: next.source, title: next.title },
"Failed to play queued item, skipping to next",
);
currentTrackItem = null;
next.status = "failed";
// Try the next item in the queue
setImmediate(() => {
this.playNext().catch((err2) => {
this.logger.error(
{ err: err2 },
"playNext after error recovery failed",
);
});
});
}
}
/**
* Called by the idle callback — delegates to playNext since the player is
* already idle and currentTrackItem is already null. When loop mode is
* enabled and a music track ended naturally, requeue it so it plays again.
*/
private async advanceQueue(): Promise<void> {
const finished = currentTrackItem;
currentTrackItem = null;
if (loopEnabled && finished && finished.mode === "music") {
mediaQueue.unshift(finished);
this.logger.info(
{ title: finished.title },
"Loop enabled — replaying finished track",
);
}
await this.playNext();
}
}
@@ -1,41 +0,0 @@
import type Redis from "ioredis";
import { createChildLogger } from "@/shared/logger/index";
import { MEDIA_STATUS_KEY } from "../../shared/redis-channels.js";
/**
* Shared sink for writing the media status Redis key.
*
* CommandHandler owns the publisher + status writes for command-triggered
* changes (`publishMediaStatus`). MediaHandler needs to also persist status
* when the queue advances *outside* a command (natural track end / screen-share
* done), so we expose the real publisher here and let CommandHandler wire it
* once at startup.
*/
const logger = createChildLogger("media-status-sink");
let _setMediaStatusKey: ((payload: unknown) => void) | null = null;
export function setMediaStatusWriter(writer: (payload: unknown) => void): void {
_setMediaStatusKey = writer;
}
export function setMediaStatusKey(payload: unknown): void {
if (!_setMediaStatusKey) {
logger.warn("Media status writer not wired — skipping status publish");
return;
}
_setMediaStatusKey(payload);
}
export { MEDIA_STATUS_KEY };
export function wireMediaStatusWriter(redisPub: Redis): void {
setMediaStatusWriter((payload) => {
redisPub
.set(MEDIA_STATUS_KEY, JSON.stringify(payload))
.catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn({ error: msg }, "Failed to update media status key");
});
});
}
@@ -1,145 +0,0 @@
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 },
};
}
}
@@ -1,229 +0,0 @@
import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis";
import { config } from "../../shared/config/config.js";
import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
import { discordPlayer } from "../voice-recording/player.js";
import { voiceTransmitter } from "../voice-recording/transmitter.js";
import type { VoiceController } from "../voice-recording/voiceController.js";
// ---------------------------------------------------------------------------
// VoiceHandler
// ---------------------------------------------------------------------------
export class VoiceHandler {
private logger = createChildLogger("voice-handler");
constructor(
private client: Client | null,
private voiceController: VoiceController | null,
) {}
setClient(client: Client): void {
this.client = client;
}
setVoiceController(voiceController: VoiceController): void {
this.voiceController = voiceController;
}
async handleVoiceConnect(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!this.client || !this.voiceController) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
const channelId = String(cmd.payload.channelId ?? "");
if (!guildId || !channelId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId and channelId are required",
};
}
const status = await this.voiceController.connect(guildId, channelId);
return { id: cmd.id, success: true, data: status };
}
async handleVoiceDisconnect(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!this.voiceController) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const status = await this.voiceController.disconnect({
clearPersisted: true,
intentional: true,
});
return { id: cmd.id, success: true, data: status };
}
async handleVoiceDisconnectGuild(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!this.voiceController) {
return {
id: cmd.id,
success: false,
data: null,
error: "Gateway not initialized",
};
}
const guildId = String(cmd.payload.guildId ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
await this.voiceController.disconnectGuild(guildId, {
clearPersisted: true,
intentional: true,
});
const status = this.voiceController.getStatus();
return { id: cmd.id, success: true, data: status };
}
async handleVoiceChannels(
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 ?? "");
if (!guildId) {
return {
id: cmd.id,
success: false,
data: null,
error: "guildId is required",
};
}
try {
const guild = await this.client.guilds.fetch(guildId);
const channels = await guild.channels.fetch();
const voiceChannels = channels
.filter((c) => c?.type === "GUILD_VOICE")
.map((c) => ({
id: c.id,
name: c.name,
type: "voice" as const,
// selfbot exposes joinable (permission check) — let FE filter
// channels the account actually may join.
joinable: (c as { joinable?: boolean }).joinable ?? true,
}));
return { id: cmd.id, success: true, data: voiceChannels };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { id: cmd.id, success: false, data: null, error: msg };
}
}
async handleVoiceTransmitStart(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
if (!discordPlayer.isConnected()) {
return {
id: cmd.id,
success: false,
data: null,
error: "Not connected to voice channel",
};
}
// Double-check: verify the voice controller also reports connected
if (this.voiceController) {
const vcStatus = this.voiceController.getStatus();
if (!vcStatus.connected) {
this.logger.warn(
"Player reports connected but voice controller says disconnected — stale state",
);
return {
id: cmd.id,
success: false,
data: null,
error: "Voice channel connection is stale — reconnect first",
};
}
}
try {
// IMPORTANT: transmitter needs its OWN Redis client because it calls
// .subscribe() which converts the connection to subscriber mode. Reusing
// the publish connection from CommandHandler would corrupt it and break
// every command reply + status update.
const transmitRedis = new Redis(config.REDIS_URL);
await voiceTransmitter.start(transmitRedis);
const status = voiceTransmitter.getStatus();
this.logger.info({ status }, "Voice transmit started");
return {
id: cmd.id,
success: true,
data: status,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error({ error: message }, "Failed to start voice transmit");
return {
id: cmd.id,
success: false,
data: null,
error: message,
};
}
}
async handleVoiceTransmitStop(
cmd: CommandMessage,
): Promise<CommandReply<unknown>> {
try {
await voiceTransmitter.stop();
this.logger.info("Voice transmit stopped");
return {
id: cmd.id,
success: true,
data: { status: "stopped" },
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error({ error: message }, "Failed to stop voice transmit");
return {
id: cmd.id,
success: false,
data: null,
error: message,
};
}
}
}
@@ -112,87 +112,6 @@ export class EventBroadcaster {
});
}
async voiceRecordingStarted(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing voice_recording_started");
await this.publisher.publish(EventChannels.VOICE_STARTED, {
type: "voice_recording_started",
data,
timestamp: Date.now(),
source: "discord-gateway",
});
}
async voiceRecordingStopped(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing voice_recording_stopped");
await this.publisher.publish(EventChannels.VOICE_STOPPED, {
type: "voice_recording_stopped",
data,
timestamp: Date.now(),
source: "discord-gateway",
});
}
async voiceRecordingUploaded(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing voice_recording_uploaded");
await this.publisher.publish(EventChannels.VOICE_UPLOADED, {
type: "voice_recording_uploaded",
data,
timestamp: Date.now(),
source: "discord-gateway",
});
}
/**
* Broadcasts PCM audio data for real-time voice streaming
* @param pcmBuffer - Raw PCM audio buffer
* @param userId - Discord user ID
* @param metadata - Optional metadata about the audio chunk
*/
async voicePcmData(
pcmBuffer: Buffer,
userId: string,
metadata?: Record<string, unknown>,
): Promise<void> {
this.logger.debug(
{ userId, pcmSize: pcmBuffer.length },
"Publishing voice_pcm_data",
);
await this.publisher.publish(EventChannels.VOICE_PCM, {
type: "voice_pcm_data",
data: {
userId,
pcm: pcmBuffer.toString("base64"),
metadata,
},
timestamp: Date.now(),
source: "discord-gateway",
});
}
/**
* Broadcasts voice user activity state changes
* @param userId - Discord user ID
* @param data - User state data including username, avatar, and speaking status
*/
async voiceActiveUser(
userId: string,
data: { username: string; avatar: string; speaking: boolean },
): Promise<void> {
this.logger.debug(
{ userId, speaking: data.speaking },
"Publishing voice_active_user",
);
await this.publisher.publish(EventChannels.VOICE_ACTIVE_USER, {
type: "voice_active_user",
data: {
userId,
...data,
},
timestamp: Date.now(),
source: "discord-gateway",
});
}
async reactionAdded(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing reaction_added");
await this.publisher.publish(EventChannels.REACTION_ADDED, {
@@ -283,16 +202,6 @@ export class EventBroadcaster {
});
}
async voiceAnalyzed(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing voice_analyzed");
await this.publisher.publish(EventChannels.VOICE_ANALYZED, {
type: "voice_analyzed",
data,
timestamp: Date.now(),
source: "discord-gateway",
});
}
async moderationAction(data: Record<string, unknown>): Promise<void> {
this.logger.debug({ data }, "Publishing moderation_action");
await this.publisher.publish(EventChannels.MODERATION_ACTION, {
@@ -16,12 +16,6 @@ import {
DISCORD_THREAD_CREATED,
DISCORD_THREAD_DELETED,
DISCORD_THREAD_UPDATED,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_ANALYZED,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STARTED,
DISCORD_VOICE_STOPPED,
DISCORD_VOICE_UPLOADED,
type DiscordGatewayEvent,
} from "../../shared/index.js";
@@ -34,12 +28,6 @@ export const EventChannels = {
MESSAGE_ANALYZED: DISCORD_MESSAGE_ANALYZED,
ATTACHMENT_CREATED: DISCORD_ATTACHMENT_CREATED,
ATTACHMENT_UPLOADED: DISCORD_ATTACHMENT_UPLOADED,
VOICE_STARTED: DISCORD_VOICE_STARTED,
VOICE_STOPPED: DISCORD_VOICE_STOPPED,
VOICE_UPLOADED: DISCORD_VOICE_UPLOADED,
// Real-time voice streaming channels
VOICE_ACTIVE_USER: DISCORD_VOICE_ACTIVE_USER, // Active speaker state updates
VOICE_PCM: DISCORD_VOICE_PCM, // Live PCM audio data stream
ANALYSIS_QUEUE_STATUS: DISCORD_ANALYSIS_QUEUE_STATUS,
REACTION_ADDED: DISCORD_REACTION_ADDED,
REACTION_REMOVED: DISCORD_REACTION_REMOVED,
@@ -50,7 +38,6 @@ export const EventChannels = {
PRESENCE_UPDATED: DISCORD_PRESENCE_UPDATED,
GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED,
GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED,
VOICE_ANALYZED: DISCORD_VOICE_ANALYZED,
MODERATION_ACTION: DISCORD_MODERATION_ACTION,
} as const;
@@ -9,7 +9,6 @@ export type {
AIStatus,
AttachmentRecord,
MessageRecord,
VoiceSegmentRecord,
} from "../message-capture/types.js";
export type { TextCaptureTarget } from "./messageCapture.js";
export {
@@ -1,11 +1,8 @@
import type fs from "node:fs";
import type prism from "prism-media";
import type {
AnalysisQueueStatus,
AttachmentRecord,
MessageRecord,
UserMetadata,
VoiceRecordingUploadData,
} from "../../shared/index.js";
// Re-export all shared types for backward compatibility
@@ -29,41 +26,8 @@ export type {
ReviewStatus,
RoleMetadata,
UserMetadata,
VoiceRecordingUploadData,
VoiceSegmentRecord,
} from "../../shared/index.js";
// Types that are LOCAL ONLY (not in shared) — keep here
export interface SegmentState {
index: number;
startTime: number;
endTime: number | null;
filename: string;
jsonFilename: string;
oggStream: prism.opus.OggLogicalBitstream;
out: fs.WriteStream;
}
export interface SegmentMetadata extends UserMetadata {
recordingSessionId: string;
sessionId: string;
sessionStartTime: number;
segmentIndex: number;
segmentMs: number;
startTime: number;
endTime: number;
durationMs: number;
filename: string;
}
export interface PcmBroadcaster {
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
updateActiveUser?: (
userId: string,
data: { username: string; avatar: string; speaking: boolean },
) => void;
}
// Local-only types (not shared across services)
export type ModerationWsEvent =
| { type: "ui_state"; state: unknown }
@@ -73,6 +37,4 @@ export type ModerationWsEvent =
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
| { type: "message_analyzed"; data: MessageRecord }
| { type: "attachment_created"; data: AttachmentRecord }
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
| { type: "media_state"; state: unknown }
| { type: "voice_recording_uploaded"; data: VoiceRecordingUploadData };
| { type: "analysis_queue_status"; data: AnalysisQueueStatus };
@@ -1 +0,0 @@
export { VoicePcmWsClient } from "./voicePcmWsClient.js";
@@ -1,151 +0,0 @@
/**
* Direct WebSocket client for streaming voice PCM to the backend.
*
* Bypasses Redis pub/sub entirely — gateway connects as a WS client
* to the backend's existing WebSocket server and sends pre-formatted
* binary frames (4-byte FNV-1a userId hash + raw Int16LE PCM).
* The backend forwards these unchanged to frontend clients.
*/
import WebSocket from "ws";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("voice-pcm-ws");
/** 32-bit FNV-1a hash — mirrors backend/ws/redis-bridge.ts and frontend useAudioPlayback.ts */
function hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export class VoicePcmWsClient {
private ws: WebSocket | null = null;
private readonly token: string;
private readonly url: string;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private closed = false;
private reconnectMs = 1000;
constructor(url: string, token: string) {
this.url = url;
this.token = token;
}
/** Open connection and begin automatic reconnect loop. */
connect(): void {
if (this.closed) return;
this.doConnect();
}
/**
* Send a PCM chunk to the backend.
*
* Binary frame format (identical to what frontend expects):
* Byte 0–3: FNV-1a 32-bit userId hash (UInt32LE)
* Byte 4+: PCM audio (24kHz mono Int16LE)
*
* Silently drops if not connected — real-time audio, stale
* chunks from a disconnect gap are useless.
*/
sendPcm(userId: string, pcmBuffer: Buffer): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const hash = hashUserId(userId);
const frame = Buffer.alloc(4 + pcmBuffer.length);
frame.writeUInt32LE(hash, 0);
pcmBuffer.copy(frame, 4);
try {
this.ws.send(frame);
} catch (err) {
logger.warn({ err }, "Failed to send PCM frame");
}
}
/** Graceful close — send close frame, clear reconnect timer. */
async close(): Promise<void> {
this.closed = true;
this.clearReconnectTimer();
if (!this.ws) return;
// Remove listeners so reconnect doesn't fire during close
this.ws.removeAllListeners();
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, "shutdown");
}
this.ws = null;
}
// ── internals ────────────────────────────────────────────────────────
private getUrlWithAuth(): string {
const sep = this.url.includes("?") ? "&" : "?";
return `${this.url}${sep}token=${encodeURIComponent(this.token)}`;
}
private doConnect(): void {
if (this.closed) return;
const authUrl = this.getUrlWithAuth();
logger.info({ url: this.url }, "Connecting to backend WebSocket");
try {
this.ws = new WebSocket(authUrl);
} catch (err) {
logger.error({ err }, "Failed to create WebSocket");
this.scheduleReconnect();
return;
}
this.ws.on("open", () => {
logger.info("Voice PCM WS client connected");
this.reconnectMs = 1000; // reset backoff on successful connect
});
this.ws.on("close", (code, reason) => {
logger.warn(
{ code, reason: reason.toString() },
"Voice PCM WS client disconnected",
);
this.ws = null;
this.scheduleReconnect();
});
this.ws.on("error", (err) => {
logger.error({ err: err.message }, "Voice PCM WS client error");
// on("close") fires after on("error") — reconnect handled there
});
}
private scheduleReconnect(): void {
if (this.closed) return;
this.clearReconnectTimer();
// Exponential backoff with jitter: 1s → 2s → 4s → ... → 30s cap
const delay = this.reconnectMs + Math.random() * 1000;
logger.info({ delayMs: Math.round(delay) }, "Scheduling WS reconnect");
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.closed) {
this.doConnect();
}
}, delay);
this.reconnectMs = Math.min(this.reconnectMs * 2, 30_000);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
@@ -1,78 +0,0 @@
import { spawn } from "node:child_process";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("ffmpeg-process");
export interface MuxFfmpegArgsOptions {
inputs: string[];
filter: string;
output: string;
codec: string;
audioFrequency?: number;
audioChannels?: number;
}
/**
* Builds ffmpeg argument array for muxing audio clips.
*/
export function buildMuxFfmpegArgs(options: MuxFfmpegArgsOptions): string[] {
const args: string[] = ["-y"];
for (const input of options.inputs) {
args.push("-i", input);
}
args.push("-filter_complex", options.filter);
args.push("-map", "[out]");
args.push("-codec:a", options.codec);
if (options.audioFrequency !== undefined) {
args.push("-ar", String(options.audioFrequency));
}
if (options.audioChannels !== undefined) {
args.push("-ac", String(options.audioChannels));
}
args.push(options.output);
return args;
}
/**
* Runs ffmpeg with the given arguments.
* Resolves on successful (code 0) exit, rejects on error or non-zero exit.
*/
export function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
logger.debug({ args }, "Starting ffmpeg");
const proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stderrBuf = "";
proc.stderr?.on("data", (chunk: Buffer) => {
stderrBuf += chunk.toString("utf8");
});
proc.on("close", (code) => {
if (code === 0) {
logger.debug("ffmpeg completed successfully");
resolve();
} else {
const detail = stderrBuf.trim().slice(0, 2000);
logger.warn(
{ exitCode: code, stderr: detail },
"ffmpeg exited with non-zero code",
);
reject(new Error(`ffmpeg exited with code ${code}: ${detail}`));
}
});
proc.on("error", (err) => {
logger.error({ error: err.message }, "ffmpeg process error");
reject(err);
});
});
}
@@ -1,573 +0,0 @@
import { type ChildProcess, spawn } from "node:child_process";
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, type Readable } from "node:stream";
import { StreamType } from "@discordjs/voice";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("media-source");
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MediaInfo {
title: string;
duration: number;
thumbnail?: string;
}
export interface MediaSourceResolution {
stream: Readable;
type: StreamType;
title?: string;
duration?: number;
info: MediaInfo;
}
export interface ResolveOptions {
/** Timeout in milliseconds for the yt-dlp process. */
timeout?: number;
/**
* yt-dlp format string override (e.g. "bestaudio[ext=m4a]").
* Defaults to "bestaudio[ext=m4a]/bestaudio/best".
*/
quality?: string;
}
export interface TranscodeResult {
stream: Readable;
/** Kill the ffmpeg child (used on stop/skip). */
cleanup: () => void;
}
/**
* Re-encode a source stream to high-quality OggOpus (48kHz stereo, 192kbps).
*
* Discord voice downmixes whatever we feed it to the channel's bitrate, so the
* best we can do is hand it a clean 48kHz stereo Opus stream instead of the
* raw source (which may be mono, low-bitrate, or a non-Opus container). The
* volume is baked into the encode with `-af volume=` so the player does not
* need inlineVolume re-encoding (double lossy encode).
*/
export function transcodeToHighQualityOgg(
input: Readable,
volume: number,
): TranscodeResult {
const proc = spawn(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-i",
"pipe:0",
"-vn",
"-ac",
"2",
"-ar",
"48000",
"-c:a",
"libopus",
"-b:a",
"192k",
"-af",
`volume=${volume}`,
"-f",
"ogg",
"pipe:1",
],
{ stdio: ["pipe", "pipe", "ignore"] },
);
input.pipe(proc.stdin);
// ffmpeg teardown closes stdin while the upstream source may still write —
// swallow EPIPE / destroyed-stream errors so they don't crash the gateway.
proc.stdin.on("error", (err: NodeJS.ErrnoException) => {
if (
err.code === "EPIPE" ||
err.code === "ERR_STREAM_DESTROYED" ||
err.code === "ERR_STREAM_WRITE_AFTER_END"
) {
logger.debug(
{ code: err.code },
"Transcode stdin closed during teardown",
);
} else {
logger.error({ error: err.message }, "Transcode stdin error");
}
});
activeProcesses.add(proc);
const cleanup = () => {
activeProcesses.delete(proc);
if (proc.exitCode === null) {
proc.kill("SIGKILL");
}
};
proc.once("exit", () => activeProcesses.delete(proc));
// If ffmpeg fails, surface the error to the consumer stream so the
// AudioPlayer's error handler can advance the queue.
const output = proc.stdout;
output.on("error", () => cleanup());
return { stream: output, cleanup };
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Tracks all spawned yt-dlp child processes for shutdown cleanup. */
const activeProcesses: Set<ChildProcess> = new Set();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseSeconds(value: string): number {
const n = Number.parseFloat(value);
return Number.isFinite(n) ? n : 0;
}
/**
* Read the first two newline-delimited lines from a Readable stdout stream.
*
* yt-dlp with `--print before_dl:title --print before_dl:duration` outputs:
* line 1: video title
* line 2: duration in seconds (float)
* rest: raw binary audio data
*
* Returns the parsed header and a new Readable that contains all remaining
* data (the audio stream).
*/
const MAX_HEADER_BUFFER = 65536; // 64KB safety limit for metadata headers
/**
* Read the title + duration header lines from yt-dlp's STDERR.
*
* When yt-dlp streams media to stdout (`-o -`) it redirects its `--print`
* output to STDERR so the media stream on stdout stays clean. The first two
* meaningful lines on stderr are then the title and (before_dl) duration.
*
* Blank lines and `[...]` info prefixes are skipped. An `ERROR:` line is
* reported via `onError` so a failing download surfaces as a resolution error
* instead of a silent empty stream.
*/
function readStderrHeader(
stderr: Readable,
onError: (message: string) => void,
maxBufferSize: number = MAX_HEADER_BUFFER,
): Promise<{ title: string; duration: number }> {
return new Promise((resolve) => {
let buffer = "";
let title = "";
let duration = 0;
let done = false;
const finish = () => {
if (done) return;
done = true;
stderr.removeListener("data", onData);
resolve({ title, duration });
};
const onData = (chunk: Buffer) => {
if (done) return;
buffer += chunk.toString("utf8");
if (buffer.length > maxBufferSize) {
finish();
return;
}
while (!done) {
const nl = buffer.indexOf("\n");
if (nl === -1) break; // need more data
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (line.length === 0) continue; // blank line
if (line.startsWith("[")) continue; // "[info] ..." — not a header
if (line.startsWith("ERROR")) {
onError(line);
finish();
return;
}
if (!title) {
title = line;
} else {
duration = parseSeconds(line);
finish();
return;
}
}
};
stderr.on("data", onData);
stderr.on("end", finish);
});
}
function buildNotInstalledError(): Error {
return new Error(
"yt-dlp is not installed or not found in PATH. " +
'Run "pnpm run install:yt-dlp" to install it.',
);
}
/**
* Build the yt-dlp --cookies args. YouTube blocks anonymous embeds with a
* "Sign in to confirm you're not a bot" 403 unless yt-dlp is given a logged-
* in account's cookies. The path is configurable via GMW_YT_COOKIES_PATH
* (default: the BWS-provided file the deploy writes to /etc/.../ytcookies.txt).
* If the file doesn't exist we pass nothing and fall back to anon (YouTube
* may 403 — playback will fail gracefully, not crash).
*/
var _cachedCookiePath: string | null = null;
function buildCookieArgs(): string[] {
// Single source of truth: BWS injects the account cookies via env
// (gmw_yt_downloader_cookies → GMW_YT_DOWNLOADER_COOKIES by bws-exec).
// We materialize them to a temp Netscape file because yt-dlp --cookies
// only accepts a file path, not stdin, and multiline env values are not
// reliable to pass directly on the spawn argv. Falls back to the on-disk
// file at GMW_YT_COOKIES_PATH (or /etc/gmw-discord-gateway/ytcookies.txt)
// which the Nix deploy writes from BWS once at start.
if (_cachedCookiePath) return ["--cookies", _cachedCookiePath];
const envCookies = process.env.GMW_YT_DOWNLOADER_COOKIES?.trim();
if (envCookies?.includes("LOGIN_INFO")) {
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
writeFileSync(fdPath, envCookies);
try {
chmodSync(fdPath, 0o600);
} catch {
/* best-effort */
}
_cachedCookiePath = fdPath;
logger.info(
{ cookiePath: fdPath, source: "GMW_YT_DOWNLOADER_COOKIES env" },
"Using YouTube cookies (from BWS env)",
);
return ["--cookies", fdPath];
}
const cookiePath =
process.env.GMW_YT_COOKIES_PATH ?? "/etc/gmw-discord-gateway/ytcookies.txt";
try {
if (cookiePath && existsSync(cookiePath)) {
// Never hand the ORIGINAL system file to yt-dlp: recent yt-dlp rewrites
// the cookie file on close (`--cookies` implies write-back). The system
// file is owned by another user (root/deploy) and the service user
// cannot write it → PermissionError → yt-dlp exits 1 → playback
// fails for every attempt. Copy to a per-run temp file (like the env
// branch above) so write-back lands somewhere we own; if the original
// is not readable we fall back to anonymous (YouTube may 403 → the
// screen-share controller retries via Invidious mirrors without auth).
let cookieContents: string;
try {
cookieContents = readFileSync(cookiePath, "utf8");
} catch {
logger.warn(
{ cookiePath },
"Cookie file not readable; continuing without cookies (anon)",
);
return [];
}
const fdPath = join(tmpdir(), `gmw-ytcookies.${process.pid}.txt`);
writeFileSync(fdPath, cookieContents);
try {
chmodSync(fdPath, 0o600);
} catch {
/* best-effort */
}
_cachedCookiePath = fdPath;
logger.info(
{ cookiePath: fdPath, source: "on-disk file (copied)" },
"Using YouTube cookies for yt-dlp",
);
return ["--cookies", fdPath];
}
} catch {
/* ignore — fallback to anon */
}
logger.warn(
"No YouTube cookies available; yt-dlp will use anonymous (YouTube may 403)",
);
return [];
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolve a media URL (YouTube, Spotify, etc.) to a playable audio stream.
*
* Spawns `yt-dlp` with `-o -` so the raw audio bytes stream on stdout. Since
* stdout is the media sink, yt-dlp emits its `--print before_dl:title` /
* `before_dl:duration` header lines on STDERR — the title + duration are read
* from there and the stdout media stream is returned untouched.
*
* The returned stream uses `StreamType.Arbitrary` — suitable for
* `DiscordPlayer.playStream()` with `inputType: StreamType.Arbitrary`.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero code
* before the metadata headers have been parsed.
*/
export function resolveMediaUrl(
url: string,
options?: ResolveOptions,
): Promise<MediaSourceResolution> {
return new Promise<MediaSourceResolution>((resolve, reject) => {
// Fallback chain: YouTube gets progressive m4a audio; direct-file hosts
// (upload mirror / Telegram files) expose a single generic format with an
// arbitrary ID — `bestaudio` alone fails there ("Requested format is not
// available"), so fall back to `bestaudio` then plain `best`.
const format = options?.quality ?? "bestaudio[ext=m4a]/bestaudio/best";
const cookieArgs = buildCookieArgs();
const args = [
"-f",
format,
"-o",
"-",
"--no-progress",
"--no-warnings",
...cookieArgs,
"--print",
"before_dl:title",
"--print",
"before_dl:duration",
url,
];
logger.info({ url }, "Spawning yt-dlp for media resolution");
const proc = spawn("yt-dlp", args, {
stdio: ["ignore", "pipe", "pipe"],
});
activeProcesses.add(proc);
// With `-o -` yt-dlp streams the raw audio on stdout and moves its
// `--print` headers to stderr — pipe stdout immediately so the child
// never blocks on a full pipe while we wait for the headers on stderr.
const mediaStream = new PassThrough();
// Teardown (player stop / ffmpeg exit) destroys this stream while
// yt-dlp may still push bytes — without a listener an EPIPE /
// ERR_STREAM_DESTROYED surfaces as an uncaughtException.
mediaStream.on("error", (err: NodeJS.ErrnoException) => {
if (
err.code === "EPIPE" ||
err.code === "ERR_STREAM_DESTROYED" ||
err.code === "ERR_STREAM_WRITE_AFTER_END"
) {
logger.debug({ code: err.code }, "Media stream closed during teardown");
} else {
logger.error({ error: err.message }, "Media stream error");
}
});
proc.stdout.pipe(mediaStream);
let stderrBuf = "";
let resolved = false;
// -- helpers -----------------------------------------------------------
const failOnce = (err: Error) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
mediaStream.destroy();
reject(err);
};
const resolveOnce = (info: MediaInfo) => {
if (resolved) return;
resolved = true;
activeProcesses.delete(proc);
resolve({
stream: mediaStream,
type: StreamType.Arbitrary,
title: info.title,
duration: info.duration,
info,
});
};
// -- spawn error (ENOENT etc.) ----------------------------------------
proc.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
failOnce(buildNotInstalledError());
} else {
failOnce(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
// -- stderr: title + duration headers ---------------------------------
// Capture raw stderr too, for the exit-diagnostics in the close handler.
const _MAX_STDERR = 4096;
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
if (stderrBuf.length < _MAX_STDERR) {
stderrBuf += chunk
.toString("utf8")
.slice(0, _MAX_STDERR - stderrBuf.length);
}
});
}
readStderrHeader(proc.stderr, (message) => {
failOnce(new Error(message));
})
.then(({ title, duration }) => {
resolveOnce({ title: title || url, duration });
})
.catch((err: Error) => {
failOnce(err);
});
// -- process exit (non-zero means failure) -----------------------------
proc.on("close", (code, signal) => {
activeProcesses.delete(proc);
if (resolved) return;
if (code !== null && code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
failOnce(new Error(`yt-dlp exited with code ${code}${detail}`));
} else if (signal) {
failOnce(new Error(`yt-dlp was killed by signal ${signal}`));
} else {
// Exited cleanly but the header lines never surfaced (e.g. a direct
// file URL with no duration) — keep the media stream alive anyway.
resolveOnce({ title: url, duration: 0 });
}
});
// -- optional timeout --------------------------------------------------
if (options?.timeout && options.timeout > 0) {
const timer = setTimeout(() => {
if (resolved) return;
logger.warn({ url, timeout: options.timeout }, "yt-dlp timed out");
proc.kill("SIGTERM");
failOnce(new Error(`yt-dlp timed out after ${options.timeout}ms`));
}, options.timeout);
proc.once("close", () => clearTimeout(timer));
}
});
}
/**
* Extract metadata (title, duration, thumbnail) from a media URL
* without downloading the audio stream.
*
* Uses `yt-dlp --dump-json` and parses the JSON output.
*
* @throws If yt-dlp is not installed or the process exits with a non-zero
* code.
*/
export async function extractMediaInfo(url: string): Promise<MediaInfo> {
return new Promise<MediaInfo>((resolve, reject) => {
const args = ["--dump-json", "--no-warnings", url];
logger.debug({ url }, "Spawning yt-dlp for metadata extraction");
const proc = spawn("yt-dlp", args, {
stdio: ["pipe", "pipe", "pipe"],
});
activeProcesses.add(proc);
let stdoutBuf = "";
let stderrBuf = "";
const MAX_STDERR = 4096;
const MAX_STDOUT = 1_048_576; // 1MB safety limit
if (proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
if (stdoutBuf.length < MAX_STDOUT) {
stdoutBuf += chunk
.toString("utf8")
.slice(0, MAX_STDOUT - stdoutBuf.length);
}
});
}
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
if (stderrBuf.length < MAX_STDERR) {
stderrBuf += chunk
.toString("utf8")
.slice(0, MAX_STDERR - stderrBuf.length);
}
});
}
proc.on("error", (err: NodeJS.ErrnoException) => {
activeProcesses.delete(proc);
if (err.code === "ENOENT") {
reject(buildNotInstalledError());
} else {
reject(new Error(`yt-dlp failed to start: ${err.message}`));
}
});
proc.on("close", (code) => {
activeProcesses.delete(proc);
if (code !== 0) {
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
reject(
new Error(
`yt-dlp metadata extraction exited with code ${code}${detail}`,
),
);
return;
}
try {
const raw = JSON.parse(stdoutBuf.trim()) as Record<string, unknown>;
resolve({
title: String(raw.title ?? url),
duration: typeof raw.duration === "number" ? raw.duration : 0,
thumbnail: String(raw.thumbnail ?? "") || undefined,
});
} catch (parseErr) {
reject(
new Error(
`Failed to parse yt-dlp JSON output: ${(parseErr as Error).message}`,
),
);
}
});
});
}
/**
* Kill all active yt-dlp / screen-share merge ffmpeg child processes.
*
* Call during graceful shutdown to ensure no orphan processes remain.
*/
export function cleanup(): void {
if (activeProcesses.size === 0) return;
logger.info(
{ count: activeProcesses.size },
"Killing active yt-dlp processes",
);
for (const proc of activeProcesses) {
try {
proc.kill("SIGTERM");
} catch {
// Process may already be dead — ignore
}
}
activeProcesses.clear();
}
@@ -1,76 +0,0 @@
import type { Readable } from "node:stream";
import type { StreamType } from "@discordjs/voice";
export type MediaMode = "music" | "screenshare";
export type MediaSourceKind =
| "url"
| "local"
| "youtube"
| "spotify"
| "search";
export type MediaQueueItemStatus = "queued" | "playing" | "failed";
export interface ResolvedMediaSource {
source: string;
title: string;
kind: MediaSourceKind;
}
export interface MediaQueueItem extends ResolvedMediaSource {
id: string;
mode: MediaMode;
requestedBy: string;
addedAt: number;
status: MediaQueueItemStatus;
/** Duration in seconds, if known. */
duration?: number;
/** Thumbnail/preview URL, if available. */
thumbnailUrl?: string;
}
export interface MediaState {
playing: boolean;
activeMode: MediaMode | null;
musicVolume: number;
loop: boolean;
current: MediaQueueItem | null;
queue: MediaQueueItem[];
}
export interface QueueMediaOptions {
mode?: MediaMode;
requestedBy?: string;
}
export interface MusicPlayback {
done: Promise<void>;
stop(): void;
}
export interface MusicPlayer {
play(source: ResolvedMediaSource): MusicPlayback;
}
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music";
export interface DiscordPlayOptions {
inputType?: StreamType;
/** Enable volume control via resource.volume (required for setMusicVolume to work). */
inlineVolume?: boolean;
volume?: number;
}
export interface DiscordAudioPlayer {
getOwner(): DiscordPlayerOwner;
isConnected(): boolean;
playStream(
stream: Readable,
owner: DiscordPlayerOwner,
options?: DiscordPlayOptions,
): void;
pause(owner?: DiscordPlayerOwner): void;
unpause(owner?: DiscordPlayerOwner): boolean;
stop(owner?: DiscordPlayerOwner): void;
getMusicVolume(): number;
setMusicVolume(volume: number): void;
}
@@ -1,193 +0,0 @@
import { eq } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { createChildLogger } from "@/shared/logger/index";
import { getDatabase } from "../../shared/database/drizzle.js";
import type * as schema from "../../shared/database/schema.js";
import { muxerJobsTable } from "../../shared/database/schema.js";
import { buildMuxFfmpegArgs, runFfmpeg } from "./ffmpegProcess.js";
const logger = createChildLogger("muxer");
// ─── Types ─────────────────────────────────────────────────────────────────────────────
export interface MuxerJobData {
inputs: string[];
output: string;
guildId: string;
channelId: string;
sessionId: string;
}
// ─── State ───────────────────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
// ─── Public API ──────────────────────────────────────────────────────────
/**
* Enqueue a muxer job that merges multiple OGG files into one.
*/
export async function enqueueMuxerJob(data: MuxerJobData): Promise<void> {
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
const id = `${data.sessionId}-${Date.now()}`;
try {
await db.insert(muxerJobsTable).values({
id,
data: JSON.stringify(data),
status: "pending",
attempts: 0,
maxAttempts: 3,
createdAt: Date.now(),
updatedAt: Date.now(),
});
logger.info({ id, sessionId: data.sessionId }, "Muxer job enqueued");
} catch (error) {
logger.error(
{ id, error: error instanceof Error ? error.message : String(error) },
"Failed to enqueue muxer job",
);
}
}
/**
* Start the background worker that polls for pending muxer jobs.
*/
export function startMuxerWorker(): void {
if (pollTimer) return;
logger.info("Starting muxer worker (interval: 10s)");
pollTimer = setInterval(() => {
processNextJobs().catch((err: unknown) => {
logger.error({ error: String(err) }, "Muxer worker tick failed");
});
}, 10_000);
}
/**
* Stop the background worker.
*/
export function stopMuxerWorker(): void {
if (!pollTimer) return;
clearInterval(pollTimer);
pollTimer = null;
logger.info("Muxer worker stopped");
}
// ─── Internal ────────────────────────────────────────────────────────────
async function processNextJobs(): Promise<void> {
const db = getDatabase() as unknown as NodePgDatabase<typeof schema>;
try {
const jobs = await db
.select()
.from(muxerJobsTable)
.where(eq(muxerJobsTable.status, "pending"))
.limit(5);
if (jobs.length === 0) return;
const results = await Promise.allSettled(
jobs.map((job) => processJob(db, job)),
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === "rejected") {
logger.error(
{ jobId: jobs[i].id, error: result.reason },
"Muxer job failed",
);
}
}
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"Failed to fetch pending muxer jobs",
);
}
}
async function processJob(
db: NodePgDatabase<typeof schema>,
job: typeof muxerJobsTable.$inferSelect,
): Promise<void> {
// Mark as processing
await db
.update(muxerJobsTable)
.set({ status: "processing", updatedAt: Date.now() })
.where(eq(muxerJobsTable.id, job.id));
try {
const data = JSON.parse(job.data) as MuxerJobData;
if (!data.inputs || data.inputs.length < 2) {
throw new Error(
`Muxer job ${job.id} needs at least 2 inputs, got ${data.inputs?.length ?? 0}`,
);
}
logger.info(
{ jobId: job.id, inputs: data.inputs.length, output: data.output },
"Processing muxer job",
);
// Sequential concat of all OGG segments (amix = simultaneous mix, wrong for this)
const inputLabels = data.inputs.map((_, i) => `[${i}:a:0]`);
const n = data.inputs.length;
const filterComplex = `${inputLabels.join("")}concat=n=${n}:v=0:a=1[out]`;
const args = buildMuxFfmpegArgs({
inputs: data.inputs,
filter: filterComplex,
output: data.output,
codec: "libmp3lame",
audioFrequency: 48000,
audioChannels: 2,
});
await runFfmpeg(args);
// Mark as completed
await db
.update(muxerJobsTable)
.set({ status: "completed", updatedAt: Date.now() })
.where(eq(muxerJobsTable.id, job.id));
logger.info({ jobId: job.id, output: data.output }, "Muxer job completed");
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
const newAttempts = job.attempts + 1;
if (newAttempts >= job.maxAttempts) {
await db
.update(muxerJobsTable)
.set({
status: "failed",
error: errMsg,
attempts: newAttempts,
updatedAt: Date.now(),
})
.where(eq(muxerJobsTable.id, job.id));
logger.error(
{ jobId: job.id, error: errMsg },
"Muxer job failed permanently",
);
} else {
await db
.update(muxerJobsTable)
.set({
status: "pending",
error: errMsg,
attempts: newAttempts,
updatedAt: Date.now(),
})
.where(eq(muxerJobsTable.id, job.id));
logger.warn(
{ jobId: job.id, error: errMsg, attempt: newAttempts },
"Muxer job will be retried",
);
}
}
}
@@ -1,45 +0,0 @@
import { Transform, type TransformCallback } from "node:stream";
import { createChildLogger } from "@/shared/logger/index";
const _logger = createChildLogger("packet-filter");
/**
* Transform stream to filter out audio packets that are too small.
* Packets that are too small are likely to fail decryption by Discord.
*/
export class PacketFilter extends Transform {
private minPacketSize: number;
private filteredCount: number = 0;
private totalCount: number = 0;
constructor(minPacketSize: number = 10) {
super();
this.minPacketSize = minPacketSize;
}
_transform(
chunk: Buffer,
_encoding: string,
callback: TransformCallback,
): void {
this.totalCount++;
// Filter out undersized packets
if (chunk.length >= this.minPacketSize) {
this.push(chunk);
} else {
this.filteredCount++;
}
callback();
}
/** Returns the number of packets filtered out and processed. */
getStats(): { filtered: number; total: number } {
return { filtered: this.filteredCount, total: this.totalCount };
}
_flush(callback: TransformCallback): void {
callback();
}
}

Some files were not shown because too many files have changed in this diff Show More