refactor: remove voice, recording, and music/media features (gateway + backend)
- Gateway: delete voice-recording/, voice-pcm-ws/, voice/video/media handlers, vendor/discord-voice-fork/, voice DB repos, 2 voice migrations - Gateway: cut voice wiring from bootstrap/shutdown/commandHandler/handler-registry, eventBroadcaster/eventTypes, redis-channels, moderation-types, message-capture, config keys, and deps (@discordjs/voice, opus, libsodium, prism-media, davey) - Backend: delete voice/, recordings/, media/ modules + @discordjs/voice dep - Backend: cut voice/media/recordings oRPC routers, WS gateway-PCM auth + voice handlers, Redis bridge voice aggregation, redis-channels voice/media constants, config keys, moderation-types voice items, e2e voice/recordings suites - Keep voice DB tables (destructive migration avoided); chatbot voiceRecordings tool + dashboard count remain as read-only historical data access
This commit is contained in:
@@ -18,31 +18,12 @@ MONITOR_GUILD_ID=your_guild_id_here # Target guild for text monitoring
|
|||||||
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
|
TEXT_GUILD_ID=optional_text_guild_id # Override text capture guild (falls back to MONITOR_GUILD_ID)
|
||||||
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
|
TEXT_CHANNEL_ID=optional_text_channel_id # Restrict text capture to a single channel
|
||||||
|
|
||||||
# === Voice Channels ===
|
|
||||||
# VOICE_GUILD_ID= # Guild for voice connection (optional)
|
|
||||||
# VOICE_CHANNEL_ID= # Channel for voice connection (optional)
|
|
||||||
|
|
||||||
# === Recording ===
|
|
||||||
RECORDINGS_DIR=./recordings # Audio file output directory (default: ./recordings)
|
|
||||||
RECORDING_SEGMENT_MS=5000 # OGG segment duration in ms (default: 5000)
|
|
||||||
|
|
||||||
# === Decoder ===
|
|
||||||
DECODER_ROTATE_MS=5000 # Opus decoder rotation interval in ms (default: 5000)
|
|
||||||
DECODER_COOLDOWN_MS=30000 # Decoder error cooldown in ms (default: 30000)
|
|
||||||
|
|
||||||
# === Audio ===
|
|
||||||
AUDIO_STREAM_SILENCE_DURATION_MS=3000 # Silence threshold in ms before stopping stream (default: 3000)
|
|
||||||
PACKET_FILTER_MIN_SIZE=8 # Minimum Opus packet size in bytes (default: 8)
|
|
||||||
OPUS_FRAME_SIZE=960 # Opus frame size in samples (default: 960)
|
|
||||||
AUDIO_SAMPLE_RATE=48000 # Audio sample rate in Hz (default: 48000)
|
|
||||||
AUDIO_CHANNELS=2 # Number of audio channels (default: 2)
|
|
||||||
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
AVATAR_SIZE=64 # User avatar size in pixels (default: 64)
|
||||||
|
|
||||||
# === Webserver ===
|
# === Webserver ===
|
||||||
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
WEBSERVER_PORT=4001 # Backend HTTP/WS server port (default: 4001)
|
||||||
|
|
||||||
# === Connection ===
|
# === Connection ===
|
||||||
VOICE_CONNECTION_TIMEOUT_MS=15000 # Voice connection timeout in ms (default: 15000)
|
|
||||||
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
|
RECONNECT_TIMEOUT_MS=5000 # Reconnect timeout in ms (default: 5000)
|
||||||
|
|
||||||
# === Logging ===
|
# === Logging ===
|
||||||
@@ -69,11 +50,6 @@ POSTGRES_POOL_MAX=10 # Maximum pool connections (default: 10)
|
|||||||
# === Redis ===
|
# === Redis ===
|
||||||
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
|
REDIS_URL=redis://100.121.180.82:6379 # Redis connection string (default: redis://localhost:6379)
|
||||||
|
|
||||||
# === Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ===
|
|
||||||
VOICE_PCM_WS_ENABLED=true # Use direct WS for PCM audio (default: true)
|
|
||||||
BACKEND_WS_URL=ws://backend:4001/ws # Backend WebSocket URL for gateway PCM streaming
|
|
||||||
BACKEND_WS_TOKEN= # REQUIRED if VOICE_PCM_WS_ENABLED=true. Internal shared secret
|
|
||||||
|
|
||||||
# === Attachments ===
|
# === Attachments ===
|
||||||
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
|
TELE_UPLOAD_URL=https://upload.asepharyana.my.id/api/upload # Attachment upload endpoint (default)
|
||||||
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
|
ATTACHMENT_UPLOAD_TIMEOUT_MS=30000 # Upload timeout in ms (default: 30000)
|
||||||
@@ -132,7 +108,6 @@ AUTO_DELETE_LOG_CHANNEL_ID= # Channel ID to log auto-delete actions
|
|||||||
# === Retention (0 = disabled) ===
|
# === Retention (0 = disabled) ===
|
||||||
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
|
RETENTION_MESSAGES_DAYS=0 # Message retention in days (default: 0 = off)
|
||||||
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
|
RETENTION_ATTACHMENTS_DAYS=0 # Attachment retention in days (default: 0 = off)
|
||||||
RETENTION_VOICE_DAYS=0 # Voice recording retention in days (default: 0 = off)
|
|
||||||
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
|
RETENTION_CLEANUP_INTERVAL_MS=86400000 # Cleanup interval in ms (default: 24h)
|
||||||
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
|
RETENTION_DRY_RUN=true # Dry-run: log but do not delete (default: true)
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/voice": "^0.19.2",
|
|
||||||
"@orpc/server": "1.15.2",
|
"@orpc/server": "1.15.2",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"dotenv": "^18.0.1",
|
"dotenv": "^18.0.1",
|
||||||
|
|||||||
@@ -41,20 +41,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", () => {
|
describe("API Config", () => {
|
||||||
it("GET /config returns 200", async () => {
|
it("GET /config returns 200", async () => {
|
||||||
const { status } = await api("/config");
|
const { status } = await api("/config");
|
||||||
@@ -62,13 +48,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", () => {
|
describe("API Negative", () => {
|
||||||
it("GET /nonexistent returns 404", async () => {
|
it("GET /nonexistent returns 404", async () => {
|
||||||
const { status } = await api("/nonexistent");
|
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,33 +5,13 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
|||||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||||
import { knowledgeService } from "../modules/knowledge/knowledge.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 {
|
import {
|
||||||
messageQuerySchema,
|
messageQuerySchema,
|
||||||
semanticSearchSchema,
|
semanticSearchSchema,
|
||||||
} from "../modules/messages/messages.schema";
|
} from "../modules/messages/messages.schema";
|
||||||
import { messagesService } from "../modules/messages/messages.service";
|
import { messagesService } from "../modules/messages/messages.service";
|
||||||
import { moderationService } from "../modules/moderation/moderation.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 { 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 { config } from "../shared/config/index";
|
||||||
import { publishCommandNoReply } from "../shared/redis/index";
|
import { publishCommandNoReply } from "../shared/redis/index";
|
||||||
|
|
||||||
@@ -238,86 +218,8 @@ const moderationRouter = {
|
|||||||
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Media ────────────────────────────────────────────────────────
|
// ── Recordings (removed) / Voice (removed) ─────────────────────
|
||||||
const mediaRouter = {
|
// Music/media playback (media.mjs) was removed with the voice feature.
|
||||||
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) ──────────────────────────────────────────────
|
// ── Analysis (search) ──────────────────────────────────────────────
|
||||||
const analysisRouter = {
|
const analysisRouter = {
|
||||||
search: os
|
search: os
|
||||||
@@ -416,11 +318,8 @@ const configRouter = {
|
|||||||
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
backlogSyncBatchSize: config.BACKLOG_SYNC_BATCH_SIZE,
|
||||||
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
retentionMessagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
retentionAttachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
retentionVoiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
autoDeleteFlaggedEnabled: config.AUTO_DELETE_FLAGGED_ENABLED,
|
||||||
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
aiAnalysisEnabled: config.AI_ANALYSIS_ENABLED,
|
||||||
voiceGuildId: config.VOICE_GUILD_ID || null,
|
|
||||||
voiceChannelId: config.VOICE_CHANNEL_ID || null,
|
|
||||||
logLevel: config.LOG_LEVEL,
|
logLevel: config.LOG_LEVEL,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -438,9 +337,6 @@ export const appRouter = {
|
|||||||
dashboard: dashboardRouter,
|
dashboard: dashboardRouter,
|
||||||
messages: messagesRouter,
|
messages: messagesRouter,
|
||||||
moderation: moderationRouter,
|
moderation: moderationRouter,
|
||||||
media: mediaRouter,
|
|
||||||
voice: voiceRouter,
|
|
||||||
recordings: recordingsRouter,
|
|
||||||
analysis: analysisRouter,
|
analysis: analysisRouter,
|
||||||
chatbot: chatbotRouter,
|
chatbot: chatbotRouter,
|
||||||
config: configRouter,
|
config: configRouter,
|
||||||
|
|||||||
@@ -33,29 +33,6 @@ export const configSchema = z
|
|||||||
.transform((v) => v.split(",").filter(Boolean))
|
.transform((v) => v.split(",").filter(Boolean))
|
||||||
.describe("Thread IDs to exclude from capture"),
|
.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 ───────────────────────────────────────────────────────────
|
// ── Server ───────────────────────────────────────────────────────────
|
||||||
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
|
WEBSERVER_PORT: z.coerce.number().positive().default(3001),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
@@ -92,17 +69,8 @@ export const configSchema = z
|
|||||||
|
|
||||||
// ── Redis ────────────────────────────────────────────────────────────
|
// ── Redis ────────────────────────────────────────────────────────────
|
||||||
REDIS_URL: z.string().default("redis://localhost:6379"),
|
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 ───────────────────────────────────────────────────────
|
// ── Connection ───────────────────────────────────────────────────────
|
||||||
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
|
|
||||||
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
|
||||||
|
|
||||||
// ── Attachments ─────────────────────────────────────────────────────
|
// ── Attachments ─────────────────────────────────────────────────────
|
||||||
@@ -203,13 +171,6 @@ export const configSchema = z
|
|||||||
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
|
PISCINA_MAX_THREADS: z.coerce.number().int().positive().optional(),
|
||||||
PISCINA_MEDIA_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 ───────────────────────────────────────────────
|
||||||
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
OPENAI_MODERATION_API_KEY: z.string().optional(),
|
||||||
OPENAI_MODERATION_BASE_URL: z
|
OPENAI_MODERATION_BASE_URL: z
|
||||||
@@ -252,7 +213,6 @@ export const configSchema = z
|
|||||||
// ── Retention ───────────────────────────────────────────────────────
|
// ── Retention ───────────────────────────────────────────────────────
|
||||||
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
RETENTION_MESSAGES_DAYS: z.coerce.number().int().min(0).default(0),
|
||||||
RETENTION_ATTACHMENTS_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
|
RETENTION_CLEANUP_INTERVAL_MS: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
@@ -291,7 +251,6 @@ export const configSchema = z
|
|||||||
|
|
||||||
export type AppConfig = z.infer<typeof configSchema> & {
|
export type AppConfig = z.infer<typeof configSchema> & {
|
||||||
EFFECTIVE_TEXT_GUILD_ID?: string;
|
EFFECTIVE_TEXT_GUILD_ID?: string;
|
||||||
EFFECTIVE_VOICE_GUILD_ID?: string;
|
|
||||||
EFFECTIVE_MONITOR_GUILD_IDS: string[];
|
EFFECTIVE_MONITOR_GUILD_IDS: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,7 +260,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
EFFECTIVE_TEXT_GUILD_ID: parsed.TEXT_GUILD_ID ?? parsed.MONITOR_GUILD_ID,
|
||||||
EFFECTIVE_VOICE_GUILD_ID: parsed.VOICE_GUILD_ID,
|
|
||||||
EFFECTIVE_MONITOR_GUILD_IDS:
|
EFFECTIVE_MONITOR_GUILD_IDS:
|
||||||
parsed.MONITOR_GUILD_IDS.length > 0
|
parsed.MONITOR_GUILD_IDS.length > 0
|
||||||
? parsed.MONITOR_GUILD_IDS
|
? parsed.MONITOR_GUILD_IDS
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export interface BroadcasterClient {
|
|||||||
messageAnalyzed: (data: unknown) => void;
|
messageAnalyzed: (data: unknown) => void;
|
||||||
attachmentCreated: (data: unknown) => void;
|
attachmentCreated: (data: unknown) => void;
|
||||||
attachmentUploaded: (data: unknown) => void;
|
attachmentUploaded: (data: unknown) => void;
|
||||||
voiceRecordingStarted: (data: unknown) => void;
|
|
||||||
voiceRecordingStopped: (data: unknown) => void;
|
|
||||||
voiceRecordingUploaded: (data: unknown) => void;
|
|
||||||
analysisQueueStatus: (data: unknown) => void;
|
analysisQueueStatus: (data: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,17 +99,6 @@ export interface AttachmentRecord {
|
|||||||
uploaded_at: number | null;
|
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 {
|
export interface DashboardMessage {
|
||||||
id: string;
|
id: string;
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
@@ -121,7 +107,7 @@ export interface DashboardMessage {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
type: "text" | "image" | "voice";
|
type: "text" | "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageQuery {
|
export interface MessageQuery {
|
||||||
@@ -154,23 +140,6 @@ export interface AnalysisResult {
|
|||||||
evidence?: string[];
|
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 {
|
export interface AnalysisQueueStatus {
|
||||||
queuedConversations: number;
|
queuedConversations: number;
|
||||||
activeRequests: number;
|
activeRequests: number;
|
||||||
@@ -221,7 +190,6 @@ export interface RetentionPolicy {
|
|||||||
channel_id: string | null;
|
channel_id: string | null;
|
||||||
retention_days: number;
|
retention_days: number;
|
||||||
apply_to_media: boolean;
|
apply_to_media: boolean;
|
||||||
apply_to_voice: boolean;
|
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_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_MESSAGE_ANALYZED = "discord:message:analyzed";
|
||||||
export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created";
|
export const DISCORD_ATTACHMENT_CREATED = "discord:attachment:created";
|
||||||
export const DISCORD_ATTACHMENT_UPLOADED = "discord:attachment:uploaded";
|
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_ANALYSIS_QUEUE_STATUS = "discord:analysis:queue_status";
|
||||||
export const DISCORD_REACTION_ADDED = "discord:reaction:added";
|
export const DISCORD_REACTION_ADDED = "discord:reaction:added";
|
||||||
export const DISCORD_REACTION_REMOVED = "discord:reaction:removed";
|
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_COMMAND = "backend:command";
|
||||||
export const BACKEND_VOICE_TRANSMIT = "backend:voice:transmit";
|
|
||||||
export const BACKEND_COMMAND_REPLY_PREFIX = "backend:command:reply:";
|
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)
|
// 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_LIST = "guilds:list";
|
||||||
export const COMMAND_GUILDS_TEXT_CHANNELS = "guilds:text-channels";
|
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 COMMAND_MODERATION_ACTION = "moderation:action";
|
||||||
export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Event envelope — used by discord-gateway when publishing to Redis
|
// 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_MESSAGE_ANALYZED]: "message_analyzed",
|
||||||
[DISCORD_ATTACHMENT_CREATED]: "attachment_created",
|
[DISCORD_ATTACHMENT_CREATED]: "attachment_created",
|
||||||
[DISCORD_ATTACHMENT_UPLOADED]: "attachment_uploaded",
|
[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_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_ADDED]: "reaction_added",
|
||||||
[DISCORD_REACTION_REMOVED]: "reaction_removed",
|
[DISCORD_REACTION_REMOVED]: "reaction_removed",
|
||||||
[DISCORD_THREAD_CREATED]: "thread_created",
|
[DISCORD_THREAD_CREATED]: "thread_created",
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export function clearBroadcastFunctions(): void {
|
|||||||
function shouldLog(type: string): boolean {
|
function shouldLog(type: string): boolean {
|
||||||
if (!_enabled) return false;
|
if (!_enabled) return false;
|
||||||
// Avoid logging high-volume events
|
// Avoid logging high-volume events
|
||||||
if (type === "voice_pcm_data") return false;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import {
|
|
||||||
clearAllSpeakers,
|
|
||||||
recordSpeaker,
|
|
||||||
} from "../modules/voice/live-speaker.js";
|
|
||||||
import { config } from "../shared/config/index.js";
|
import { config } from "../shared/config/index.js";
|
||||||
import {
|
import { DISCORD_CHANNEL_TO_WS_EVENT } from "../shared/index.js";
|
||||||
DISCORD_CHANNEL_TO_WS_EVENT,
|
|
||||||
DISCORD_VOICE_ACTIVE_USER,
|
|
||||||
DISCORD_VOICE_PCM,
|
|
||||||
DISCORD_VOICE_STOPPED,
|
|
||||||
} from "../shared/index.js";
|
|
||||||
import { createChildLogger } from "../shared/logger/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");
|
const logger = createChildLogger("ws.redis-bridge");
|
||||||
|
|
||||||
@@ -49,67 +40,10 @@ function handleSubscriptionMessage(channel: string, message: string): void {
|
|||||||
// We only want <actual payload>, not the full envelope.
|
// We only want <actual payload>, not the full envelope.
|
||||||
const data = envelope.data !== undefined ? envelope.data : 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");
|
logger.debug({ channel, eventType }, "Broadcasting Redis event");
|
||||||
broadcastEvent(eventType, data);
|
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> {
|
export async function startRedisBridge(): Promise<void> {
|
||||||
if (!config.REDIS_URL) {
|
if (!config.REDIS_URL) {
|
||||||
logger.info("Redis not configured, skipping Redis bridge");
|
logger.info("Redis not configured, skipping Redis bridge");
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { IncomingMessage, Server } from "node:http";
|
|||||||
import type { Duplex } from "node:stream";
|
import type { Duplex } from "node:stream";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { messagesService } from "../modules/messages/messages.service.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 { createChildLogger } from "../shared/logger/index.js";
|
||||||
import { setBroadcastFunctions } from "./broadcast.js";
|
import { setBroadcastFunctions } from "./broadcast.js";
|
||||||
|
|
||||||
@@ -17,8 +15,6 @@ interface BroadcastEvent {
|
|||||||
|
|
||||||
interface JsonMessage {
|
interface JsonMessage {
|
||||||
type: string;
|
type: string;
|
||||||
buffer?: string;
|
|
||||||
command?: string;
|
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,36 +50,6 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, "Failed to send initial ui_state");
|
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 {
|
export function closeWebSocketServer(): void {
|
||||||
@@ -94,9 +60,7 @@ export function closeWebSocketServer(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createWebSocketServer(server: Server): WebSocketServer {
|
export function createWebSocketServer(server: Server): WebSocketServer {
|
||||||
// Separate tracking: gateway sends PCM → forwarded to frontend only
|
|
||||||
const frontendClients = new Set<WebSocket>();
|
const frontendClients = new Set<WebSocket>();
|
||||||
const gatewayClients = new Set<WebSocket>();
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
|
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
|
||||||
_wss = wss;
|
_wss = wss;
|
||||||
@@ -115,35 +79,6 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
// Map-based dispatcher for JSON WebSocket message types
|
// Map-based dispatcher for JSON WebSocket message types
|
||||||
const jsonHandlers = new Map<string, MessageHandler>();
|
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) => {
|
jsonHandlers.set("stream_messages", async (ws, message) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
const payload = (message.payload ?? {}) as {
|
const payload = (message.payload ?? {}) as {
|
||||||
@@ -208,74 +143,15 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on("connection", (ws: WebSocket, req) => {
|
wss.on("connection", (ws: WebSocket) => {
|
||||||
// Parse auth token from query string
|
frontendClients.add(ws);
|
||||||
const rawUrl = req.url ?? "/";
|
logger.info(`Frontend client connected (${frontendClients.size} total)`);
|
||||||
let isGateway = false;
|
// Send initial states (user, ui) — fire-and-forget
|
||||||
|
sendInitialStates(ws).catch((err) =>
|
||||||
try {
|
logger.error({ err }, "sendInitialStates failed"),
|
||||||
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"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.on("message", (data: Buffer) => {
|
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
|
// Handle JSON messages from browser
|
||||||
if (
|
if (
|
||||||
typeof data === "string" ||
|
typeof data === "string" ||
|
||||||
@@ -296,24 +172,15 @@ export function createWebSocketServer(server: Server): WebSocketServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
if (isGateway) {
|
frontendClients.delete(ws);
|
||||||
gatewayClients.delete(ws);
|
logger.info(
|
||||||
logger.info("Discord gateway WebSocket disconnected");
|
`Frontend client disconnected (${frontendClients.size} total)`,
|
||||||
} else {
|
);
|
||||||
frontendClients.delete(ws);
|
|
||||||
logger.info(
|
|
||||||
`Frontend client disconnected (${frontendClients.size} total)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", (err: Error) => {
|
ws.on("error", (err: Error) => {
|
||||||
logger.error({ err }, "WebSocket client error");
|
logger.error({ err }, "WebSocket client error");
|
||||||
if (isGateway) {
|
frontendClients.delete(ws);
|
||||||
gatewayClients.delete(ws);
|
|
||||||
} else {
|
|
||||||
frontendClients.delete(ws);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"tag": "0010_add_reactions_and_edit_history",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"idx": 11,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1785600000000,
|
|
||||||
"tag": "0011_add_voice_transcription",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"idx": 12,
|
"idx": 12,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
@@ -141,13 +134,6 @@
|
|||||||
"when": 1787830000000,
|
"when": 1787830000000,
|
||||||
"tag": "0019_rename_server_name_to_server_nick",
|
"tag": "0019_rename_server_name_to_server_nick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 20,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1787860000000,
|
|
||||||
"tag": "0020_add_voice_auto_reconnect",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@bete/discord-gateway",
|
"name": "@bete/discord-gateway",
|
||||||
"version": "1.0.0",
|
"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",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@discordjs/opus",
|
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"sharp"
|
"sharp"
|
||||||
]
|
]
|
||||||
@@ -24,25 +23,19 @@
|
|||||||
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
|
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/opus": "^0.10.0",
|
|
||||||
"@discordjs/voice": "file:vendor/discord-voice-fork",
|
|
||||||
"@snazzah/davey": "^0.1.11",
|
|
||||||
"@typesafe-ai/sdk": "^0.6.0",
|
"@typesafe-ai/sdk": "^0.6.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"dotenv": "^18.0.0",
|
"dotenv": "^18.0.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"ioredis": "^6.0.0",
|
"ioredis": "^6.0.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
|
||||||
"lru-cache": "^11.5.3",
|
"lru-cache": "^11.5.3",
|
||||||
"openai": "^7.19.0",
|
"openai": "^7.19.0",
|
||||||
"opusscript": "^0.1.1",
|
|
||||||
"p-limit": "^7.3.3",
|
"p-limit": "^7.3.3",
|
||||||
"p-retry": "^8.0.1",
|
"p-retry": "^8.0.1",
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"piscina": "^5.3.2",
|
"piscina": "^5.3.2",
|
||||||
"prism-media": "2.0.0-alpha.0",
|
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"tiktoken": "^1.0.22",
|
"tiktoken": "^1.0.22",
|
||||||
"ws": "^8.21.3",
|
"ws": "^8.21.3",
|
||||||
|
|||||||
@@ -33,18 +33,6 @@ import { startDigestScheduler } from "../modules/monitor/digestScheduler.js";
|
|||||||
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
import { registerReactionCapture } from "../modules/reaction-tracking/index.js";
|
||||||
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
import { registerThreadCapture } from "../modules/thread-tracking/index.js";
|
||||||
import { registerPresenceCapture } from "../modules/user-presence/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 { config } from "../shared/config/config.js";
|
||||||
import {
|
import {
|
||||||
closeDatabase,
|
closeDatabase,
|
||||||
@@ -53,11 +41,7 @@ import {
|
|||||||
} from "../shared/database/drizzle.js";
|
} from "../shared/database/drizzle.js";
|
||||||
import { runMigrations } from "../shared/database/migrate.js";
|
import { runMigrations } from "../shared/database/migrate.js";
|
||||||
import type * as schema from "../shared/database/schema.js";
|
import type * as schema from "../shared/database/schema.js";
|
||||||
import {
|
import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
|
||||||
attachmentsTable,
|
|
||||||
messagesTable,
|
|
||||||
voiceRecordingsTable,
|
|
||||||
} from "../shared/database/schema.js";
|
|
||||||
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
|
import { createDiscordClientOptions } from "../shared/discord/clientOptions.js";
|
||||||
import { createGracefulShutdown } from "./shutdown.js";
|
import { createGracefulShutdown } from "./shutdown.js";
|
||||||
|
|
||||||
@@ -124,7 +108,6 @@ function startRetentionCleanup(): void {
|
|||||||
dryRun,
|
dryRun,
|
||||||
messagesDays: config.RETENTION_MESSAGES_DAYS,
|
messagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
voiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
},
|
},
|
||||||
"Starting retention cleanup scheduler",
|
"Starting retention cleanup scheduler",
|
||||||
);
|
);
|
||||||
@@ -144,13 +127,6 @@ function startRetentionCleanup(): void {
|
|||||||
dryRun,
|
dryRun,
|
||||||
"attachments",
|
"attachments",
|
||||||
);
|
);
|
||||||
await deleteExpiredRecords(
|
|
||||||
voiceRecordingsTable,
|
|
||||||
voiceRecordingsTable.created_at,
|
|
||||||
config.RETENTION_VOICE_DAYS,
|
|
||||||
dryRun,
|
|
||||||
"voice recordings",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run immediately on start, then schedule
|
// Run immediately on start, then schedule
|
||||||
@@ -188,13 +164,6 @@ export async function initializeDiscordGateway() {
|
|||||||
|
|
||||||
logger.info("Creating Discord client");
|
logger.info("Creating Discord client");
|
||||||
const client = new Client(createDiscordClientOptions());
|
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
|
// Initialize Redis event broadcaster
|
||||||
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
|
const redisPublisher = new RedisEventPublisher(config.REDIS_URL, logger);
|
||||||
@@ -203,33 +172,13 @@ export async function initializeDiscordGateway() {
|
|||||||
// Initialize Redis command handler for backend→gateway commands
|
// Initialize Redis command handler for backend→gateway commands
|
||||||
const commandHandler = new CommandHandler();
|
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({
|
const gracefulShutdown = createGracefulShutdown({
|
||||||
logger,
|
logger,
|
||||||
closeDatabase,
|
closeDatabase,
|
||||||
voiceController,
|
|
||||||
client,
|
client,
|
||||||
eventBroadcaster,
|
eventBroadcaster,
|
||||||
commandHandler,
|
commandHandler,
|
||||||
stopMetricsServer,
|
stopMetricsServer,
|
||||||
pcmWsClient,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -255,8 +204,6 @@ export async function initializeDiscordGateway() {
|
|||||||
|
|
||||||
client.on("debug", (msg) => {
|
client.on("debug", (msg) => {
|
||||||
if (
|
if (
|
||||||
msg.includes("[VOICE") ||
|
|
||||||
msg.includes("[ffmpeg") ||
|
|
||||||
msg.toLowerCase().includes("error") ||
|
msg.toLowerCase().includes("error") ||
|
||||||
msg.toLowerCase().includes("stream")
|
msg.toLowerCase().includes("stream")
|
||||||
) {
|
) {
|
||||||
@@ -269,7 +216,6 @@ export async function initializeDiscordGateway() {
|
|||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
setMessageCaptureEventBroadcaster(eventBroadcaster);
|
||||||
setRecorderEventBroadcaster(eventBroadcaster);
|
|
||||||
setModerationEventBroadcaster(eventBroadcaster);
|
setModerationEventBroadcaster(eventBroadcaster);
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
||||||
@@ -281,30 +227,10 @@ export async function initializeDiscordGateway() {
|
|||||||
registerChannelTopicCapture(client, eventBroadcaster);
|
registerChannelTopicCapture(client, eventBroadcaster);
|
||||||
registerGuildMemberEvents(client, eventBroadcaster);
|
registerGuildMemberEvents(client, eventBroadcaster);
|
||||||
|
|
||||||
// Start background workers
|
|
||||||
startMuxerWorker();
|
|
||||||
|
|
||||||
// Start command handler after Discord is ready
|
// Start command handler after Discord is ready
|
||||||
commandHandler.start(client, voiceController);
|
commandHandler.start(client);
|
||||||
logger.info("Command handler started");
|
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
|
// Start retention cleanup scheduler
|
||||||
startRetentionCleanup();
|
startRetentionCleanup();
|
||||||
// Start weekly moderation digest (public, automated)
|
// Start weekly moderation digest (public, automated)
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import { createChildLogger } from "@/shared/logger/index";
|
|||||||
import { config } from "../shared/config/config.js";
|
import { config } from "../shared/config/config.js";
|
||||||
import { getDatabase } from "../shared/database/drizzle.js";
|
import { getDatabase } from "../shared/database/drizzle.js";
|
||||||
import type * as schema from "../shared/database/schema.js";
|
import type * as schema from "../shared/database/schema.js";
|
||||||
import {
|
import { attachmentsTable, messagesTable } from "../shared/database/schema.js";
|
||||||
attachmentsTable,
|
|
||||||
messagesTable,
|
|
||||||
voiceRecordingsTable,
|
|
||||||
} from "../shared/database/schema.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("discord-gateway");
|
const logger = createChildLogger("discord-gateway");
|
||||||
|
|
||||||
@@ -73,7 +69,6 @@ function startRetentionCleanup(): void {
|
|||||||
dryRun,
|
dryRun,
|
||||||
messagesDays: config.RETENTION_MESSAGES_DAYS,
|
messagesDays: config.RETENTION_MESSAGES_DAYS,
|
||||||
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
attachmentsDays: config.RETENTION_ATTACHMENTS_DAYS,
|
||||||
voiceDays: config.RETENTION_VOICE_DAYS,
|
|
||||||
},
|
},
|
||||||
"Starting retention cleanup scheduler",
|
"Starting retention cleanup scheduler",
|
||||||
);
|
);
|
||||||
@@ -93,13 +88,6 @@ function startRetentionCleanup(): void {
|
|||||||
dryRun,
|
dryRun,
|
||||||
"attachments",
|
"attachments",
|
||||||
);
|
);
|
||||||
await deleteExpiredRecords(
|
|
||||||
voiceRecordingsTable,
|
|
||||||
voiceRecordingsTable.created_at,
|
|
||||||
config.RETENTION_VOICE_DAYS,
|
|
||||||
dryRun,
|
|
||||||
"voice recordings",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run immediately on start, then schedule
|
// Run immediately on start, then schedule
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import type { createChildLogger } from "@/shared/logger/index";
|
|||||||
import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
import type { CommandHandler } from "../modules/command-handler/commandHandler.js";
|
||||||
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
|
import type { EventBroadcaster } from "../modules/event-broadcaster/index.js";
|
||||||
import type { stopMetricsServer } from "../modules/gateway-metrics/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";
|
import type { closeDatabase } from "../shared/database/drizzle.js";
|
||||||
|
|
||||||
type Logger = ReturnType<typeof createChildLogger>;
|
type Logger = ReturnType<typeof createChildLogger>;
|
||||||
@@ -15,12 +12,10 @@ type StopMetricsServer = typeof stopMetricsServer;
|
|||||||
export interface GracefulShutdownOptions {
|
export interface GracefulShutdownOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
closeDatabase: CloseDatabase;
|
closeDatabase: CloseDatabase;
|
||||||
voiceController: VoiceController;
|
|
||||||
client: Client;
|
client: Client;
|
||||||
eventBroadcaster: EventBroadcaster;
|
eventBroadcaster: EventBroadcaster;
|
||||||
commandHandler: CommandHandler;
|
commandHandler: CommandHandler;
|
||||||
stopMetricsServer?: StopMetricsServer;
|
stopMetricsServer?: StopMetricsServer;
|
||||||
pcmWsClient?: VoicePcmWsClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createGracefulShutdown(options: GracefulShutdownOptions) {
|
export function createGracefulShutdown(options: GracefulShutdownOptions) {
|
||||||
@@ -37,26 +32,15 @@ export function createGracefulShutdown(options: GracefulShutdownOptions) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
options.stopMetricsServer?.();
|
options.stopMetricsServer?.();
|
||||||
stopMuxerWorker();
|
|
||||||
|
|
||||||
// 1. Voice disconnect (may write final DB records)
|
// 1. Close Redis/pubsub
|
||||||
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
|
|
||||||
options.logger.info("Closing event broadcaster...");
|
options.logger.info("Closing event broadcaster...");
|
||||||
await options.eventBroadcaster.close();
|
await options.eventBroadcaster.close();
|
||||||
|
|
||||||
options.logger.info("Closing command handler...");
|
options.logger.info("Closing command handler...");
|
||||||
await options.commandHandler.close();
|
await options.commandHandler.close();
|
||||||
|
|
||||||
// 3. DB pool LAST – voice disconnect may finalize recordings
|
// 2. DB pool
|
||||||
options.logger.info("Closing database...");
|
options.logger.info("Closing database...");
|
||||||
await options.closeDatabase();
|
await options.closeDatabase();
|
||||||
options.logger.info("Database closed");
|
options.logger.info("Database closed");
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import "libsodium-wrappers";
|
|
||||||
import "@snazzah/davey";
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { initializeDiscordGateway } from "./app/bootstrap.js";
|
import { initializeDiscordGateway } from "./app/bootstrap.js";
|
||||||
|
|||||||
@@ -5,41 +5,17 @@ import {
|
|||||||
BACKEND_COMMAND,
|
BACKEND_COMMAND,
|
||||||
type CommandMessage,
|
type CommandMessage,
|
||||||
type CommandReply,
|
type CommandReply,
|
||||||
MEDIA_STATUS_KEY,
|
|
||||||
VOICE_STATUS_KEY,
|
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
import type { VoiceController } from "../voice-recording/voiceController.js";
|
|
||||||
import { GuildHandler } from "./guild.handler.js";
|
import { GuildHandler } from "./guild.handler.js";
|
||||||
import {
|
import {
|
||||||
type CommandHandlerFn,
|
type CommandHandlerFn,
|
||||||
createHandlerRegistry,
|
createHandlerRegistry,
|
||||||
} from "./handler-registry.js";
|
} from "./handler-registry.js";
|
||||||
import { MediaHandler } from "./media.handler.js";
|
|
||||||
import { wireMediaStatusWriter } from "./mediaStatusSink.js";
|
|
||||||
import { ModerationHandler } from "./moderation.handler.js";
|
import { ModerationHandler } from "./moderation.handler.js";
|
||||||
import { VideoHandler } from "./video.handler.js";
|
|
||||||
import { VoiceHandler } from "./voice.handler.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("command-handler");
|
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
|
// CommandHandler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -47,13 +23,9 @@ interface VoiceStatusPayload {
|
|||||||
export class CommandHandler {
|
export class CommandHandler {
|
||||||
private redisSub: Redis;
|
private redisSub: Redis;
|
||||||
private redisPub: Redis;
|
private redisPub: Redis;
|
||||||
private voiceController: VoiceController | null = null;
|
|
||||||
private registry: Map<string, CommandHandlerFn> = new Map();
|
private registry: Map<string, CommandHandlerFn> = new Map();
|
||||||
private voiceHandler!: VoiceHandler;
|
|
||||||
private mediaHandler!: MediaHandler;
|
|
||||||
private guildHandler!: GuildHandler;
|
private guildHandler!: GuildHandler;
|
||||||
private moderationHandler!: ModerationHandler;
|
private moderationHandler!: ModerationHandler;
|
||||||
private videoHandler!: VideoHandler;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Dedicated Redis connection needed because: Redis requires a dedicated
|
// Dedicated Redis connection needed because: Redis requires a dedicated
|
||||||
@@ -77,30 +49,18 @@ export class CommandHandler {
|
|||||||
// ---- Lifecycle ----
|
// ---- Lifecycle ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach the Discord client and VoiceController, then subscribe to the Redis
|
* Attach the Discord client, then subscribe to the Redis command channel.
|
||||||
* command channel. Must be called *after* the Discord client is created.
|
* Must be called *after* the Discord client is created.
|
||||||
*/
|
*/
|
||||||
start(client: Client, voiceController: VoiceController): void {
|
start(client: Client): void {
|
||||||
this.voiceController = voiceController;
|
|
||||||
|
|
||||||
// Create domain-specific handlers with their dependencies
|
// Create domain-specific handlers with their dependencies
|
||||||
this.voiceHandler = new VoiceHandler(client, voiceController);
|
|
||||||
this.mediaHandler = new MediaHandler();
|
|
||||||
this.guildHandler = new GuildHandler(client);
|
this.guildHandler = new GuildHandler(client);
|
||||||
this.moderationHandler = new ModerationHandler(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
|
// Build the command registry
|
||||||
this.registry = createHandlerRegistry(
|
this.registry = createHandlerRegistry(
|
||||||
this.voiceHandler,
|
|
||||||
this.mediaHandler,
|
|
||||||
this.guildHandler,
|
this.guildHandler,
|
||||||
this.moderationHandler,
|
this.moderationHandler,
|
||||||
this.videoHandler,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.redisSub.on("message", (_channel, message) => {
|
this.redisSub.on("message", (_channel, message) => {
|
||||||
@@ -117,10 +77,6 @@ export class CommandHandler {
|
|||||||
logger.info(`Subscribed to Redis channel "${BACKEND_COMMAND}"`);
|
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> {
|
async close(): Promise<void> {
|
||||||
@@ -175,51 +131,5 @@ export class CommandHandler {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Failed to publish command reply");
|
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 {
|
import {
|
||||||
COMMAND_GUILDS_LIST,
|
COMMAND_GUILDS_LIST,
|
||||||
COMMAND_GUILDS_TEXT_CHANNELS,
|
COMMAND_GUILDS_TEXT_CHANNELS,
|
||||||
COMMAND_MEDIA_LOOP,
|
|
||||||
COMMAND_MEDIA_QUEUE,
|
|
||||||
COMMAND_MEDIA_SKIP,
|
|
||||||
COMMAND_MEDIA_STOP,
|
|
||||||
COMMAND_MEDIA_VOLUME,
|
|
||||||
COMMAND_MODERATION_ACTION,
|
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 CommandMessage,
|
||||||
type CommandReply,
|
type CommandReply,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
import type { GuildHandler } from "./guild.handler.js";
|
import type { GuildHandler } from "./guild.handler.js";
|
||||||
import type { MediaHandler } from "./media.handler.js";
|
|
||||||
import type { ModerationHandler } from "./moderation.handler.js";
|
import type { ModerationHandler } from "./moderation.handler.js";
|
||||||
import type { VideoHandler } from "./video.handler.js";
|
|
||||||
import type { VoiceHandler } from "./voice.handler.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -37,53 +21,11 @@ export type CommandHandlerFn = (
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function createHandlerRegistry(
|
export function createHandlerRegistry(
|
||||||
voiceHandler: VoiceHandler,
|
|
||||||
mediaHandler: MediaHandler,
|
|
||||||
guildHandler: GuildHandler,
|
guildHandler: GuildHandler,
|
||||||
moderationHandler: ModerationHandler,
|
moderationHandler: ModerationHandler,
|
||||||
videoHandler: VideoHandler,
|
|
||||||
): Map<string, CommandHandlerFn> {
|
): Map<string, CommandHandlerFn> {
|
||||||
const registry = new 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
|
// Guild commands
|
||||||
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
|
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
|
||||||
guildHandler.handleListGuilds(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> {
|
async reactionAdded(data: Record<string, unknown>): Promise<void> {
|
||||||
this.logger.debug({ data }, "Publishing reaction_added");
|
this.logger.debug({ data }, "Publishing reaction_added");
|
||||||
await this.publisher.publish(EventChannels.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> {
|
async moderationAction(data: Record<string, unknown>): Promise<void> {
|
||||||
this.logger.debug({ data }, "Publishing moderation_action");
|
this.logger.debug({ data }, "Publishing moderation_action");
|
||||||
await this.publisher.publish(EventChannels.MODERATION_ACTION, {
|
await this.publisher.publish(EventChannels.MODERATION_ACTION, {
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ import {
|
|||||||
DISCORD_THREAD_CREATED,
|
DISCORD_THREAD_CREATED,
|
||||||
DISCORD_THREAD_DELETED,
|
DISCORD_THREAD_DELETED,
|
||||||
DISCORD_THREAD_UPDATED,
|
DISCORD_THREAD_UPDATED,
|
||||||
DISCORD_VOICE_ACTIVE_USER,
|
|
||||||
DISCORD_VOICE_ANALYZED,
|
|
||||||
DISCORD_VOICE_PCM,
|
|
||||||
DISCORD_VOICE_STARTED,
|
|
||||||
DISCORD_VOICE_STOPPED,
|
|
||||||
DISCORD_VOICE_UPLOADED,
|
|
||||||
type DiscordGatewayEvent,
|
type DiscordGatewayEvent,
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
|
|
||||||
@@ -34,12 +28,6 @@ export const EventChannels = {
|
|||||||
MESSAGE_ANALYZED: DISCORD_MESSAGE_ANALYZED,
|
MESSAGE_ANALYZED: DISCORD_MESSAGE_ANALYZED,
|
||||||
ATTACHMENT_CREATED: DISCORD_ATTACHMENT_CREATED,
|
ATTACHMENT_CREATED: DISCORD_ATTACHMENT_CREATED,
|
||||||
ATTACHMENT_UPLOADED: DISCORD_ATTACHMENT_UPLOADED,
|
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,
|
ANALYSIS_QUEUE_STATUS: DISCORD_ANALYSIS_QUEUE_STATUS,
|
||||||
REACTION_ADDED: DISCORD_REACTION_ADDED,
|
REACTION_ADDED: DISCORD_REACTION_ADDED,
|
||||||
REACTION_REMOVED: DISCORD_REACTION_REMOVED,
|
REACTION_REMOVED: DISCORD_REACTION_REMOVED,
|
||||||
@@ -50,7 +38,6 @@ export const EventChannels = {
|
|||||||
PRESENCE_UPDATED: DISCORD_PRESENCE_UPDATED,
|
PRESENCE_UPDATED: DISCORD_PRESENCE_UPDATED,
|
||||||
GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED,
|
GUILD_MEMBER_ADDED: DISCORD_GUILD_MEMBER_ADDED,
|
||||||
GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED,
|
GUILD_MEMBER_REMOVED: DISCORD_GUILD_MEMBER_REMOVED,
|
||||||
VOICE_ANALYZED: DISCORD_VOICE_ANALYZED,
|
|
||||||
MODERATION_ACTION: DISCORD_MODERATION_ACTION,
|
MODERATION_ACTION: DISCORD_MODERATION_ACTION,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type {
|
|||||||
AIStatus,
|
AIStatus,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
VoiceSegmentRecord,
|
|
||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
export type { TextCaptureTarget } from "./messageCapture.js";
|
export type { TextCaptureTarget } from "./messageCapture.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type fs from "node:fs";
|
|
||||||
import type prism from "prism-media";
|
|
||||||
import type {
|
import type {
|
||||||
AnalysisQueueStatus,
|
AnalysisQueueStatus,
|
||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
UserMetadata,
|
UserMetadata,
|
||||||
VoiceRecordingUploadData,
|
|
||||||
} from "../../shared/index.js";
|
} from "../../shared/index.js";
|
||||||
|
|
||||||
// Re-export all shared types for backward compatibility
|
// Re-export all shared types for backward compatibility
|
||||||
@@ -29,41 +26,8 @@ export type {
|
|||||||
ReviewStatus,
|
ReviewStatus,
|
||||||
RoleMetadata,
|
RoleMetadata,
|
||||||
UserMetadata,
|
UserMetadata,
|
||||||
VoiceRecordingUploadData,
|
|
||||||
VoiceSegmentRecord,
|
|
||||||
} from "../../shared/index.js";
|
} 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)
|
// Local-only types (not shared across services)
|
||||||
export type ModerationWsEvent =
|
export type ModerationWsEvent =
|
||||||
| { type: "ui_state"; state: unknown }
|
| { type: "ui_state"; state: unknown }
|
||||||
@@ -73,6 +37,4 @@ export type ModerationWsEvent =
|
|||||||
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
| { type: "message_deleted"; data: { id: string; deleted_at: number } }
|
||||||
| { type: "message_analyzed"; data: MessageRecord }
|
| { type: "message_analyzed"; data: MessageRecord }
|
||||||
| { type: "attachment_created"; data: AttachmentRecord }
|
| { type: "attachment_created"; data: AttachmentRecord }
|
||||||
| { type: "analysis_queue_status"; data: AnalysisQueueStatus }
|
| { type: "analysis_queue_status"; data: AnalysisQueueStatus };
|
||||||
| { type: "media_state"; state: unknown }
|
|
||||||
| { type: "voice_recording_uploaded"; data: VoiceRecordingUploadData };
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import type { Readable } from "node:stream";
|
|
||||||
import {
|
|
||||||
type AudioPlayer,
|
|
||||||
AudioPlayerStatus,
|
|
||||||
type AudioResource,
|
|
||||||
createAudioPlayer,
|
|
||||||
createAudioResource,
|
|
||||||
StreamType,
|
|
||||||
type VoiceConnection,
|
|
||||||
} from "@discordjs/voice";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { DiscordPlayerOwner, DiscordPlayOptions } from "./mediaTypes.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("player");
|
|
||||||
|
|
||||||
export class DiscordPlayer {
|
|
||||||
private player: AudioPlayer;
|
|
||||||
private connection: VoiceConnection | null = null;
|
|
||||||
private owner: DiscordPlayerOwner = "none";
|
|
||||||
private resource: AudioResource | null = null;
|
|
||||||
private musicVolume = 0.3;
|
|
||||||
private idleCallback: (() => void) | null = null;
|
|
||||||
/** Set before manual stop() calls to distinguish from natural track end. */
|
|
||||||
private manualStop = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.player = createAudioPlayer();
|
|
||||||
|
|
||||||
this.player.on(AudioPlayerStatus.Playing, () => {
|
|
||||||
logger.info("Audio player is now playing!");
|
|
||||||
});
|
|
||||||
|
|
||||||
this.player.on(AudioPlayerStatus.Idle, () => {
|
|
||||||
// manualStop guards against both sync & async idle emission from stop()
|
|
||||||
if (this.manualStop || this.owner === "none") {
|
|
||||||
this.manualStop = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Natural track end — queue auto-advance
|
|
||||||
logger.info("Audio player finished — advancing queue");
|
|
||||||
this.owner = "none";
|
|
||||||
this.resource = null;
|
|
||||||
this.idleCallback?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.player.on("error", (error) => {
|
|
||||||
logger.error({ error: error.message }, "Audio player error");
|
|
||||||
this.owner = "none";
|
|
||||||
this.resource = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a callback fired when the player goes idle after a natural track end. */
|
|
||||||
public onIdle(cb: () => void): void {
|
|
||||||
this.idleCallback = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setConnection(connection: VoiceConnection) {
|
|
||||||
this.connection = connection;
|
|
||||||
this.connection.subscribe(this.player);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getOwner(): DiscordPlayerOwner {
|
|
||||||
return this.owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
public isConnected(): boolean {
|
|
||||||
return this.connection !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public playStream(
|
|
||||||
stream: Readable,
|
|
||||||
owner: DiscordPlayerOwner,
|
|
||||||
options: DiscordPlayOptions = {},
|
|
||||||
) {
|
|
||||||
if (owner === "none") {
|
|
||||||
throw new Error("Discord audio player owner is required");
|
|
||||||
}
|
|
||||||
this.assertOwnerAvailable(owner);
|
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
|
||||||
inputType: options.inputType ?? StreamType.OggOpus,
|
|
||||||
// Default to true so setMusicVolume/setResourceVolume works
|
|
||||||
inlineVolume: options.inlineVolume ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.owner === owner) {
|
|
||||||
this.manualStop = true;
|
|
||||||
this.player.stop();
|
|
||||||
}
|
|
||||||
this.resource = resource;
|
|
||||||
this.owner = owner;
|
|
||||||
if (owner === "music") {
|
|
||||||
const nextVolume =
|
|
||||||
options.volume !== undefined
|
|
||||||
? this.normalizeVolume(options.volume)
|
|
||||||
: this.musicVolume;
|
|
||||||
this.musicVolume = nextVolume;
|
|
||||||
this.setResourceVolume(nextVolume);
|
|
||||||
}
|
|
||||||
this.player.play(resource);
|
|
||||||
this.unpause(owner);
|
|
||||||
this.connection?.subscribe(this.player);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStatus(): AudioPlayerStatus {
|
|
||||||
return this.player.state.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public pause(owner?: DiscordPlayerOwner) {
|
|
||||||
if (!this.canControl(owner)) return;
|
|
||||||
this.player.pause(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public unpause(owner?: DiscordPlayerOwner): boolean {
|
|
||||||
if (!this.canControl(owner)) return false;
|
|
||||||
return this.player.unpause();
|
|
||||||
}
|
|
||||||
|
|
||||||
public stop(owner?: DiscordPlayerOwner) {
|
|
||||||
if (!this.canControl(owner)) return;
|
|
||||||
this.manualStop = true;
|
|
||||||
this.player.stop();
|
|
||||||
this.owner = "none";
|
|
||||||
this.resource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getMusicVolume(): number {
|
|
||||||
return this.musicVolume;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setMusicVolume(volume: number): void {
|
|
||||||
const nextVolume = this.normalizeVolume(volume);
|
|
||||||
this.musicVolume = nextVolume;
|
|
||||||
if (this.owner === "music") {
|
|
||||||
this.setResourceVolume(nextVolume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
|
||||||
if (this.owner !== "none" && this.owner !== owner) {
|
|
||||||
throw new Error(`Discord audio player is owned by ${this.owner}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
|
||||||
return !owner || this.owner === "none" || this.owner === owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeVolume(volume: number): number {
|
|
||||||
if (!Number.isFinite(volume)) return this.musicVolume;
|
|
||||||
return Math.max(0, Math.min(1, volume));
|
|
||||||
}
|
|
||||||
|
|
||||||
private setResourceVolume(volume: number): void {
|
|
||||||
if (!this.resource?.volume) return;
|
|
||||||
this.resource.volume.setVolume(volume);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const discordPlayer = new DiscordPlayer();
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
import { promises as fsPromises } from "node:fs";
|
|
||||||
import {
|
|
||||||
type DiscordGatewayAdapterCreator,
|
|
||||||
entersState,
|
|
||||||
getVoiceConnection,
|
|
||||||
joinVoiceChannel,
|
|
||||||
type VoiceConnection,
|
|
||||||
VoiceConnectionStatus,
|
|
||||||
} from "@discordjs/voice";
|
|
||||||
import type {
|
|
||||||
Client,
|
|
||||||
Guild,
|
|
||||||
VoiceChannel,
|
|
||||||
VoiceState,
|
|
||||||
} from "discord.js-selfbot-v13";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { retryWithBackoff } from "@/shared/utils/index";
|
|
||||||
import { config } from "../../shared/config/config.js";
|
|
||||||
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
|
||||||
import type { VoicePcmWsClient } from "../voice-pcm-ws/index.js";
|
|
||||||
import {
|
|
||||||
createRecordingSession,
|
|
||||||
type RecordingSession,
|
|
||||||
} from "./recorder/sessionRecording.js";
|
|
||||||
import { createSpeakingHandler } from "./recorder/speakingHandler.js";
|
|
||||||
import { hookScreenShareAudio } from "./screenShareAudio.js";
|
|
||||||
import { hookVideoReceiver } from "./videoReceiver.js";
|
|
||||||
import { trackChannel, untrackChannel } from "./videoRecorder.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("recorder");
|
|
||||||
|
|
||||||
let _eventBroadcaster: EventBroadcaster | undefined;
|
|
||||||
|
|
||||||
/** @internal Export for uploader.ts to broadcast voice_recording_uploaded events */
|
|
||||||
export { _eventBroadcaster };
|
|
||||||
|
|
||||||
export function setEventBroadcaster(broadcaster: EventBroadcaster | undefined) {
|
|
||||||
_eventBroadcaster = broadcaster;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force the bot (self member) to be unmuted + undeafened at the SERVER level.
|
|
||||||
*
|
|
||||||
* Why: a server-muted / server-deafened bot cannot receive/record other
|
|
||||||
* members' audio properly (and definitely cannot receive their video/screen
|
|
||||||
* share). The bot's own voice state may be server-muted/deafened by an admin
|
|
||||||
* or by a prior state. This issues a REST guild-members PATCH with
|
|
||||||
* `mute:false, deaf:false`.
|
|
||||||
*
|
|
||||||
* Requires the `MUTE_MEMBERS` + `DEAFEN_MEMBERS` permissions on the bot
|
|
||||||
* (user has granted them). Failures are logged, never fatal — an unmute
|
|
||||||
* failure must not break the voice join/recording.
|
|
||||||
*
|
|
||||||
* Exported so the video-receive path (videoRecorder.ts → streamWatch) can
|
|
||||||
* re-assert it right before requesting STREAM_WATCH — a server-deafened bot
|
|
||||||
* is NOT sent the streamer's audiovisual RTP, which is why no video arrives
|
|
||||||
* even when the DAVE watch reaches Ready. Server-deaf can silently revert on
|
|
||||||
* reconnect/restart, so this is re-invoked on every video watch attempt.
|
|
||||||
*/
|
|
||||||
export async function forceSelfServerUnmuteUndeafen(
|
|
||||||
client: Client,
|
|
||||||
guild: Guild,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const selfId = client.user?.id;
|
|
||||||
if (!selfId) return;
|
|
||||||
await guild.members.edit(selfId, { mute: false, deaf: false });
|
|
||||||
// Verify the PATCH actually landed — read back the live member voice state.
|
|
||||||
const fresh = guild.members.cache.get(selfId);
|
|
||||||
const stillDeaf = fresh?.voice?.deaf ?? false;
|
|
||||||
logger.info(
|
|
||||||
{ guildId: guild.id, stillDeaf },
|
|
||||||
stillDeaf
|
|
||||||
? "Forced bot server unmute + undeafen (but guild reports still deaf — will retry on next video)"
|
|
||||||
: "Forced bot server unmute + undeafen",
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
guildId: guild.id,
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
},
|
|
||||||
"Could not force server unmute/undeafen (permissions not granted?)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach an IMMEDIATE self-undeafen / self-unmute guard on `voiceStateUpdate`.
|
|
||||||
*
|
|
||||||
* Previous behaviour only re-asserted unmute/undeafen on video-watch attempts
|
|
||||||
* and after voice reconnects — so when an admin server-muted or
|
|
||||||
* server-deafened the bot the bot stayed muted/deafened until the *next* video
|
|
||||||
* watch or reconnect cycle (could be minutes or never if no streamer came on).
|
|
||||||
*
|
|
||||||
* This listener fires on EVERY guild voice-state change. When it detects that
|
|
||||||
* the bot's OWN voice state transitioned to serverDeaf||serverMute === true,
|
|
||||||
* it immediately re-issues `mute:false, deaf:false` so the bot is unmuted/
|
|
||||||
* undeafened instantly (well within Discord's rate limit window for the bot's
|
|
||||||
* own member). Idempotent and best-effort — failures are logged, never fatal.
|
|
||||||
*
|
|
||||||
* Idempotent: safe to call multiple times; the listener is attached once.
|
|
||||||
*/
|
|
||||||
let _selfVoiceGuardAttached = false;
|
|
||||||
export function registerSelfVoiceStateGuard(client: Client): void {
|
|
||||||
if (_selfVoiceGuardAttached) return;
|
|
||||||
_selfVoiceGuardAttached = true;
|
|
||||||
client.on(
|
|
||||||
"voiceStateUpdate",
|
|
||||||
(oldState: VoiceState, newState: VoiceState) => {
|
|
||||||
void handleSelfVoiceStateUpdate(client, oldState, newState);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSelfVoiceStateUpdate(
|
|
||||||
client: Client,
|
|
||||||
oldState: VoiceState,
|
|
||||||
newState: VoiceState,
|
|
||||||
): Promise<void> {
|
|
||||||
// Only react to the bot's own voice state transitions.
|
|
||||||
const selfId = client.user?.id;
|
|
||||||
if (!selfId || newState.id !== selfId) return;
|
|
||||||
|
|
||||||
const justServerMuted = !oldState.serverMute && newState.serverMute === true;
|
|
||||||
const justServerDeafed = !oldState.serverDeaf && newState.serverDeaf === true;
|
|
||||||
if (!justServerMuted && !justServerDeafed) return;
|
|
||||||
|
|
||||||
const guild = newState.guild;
|
|
||||||
if (!guild) return;
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
guildId: guild.id,
|
|
||||||
serverMuted: justServerMuted,
|
|
||||||
serverDeafed: justServerDeafed,
|
|
||||||
},
|
|
||||||
"Bot was server-muted/deafened — immediately self-undeafening + unmuting",
|
|
||||||
);
|
|
||||||
// Fire-and-forget the corrective edit; forceSelfServerUnmuteUndeafen logs.
|
|
||||||
void forceSelfServerUnmuteUndeafen(client, guild);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _pcmWsClient: VoicePcmWsClient | undefined;
|
|
||||||
|
|
||||||
export function setPcmWsClient(client: VoicePcmWsClient | undefined) {
|
|
||||||
_pcmWsClient = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordingsDir = config.RECORDINGS_DIR;
|
|
||||||
|
|
||||||
// Ensure recordings directory exists
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await fsPromises.mkdir(recordingsDir, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
// Directory might already exist, that's fine
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
||||||
logger.error({ error }, "Failed to create recordings directory");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
const activeSessions = new Map<string, RecordingSession>();
|
|
||||||
|
|
||||||
export function resetActiveSessions(): void {
|
|
||||||
activeSessions.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function finalizeActiveRecordingSession(guildId: string): void {
|
|
||||||
const session = activeSessions.get(guildId);
|
|
||||||
if (!session) return;
|
|
||||||
activeSessions.delete(guildId);
|
|
||||||
// Per-segment upload is the real flow; session metadata is written alongside
|
|
||||||
// each segment by finalizeSegment in segment.ts
|
|
||||||
logger.debug(
|
|
||||||
{ sessionId: session.sessionId, guildId },
|
|
||||||
"Active recording session finalized",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
|
||||||
*/
|
|
||||||
export async function startRecording(
|
|
||||||
client: Client,
|
|
||||||
channel: VoiceChannel,
|
|
||||||
): Promise<VoiceConnection | null> {
|
|
||||||
const connection = joinVoiceChannel({
|
|
||||||
channelId: channel.id,
|
|
||||||
guildId: channel.guild.id,
|
|
||||||
adapterCreator: channel.guild
|
|
||||||
.voiceAdapterCreator as DiscordGatewayAdapterCreator,
|
|
||||||
selfDeaf: false,
|
|
||||||
selfMute: false,
|
|
||||||
debug: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info({ channelName: channel.name }, "Joining voice channel");
|
|
||||||
|
|
||||||
connection.on("debug", (msg) => {
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.debug({ message: msg }, "Voice debug");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on("error", (err) => {
|
|
||||||
logger.error({ error: err }, "Voice connection error");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait until fully connected with retry logic
|
|
||||||
try {
|
|
||||||
await retryWithBackoff(
|
|
||||||
() =>
|
|
||||||
entersState(
|
|
||||||
connection,
|
|
||||||
VoiceConnectionStatus.Ready,
|
|
||||||
config.VOICE_CONNECTION_TIMEOUT_MS,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
retries: 3,
|
|
||||||
minTimeout: 1000,
|
|
||||||
maxTimeout: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
logger.info("Connected to voice channel. Recording started");
|
|
||||||
|
|
||||||
// Auto undeafen + unmute the bot itself at the server level (MUTE_MEMBERS /
|
|
||||||
// DEAFEN_MEMBERS permission granted). Server-muted/deafened bot can't
|
|
||||||
// receive others' audio/video. Fire-and-forget — never blocks recording.
|
|
||||||
void forceSelfServerUnmuteUndeafen(client, channel.guild);
|
|
||||||
|
|
||||||
// Create recording session after connection is ready
|
|
||||||
const sessionStartTime = Date.now();
|
|
||||||
const session = createRecordingSession({
|
|
||||||
guildId: channel.guild.id,
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
startTime: sessionStartTime,
|
|
||||||
});
|
|
||||||
activeSessions.set(channel.guild.id, session);
|
|
||||||
// Register this channel for video recording (others' camera/screen share).
|
|
||||||
trackChannel(channel.guild.id, channel);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
|
||||||
connection.destroy();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const receiver = connection.receiver;
|
|
||||||
|
|
||||||
// Use the extracted speaking handler for voice activity
|
|
||||||
const speakingHandler = createSpeakingHandler({
|
|
||||||
client,
|
|
||||||
channel,
|
|
||||||
receiver,
|
|
||||||
eventBroadcaster: _eventBroadcaster,
|
|
||||||
activeSessions,
|
|
||||||
recordingsDir,
|
|
||||||
pcmSender: _pcmWsClient
|
|
||||||
? (pcm, userId) => _pcmWsClient?.sendPcm(userId, pcm)
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
receiver.speaking.on("start", speakingHandler);
|
|
||||||
|
|
||||||
// ── Screen-share audio capture ──────────────────────────────────────
|
|
||||||
// Discord GoLive sends screen-share audio on a SEPARATE SSRC from the
|
|
||||||
// user's microphone. `receiver.speaking` only fires for voice (mic) SSRCs,
|
|
||||||
// so screen-share audio is silently dropped unless we hook the UDP receiver
|
|
||||||
// to discover and register those SSRCs.
|
|
||||||
hookScreenShareAudio(receiver, speakingHandler);
|
|
||||||
|
|
||||||
// ── Video capture (camera + screen share) ───────────────────────────
|
|
||||||
// @discordjs/voice only decrypts/forwards audio (opus). Video RTP (H264,
|
|
||||||
// VP8/VP9/AV1) arrives on the same UDP socket but is dropped by the opus
|
|
||||||
// gate. hookVideoReceiver wraps onUdpMessage, decrypts video payload types
|
|
||||||
// with the connection secret key (reusing receiver.parsePacket), then
|
|
||||||
// depacketizes H264 to AnnexB and writes a raw .h264 stream per user.
|
|
||||||
// Order matters: video hook must be installed AFTER the screen-share audio
|
|
||||||
// hook so non-video packets delegate down the existing wrapper chain.
|
|
||||||
hookVideoReceiver(receiver, client, recordingsDir);
|
|
||||||
|
|
||||||
// Handle unexpected disconnection
|
|
||||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.warn("Disconnected from voice channel. Reconnecting...");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
entersState(
|
|
||||||
connection,
|
|
||||||
VoiceConnectionStatus.Signalling,
|
|
||||||
config.RECONNECT_TIMEOUT_MS,
|
|
||||||
),
|
|
||||||
entersState(
|
|
||||||
connection,
|
|
||||||
VoiceConnectionStatus.Connecting,
|
|
||||||
config.RECONNECT_TIMEOUT_MS,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
// Reconnected successfully
|
|
||||||
// Server-deaf can silently revert across a reconnect — re-assert the bot
|
|
||||||
// is undeafened + unmuted so video receive keeps working.
|
|
||||||
void forceSelfServerUnmuteUndeafen(client, channel.guild);
|
|
||||||
} catch {
|
|
||||||
logger.error("Could not reconnect. Destroying connection");
|
|
||||||
connection.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
|
||||||
finalizeActiveRecordingSession(channel.guild.id);
|
|
||||||
untrackChannel(channel.guild.id);
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.info("Voice connection destroyed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop recording and disconnect from voice channel.
|
|
||||||
*/
|
|
||||||
export function stopRecording(guildId: string): void {
|
|
||||||
const connection = getVoiceConnection(guildId);
|
|
||||||
if (connection) {
|
|
||||||
connection.destroy();
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.info("Recording stopped and disconnected");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn("No active connection to stop");
|
|
||||||
}
|
|
||||||
untrackChannel(guildId);
|
|
||||||
|
|
||||||
const session = activeSessions.get(guildId);
|
|
||||||
finalizeActiveRecordingSession(guildId);
|
|
||||||
|
|
||||||
// Broadcast voice_recording_stopped + finalize session
|
|
||||||
if (session && _eventBroadcaster) {
|
|
||||||
const snapshot = session.snapshot(Date.now());
|
|
||||||
const stoppedAt = Date.now();
|
|
||||||
|
|
||||||
_eventBroadcaster
|
|
||||||
.voiceRecordingStopped({
|
|
||||||
guild_id: guildId,
|
|
||||||
session_id: session.sessionId,
|
|
||||||
duration_ms: snapshot.durationMs,
|
|
||||||
participants: snapshot.participants.length,
|
|
||||||
segment_count: snapshot.segments.length,
|
|
||||||
status: snapshot.status,
|
|
||||||
stopped_at: stoppedAt,
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
logger.warn({ err }, "Failed to broadcast voice recording stopped"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-enqueue muxer job if there are multiple segments
|
|
||||||
const segments = snapshot.segments;
|
|
||||||
if (segments.length >= 2) {
|
|
||||||
const outputFile = `${config.RECORDINGS_DIR}/merged/${session.sessionId}.mp3`;
|
|
||||||
import("./muxer.js")
|
|
||||||
.then(({ enqueueMuxerJob }) => {
|
|
||||||
enqueueMuxerJob({
|
|
||||||
inputs: segments.map((s) => s.oggPath),
|
|
||||||
output: outputFile,
|
|
||||||
guildId,
|
|
||||||
channelId: snapshot.channelId,
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
}).catch(() => {});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { createRequire } from "node:module";
|
|
||||||
import * as prism from "prism-media";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { config } from "../../../shared/config/config.js";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const logger = createChildLogger("opus-decoder");
|
|
||||||
|
|
||||||
interface OpusDecoderRuntime {
|
|
||||||
isBun: boolean;
|
|
||||||
canLoadNativeOpus: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldEnableDefaultOpusDecoder(
|
|
||||||
runtime: OpusDecoderRuntime,
|
|
||||||
): boolean {
|
|
||||||
return !runtime.isBun || runtime.canLoadNativeOpus;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canLoadNativeOpus(): boolean {
|
|
||||||
try {
|
|
||||||
require("@discordjs/opus");
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultDecoderEnabled = shouldEnableDefaultOpusDecoder({
|
|
||||||
isBun: Boolean(process.versions.bun),
|
|
||||||
canLoadNativeOpus: canLoadNativeOpus(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface OpusDecoderOptions {
|
|
||||||
cooldownMs: number;
|
|
||||||
rotateMs: number;
|
|
||||||
createDecoder?: () => prism.opus.Decoder;
|
|
||||||
onData: (pcm: Buffer) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpusDecoder {
|
|
||||||
private decoder: prism.opus.Decoder | null = null;
|
|
||||||
private disabledUntil = 0;
|
|
||||||
private createdAt = 0;
|
|
||||||
private readonly cooldownMs: number;
|
|
||||||
private readonly rotateMs: number;
|
|
||||||
private readonly createDecoderFn: () => prism.opus.Decoder;
|
|
||||||
private readonly onData: (pcm: Buffer) => void;
|
|
||||||
|
|
||||||
constructor(options: OpusDecoderOptions) {
|
|
||||||
this.cooldownMs = options.cooldownMs;
|
|
||||||
this.rotateMs = options.rotateMs;
|
|
||||||
this.onData = options.onData;
|
|
||||||
this.createDecoderFn =
|
|
||||||
options.createDecoder ??
|
|
||||||
(() => {
|
|
||||||
if (!defaultDecoderEnabled) {
|
|
||||||
throw new Error(
|
|
||||||
"Native @discordjs/opus is unavailable under Bun; web PCM decode disabled to avoid opusscript aborts",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new prism.opus.Decoder({
|
|
||||||
frameSize: config.OPUS_FRAME_SIZE,
|
|
||||||
channels: config.AUDIO_CHANNELS as 1 | 2,
|
|
||||||
rate: config.AUDIO_SAMPLE_RATE as
|
|
||||||
| 8000
|
|
||||||
| 12000
|
|
||||||
| 16000
|
|
||||||
| 24000
|
|
||||||
| 48000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateIfNeeded(): void {
|
|
||||||
if (!this.decoder || this.rotateMs <= 0) return;
|
|
||||||
if (Date.now() - this.createdAt < this.rotateMs) return;
|
|
||||||
this.destroy();
|
|
||||||
this.ensureDecoder();
|
|
||||||
}
|
|
||||||
|
|
||||||
write(chunk: Buffer): void {
|
|
||||||
const decoder = this.ensureDecoder();
|
|
||||||
if (!decoder) return;
|
|
||||||
try {
|
|
||||||
decoder.write(chunk);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ error }, "Opus decoder write failed, cooling down");
|
|
||||||
this.coolDown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(): void {
|
|
||||||
if (!this.decoder) return;
|
|
||||||
this.decoder.removeAllListeners();
|
|
||||||
this.decoder.destroy();
|
|
||||||
this.decoder = null;
|
|
||||||
this.createdAt = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureDecoder(): prism.opus.Decoder | null {
|
|
||||||
if (this.decoder) return this.decoder;
|
|
||||||
if (Date.now() < this.disabledUntil) return null;
|
|
||||||
try {
|
|
||||||
const decoder = this.createDecoderFn();
|
|
||||||
decoder.on("data", this.onData);
|
|
||||||
decoder.on("error", (error) => {
|
|
||||||
logger.warn({ error }, "Opus decoder error, cooling down");
|
|
||||||
this.coolDown();
|
|
||||||
});
|
|
||||||
this.decoder = decoder;
|
|
||||||
this.createdAt = Date.now();
|
|
||||||
return decoder;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ error }, "Opus decoder init failed, cooling down");
|
|
||||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private coolDown(): void {
|
|
||||||
this.disabledUntil = Date.now() + this.cooldownMs;
|
|
||||||
this.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { config } from "../../../shared/config/config.js";
|
|
||||||
import type {
|
|
||||||
SegmentMetadata,
|
|
||||||
SegmentState,
|
|
||||||
UserMetadata,
|
|
||||||
} from "../../message-capture/types.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice-metadata");
|
|
||||||
|
|
||||||
/** LRU-ish cache: userId -> UserMetadata. Avoids Discord API calls in hotpath. */
|
|
||||||
const metadataCache = new Map<string, UserMetadata>();
|
|
||||||
const METADATA_CACHE_MAX = 200;
|
|
||||||
|
|
||||||
function cacheMetadata(userId: string, metadata: UserMetadata): void {
|
|
||||||
if (metadataCache.size >= METADATA_CACHE_MAX) {
|
|
||||||
// Evict oldest entry via Map iteration (Map preserves insertion order)
|
|
||||||
const firstKey = metadataCache.keys().next().value;
|
|
||||||
if (firstKey) metadataCache.delete(firstKey);
|
|
||||||
}
|
|
||||||
metadataCache.set(userId, metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectUserMetadata(
|
|
||||||
client: Client,
|
|
||||||
userId: string,
|
|
||||||
channel: VoiceChannel,
|
|
||||||
): Promise<UserMetadata> {
|
|
||||||
const cached = metadataCache.get(userId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const user =
|
|
||||||
client.users.cache.get(userId) ||
|
|
||||||
(await client.users.fetch(userId).catch(() => {
|
|
||||||
logger.warn({ userId }, "Failed to fetch user");
|
|
||||||
return null;
|
|
||||||
}));
|
|
||||||
const member =
|
|
||||||
channel.guild.members.cache.get(userId) ||
|
|
||||||
(await channel.guild.members.fetch(userId).catch(() => {
|
|
||||||
logger.warn({ userId }, "Failed to fetch guild member");
|
|
||||||
return null;
|
|
||||||
}));
|
|
||||||
const username = user?.username ?? "Unknown User";
|
|
||||||
const roles =
|
|
||||||
member?.roles.cache
|
|
||||||
.filter((role) => role.id !== channel.guild.id)
|
|
||||||
.sort((a, b) => b.position - a.position)
|
|
||||||
.map((role) => ({
|
|
||||||
id: role.id,
|
|
||||||
name: role.name,
|
|
||||||
position: role.position,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
const result: UserMetadata = {
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
tag: user?.tag ?? "Unknown#0000",
|
|
||||||
displayName: member?.displayName ?? username,
|
|
||||||
avatarUrl:
|
|
||||||
user?.displayAvatarURL({
|
|
||||||
format: "png",
|
|
||||||
size: config.AVATAR_SIZE as
|
|
||||||
| 16
|
|
||||||
| 32
|
|
||||||
| 64
|
|
||||||
| 128
|
|
||||||
| 256
|
|
||||||
| 512
|
|
||||||
| 1024
|
|
||||||
| 2048
|
|
||||||
| 4096,
|
|
||||||
}) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
|
|
||||||
bot: user?.bot ?? false,
|
|
||||||
roles,
|
|
||||||
highestRole: roles[0] ?? null,
|
|
||||||
joinedTimestamp: member?.joinedTimestamp ?? null,
|
|
||||||
};
|
|
||||||
|
|
||||||
cacheMetadata(userId, result);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSegmentMetadata(
|
|
||||||
user: UserMetadata,
|
|
||||||
segment: SegmentState,
|
|
||||||
sessionId: string,
|
|
||||||
recordingSessionId: string,
|
|
||||||
sessionStartTime: number,
|
|
||||||
recordingSegmentMs: number,
|
|
||||||
): SegmentMetadata {
|
|
||||||
const endTime = segment.endTime ?? Date.now();
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
sessionId,
|
|
||||||
recordingSessionId,
|
|
||||||
sessionStartTime,
|
|
||||||
segmentIndex: segment.index,
|
|
||||||
segmentMs: recordingSegmentMs,
|
|
||||||
startTime: segment.startTime,
|
|
||||||
endTime,
|
|
||||||
durationMs: endTime - segment.startTime,
|
|
||||||
filename: path.basename(segment.filename),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ogg page CRC-32 (RFC 3533): polynomial 0x04c11db7, init 0, MSB-first
|
|
||||||
* (no reflection), no final XOR. Table-driven, pure JS — no native deps.
|
|
||||||
*
|
|
||||||
* prism-media's OggLogicalBitstream writes pages with `crc: false` (node-crc
|
|
||||||
* was dropped from the dependency tree), which produces files whose page
|
|
||||||
* checksums are all zero. Strict players (ffmpeg, iOS) reject those with
|
|
||||||
* "CRC mismatch! / End of file". This re-computes the checksum of every page
|
|
||||||
* in place, yielding a spec-compliant Ogg file that any player can open.
|
|
||||||
*/
|
|
||||||
const CRC_TABLE = new Int32Array(256);
|
|
||||||
for (let i = 0; i < 256; i++) {
|
|
||||||
let c = i << 24;
|
|
||||||
for (let k = 0; k < 8; k++) {
|
|
||||||
c = c & 0x80000000 ? (c << 1) ^ 0x04c11db7 : c << 1;
|
|
||||||
}
|
|
||||||
CRC_TABLE[i] = c | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function oggCrc32(buffer: Buffer): number {
|
|
||||||
let c = 0;
|
|
||||||
for (let i = 0; i < buffer.length; i++) {
|
|
||||||
c = ((c << 8) ^ CRC_TABLE[((c >>> 24) ^ buffer[i]) & 0xff]) | 0;
|
|
||||||
}
|
|
||||||
return c >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites the CRC field (bytes 22-25) of every OggS page in `filePath`.
|
|
||||||
* Returns the number of pages fixed (0 if the file is not a valid Ogg stream).
|
|
||||||
*/
|
|
||||||
export function fixOggCrc(filePath: string): number {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
let pos = 0;
|
|
||||||
let fixed = 0;
|
|
||||||
|
|
||||||
while (pos + 27 <= data.length) {
|
|
||||||
if (data.toString("latin1", pos, pos + 4) !== "OggS") break;
|
|
||||||
const nsegs = data[pos + 26];
|
|
||||||
let bodyLen = 0;
|
|
||||||
for (let i = 0; i < nsegs; i++) bodyLen += data[pos + 27 + i];
|
|
||||||
const pageEnd = pos + 27 + nsegs + bodyLen;
|
|
||||||
if (pageEnd > data.length) break;
|
|
||||||
|
|
||||||
// Zero the checksum field, then CRC the whole page (RFC 3533).
|
|
||||||
data.writeUInt32LE(0, pos + 22);
|
|
||||||
data.writeUInt32LE(oggCrc32(data.subarray(pos, pageEnd)), pos + 22);
|
|
||||||
fixed++;
|
|
||||||
pos = pageEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixed > 0) fs.writeFileSync(filePath, data);
|
|
||||||
return fixed;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import * as prism from "prism-media";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { SegmentState } from "../../message-capture/types.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice-segment");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Path helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function buildSegmentPaths(
|
|
||||||
userDir: string,
|
|
||||||
startTime: number,
|
|
||||||
): { filename: string; jsonFilename: string } {
|
|
||||||
return {
|
|
||||||
filename: path.join(userDir, `${startTime}.ogg`),
|
|
||||||
jsonFilename: path.join(userDir, `${startTime}.json`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRotateSegment(
|
|
||||||
startTime: number,
|
|
||||||
now: number,
|
|
||||||
recordingSegmentMs: number,
|
|
||||||
): boolean {
|
|
||||||
return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SegmentManager
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export class SegmentManager {
|
|
||||||
private currentSegment: SegmentState | null = null;
|
|
||||||
private segmentIndex = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly userDir: string,
|
|
||||||
private readonly recordingSegmentMs: number,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
|
||||||
const index = this.segmentIndex++;
|
|
||||||
const startTime = Date.now();
|
|
||||||
const { filename, jsonFilename } = buildSegmentPaths(
|
|
||||||
this.userDir,
|
|
||||||
startTime,
|
|
||||||
);
|
|
||||||
const oggStream = new prism.opus.OggLogicalBitstream({
|
|
||||||
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
|
|
||||||
pageSizeControl: { maxPackets: 10 },
|
|
||||||
crc: false,
|
|
||||||
});
|
|
||||||
const out = fs.createWriteStream(filename);
|
|
||||||
oggPacketStream.pipe(oggStream).pipe(out);
|
|
||||||
|
|
||||||
this.currentSegment = {
|
|
||||||
index,
|
|
||||||
startTime,
|
|
||||||
endTime: null,
|
|
||||||
filename,
|
|
||||||
jsonFilename,
|
|
||||||
oggStream,
|
|
||||||
out,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{ index, startTime, filename, userDir: this.userDir },
|
|
||||||
"Segment opened",
|
|
||||||
);
|
|
||||||
return this.currentSegment;
|
|
||||||
}
|
|
||||||
|
|
||||||
close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
|
||||||
if (!this.currentSegment) return null;
|
|
||||||
const segment = this.currentSegment;
|
|
||||||
segment.endTime = Date.now();
|
|
||||||
oggPacketStream.unpipe(segment.oggStream);
|
|
||||||
segment.oggStream.end();
|
|
||||||
this.currentSegment = null;
|
|
||||||
|
|
||||||
// Get file size after closing
|
|
||||||
let fileSize = 0;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(segment.filename);
|
|
||||||
fileSize = stat.size;
|
|
||||||
} catch {
|
|
||||||
// File might not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{
|
|
||||||
index: segment.index,
|
|
||||||
filename: segment.filename,
|
|
||||||
fileSize,
|
|
||||||
durationMs: (segment.endTime ?? 0) - segment.startTime,
|
|
||||||
},
|
|
||||||
"Segment closed",
|
|
||||||
);
|
|
||||||
return segment;
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
|
||||||
if (!this.currentSegment) return null;
|
|
||||||
if (
|
|
||||||
!shouldRotateSegment(
|
|
||||||
this.currentSegment.startTime,
|
|
||||||
Date.now(),
|
|
||||||
this.recordingSegmentMs,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{
|
|
||||||
index: this.currentSegment.index,
|
|
||||||
filename: this.currentSegment.filename,
|
|
||||||
durationMs: Date.now() - this.currentSegment.startTime,
|
|
||||||
},
|
|
||||||
"Segment rotating",
|
|
||||||
);
|
|
||||||
this.close(oggPacketStream);
|
|
||||||
return this.open(oggPacketStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrent(): SegmentState | null {
|
|
||||||
return this.currentSegment;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
import { promises as fsPromises } from "node:fs";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import { config } from "../../../shared/config/config.js";
|
|
||||||
import type {
|
|
||||||
SegmentState,
|
|
||||||
UserMetadata,
|
|
||||||
} from "../../message-capture/types.js";
|
|
||||||
import { createSegmentMetadata } from "./metadata.js";
|
|
||||||
import { fixOggCrc } from "./oggCrc.js";
|
|
||||||
import type { RecordingSession } from "./sessionRecording.js";
|
|
||||||
import { uploadRecordingSegment } from "./uploader.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("voice-segment-finalizer");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SegmentFinalizerInput
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface SegmentFinalizerInput {
|
|
||||||
currentSegment: SegmentState;
|
|
||||||
userMetadata: UserMetadata;
|
|
||||||
activeSession: RecordingSession | undefined;
|
|
||||||
guildId: string;
|
|
||||||
channelId: string;
|
|
||||||
channelName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// finalizeSegment
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the completion of an OGG segment:
|
|
||||||
* - Logs the saved segment (if VERBOSE)
|
|
||||||
* - Registers the segment with the active recording session
|
|
||||||
* - Writes the metadata JSON file alongside the OGG file
|
|
||||||
* - Triggers async upload of the segment to external storage
|
|
||||||
*
|
|
||||||
* This function is fire-and-forget for the metadata write and upload;
|
|
||||||
* errors are caught and logged without throwing.
|
|
||||||
*/
|
|
||||||
export function finalizeSegment(input: SegmentFinalizerInput): void {
|
|
||||||
const {
|
|
||||||
currentSegment,
|
|
||||||
userMetadata,
|
|
||||||
activeSession,
|
|
||||||
guildId,
|
|
||||||
channelId,
|
|
||||||
channelName,
|
|
||||||
} = input;
|
|
||||||
|
|
||||||
const endTime = currentSegment.endTime ?? Date.now();
|
|
||||||
const durationMs = endTime - currentSegment.startTime;
|
|
||||||
|
|
||||||
// Discard only very short segments — a blip of ambient noise or a mic
|
|
||||||
// click. At 300ms, brief acknowledgements ("ya", "siap", "ok") are still
|
|
||||||
// kept while true artifacts are dropped.
|
|
||||||
const MIN_DURATION_MS = 300;
|
|
||||||
if (durationMs < MIN_DURATION_MS) {
|
|
||||||
logger.debug(
|
|
||||||
{ filename: currentSegment.filename, durationMs },
|
|
||||||
"Segment too short, discarding",
|
|
||||||
);
|
|
||||||
// Clean up the OGG file
|
|
||||||
fsPromises.unlink(currentSegment.filename).catch(() => {});
|
|
||||||
fsPromises.unlink(currentSegment.jsonFilename).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix Ogg page CRCs before anything reads the file (upload/merge/transcode).
|
|
||||||
// prism-media writes pages with crc:false → checksums are zero → strict
|
|
||||||
// players (ffmpeg, iOS) reject the file. Re-compute in place, pure JS.
|
|
||||||
try {
|
|
||||||
const fixed = fixOggCrc(currentSegment.filename);
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.info(
|
|
||||||
{ filename: currentSegment.filename, pages: fixed },
|
|
||||||
"Ogg page CRCs fixed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
logger.error(
|
|
||||||
{
|
|
||||||
filename: currentSegment.filename,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
},
|
|
||||||
"Failed to fix Ogg CRCs — segment may be unplayable",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register segment with the active recording session
|
|
||||||
if (activeSession) {
|
|
||||||
activeSession.registerSegment({
|
|
||||||
user: userMetadata,
|
|
||||||
oggPath: currentSegment.filename,
|
|
||||||
jsonPath: currentSegment.jsonFilename,
|
|
||||||
startTime: currentSegment.startTime,
|
|
||||||
endTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata JSON (async, fire-and-forget)
|
|
||||||
const metadata = createSegmentMetadata(
|
|
||||||
userMetadata,
|
|
||||||
currentSegment,
|
|
||||||
activeSession?.sessionId ?? `${userMetadata.userId}-0`,
|
|
||||||
activeSession?.sessionId ?? `${guildId}-${channelId}-0`,
|
|
||||||
activeSession?.startTime ?? 0,
|
|
||||||
config.RECORDING_SEGMENT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
fsPromises
|
|
||||||
.writeFile(currentSegment.jsonFilename, JSON.stringify(metadata, null, 2))
|
|
||||||
.then(() => {
|
|
||||||
if (config.VERBOSE) {
|
|
||||||
logger.info(
|
|
||||||
{ jsonFile: currentSegment.jsonFilename },
|
|
||||||
"Metadata saved",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
logger.error(
|
|
||||||
{ error: err instanceof Error ? err.message : String(err) },
|
|
||||||
"Failed to write segment metadata",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger async voice segment upload (fire-and-forget)
|
|
||||||
const segmentId = `${userMetadata.userId}-${currentSegment.startTime}`;
|
|
||||||
uploadRecordingSegment({
|
|
||||||
id: segmentId,
|
|
||||||
oggPath: currentSegment.filename,
|
|
||||||
userId: userMetadata.userId,
|
|
||||||
username: userMetadata.username,
|
|
||||||
avatarUrl: userMetadata.avatarUrl,
|
|
||||||
guildId,
|
|
||||||
channelId,
|
|
||||||
channelName,
|
|
||||||
}).catch((err: unknown) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
logger.error({ segmentId, error: msg }, "Upload segment trigger failed");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { UserMetadata } from "../../message-capture/types.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("recording-session");
|
|
||||||
|
|
||||||
export interface RecordingSessionOptions {
|
|
||||||
guildId: string;
|
|
||||||
channelId: string;
|
|
||||||
channelName: string;
|
|
||||||
startTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionParticipant {
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
tag: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionSegmentRef {
|
|
||||||
userId: string;
|
|
||||||
oggPath: string;
|
|
||||||
jsonPath: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
durationMs: number;
|
|
||||||
offsetMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionRecordingMetadata {
|
|
||||||
sessionId: string;
|
|
||||||
guildId: string;
|
|
||||||
channelId: string;
|
|
||||||
channelName: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
durationMs: number;
|
|
||||||
status: "pending" | "completed" | "failed" | "empty";
|
|
||||||
outputFile: string | null;
|
|
||||||
participants: SessionParticipant[];
|
|
||||||
segments: SessionSegmentRef[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecordingSession {
|
|
||||||
readonly sessionId: string;
|
|
||||||
readonly startTime: number;
|
|
||||||
registerSegment(input: {
|
|
||||||
user: UserMetadata;
|
|
||||||
oggPath: string;
|
|
||||||
jsonPath: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
}): void;
|
|
||||||
snapshot(endTime: number): SessionRecordingMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRecordingSession(
|
|
||||||
options: RecordingSessionOptions,
|
|
||||||
): RecordingSession {
|
|
||||||
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
|
||||||
const participants = new Map<string, SessionParticipant>();
|
|
||||||
const segments: SessionSegmentRef[] = [];
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{
|
|
||||||
sessionId,
|
|
||||||
guildId: options.guildId,
|
|
||||||
channelId: options.channelId,
|
|
||||||
channelName: options.channelName,
|
|
||||||
},
|
|
||||||
"Recording session created",
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
startTime: options.startTime,
|
|
||||||
|
|
||||||
registerSegment(input) {
|
|
||||||
participants.set(input.user.userId, {
|
|
||||||
userId: input.user.userId,
|
|
||||||
username: input.user.username,
|
|
||||||
tag: input.user.tag,
|
|
||||||
displayName: input.user.displayName,
|
|
||||||
avatarUrl: input.user.avatarUrl,
|
|
||||||
});
|
|
||||||
segments.push({
|
|
||||||
userId: input.user.userId,
|
|
||||||
oggPath: input.oggPath,
|
|
||||||
jsonPath: input.jsonPath,
|
|
||||||
startTime: input.startTime,
|
|
||||||
endTime: input.endTime,
|
|
||||||
durationMs: input.endTime - input.startTime,
|
|
||||||
offsetMs: input.startTime - options.startTime,
|
|
||||||
});
|
|
||||||
logger.debug(
|
|
||||||
{ sessionId, userId: input.user.userId, segmentCount: segments.length },
|
|
||||||
"Segment registered in session",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
snapshot(endTime: number): SessionRecordingMetadata {
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
guildId: options.guildId,
|
|
||||||
channelId: options.channelId,
|
|
||||||
channelName: options.channelName,
|
|
||||||
startTime: options.startTime,
|
|
||||||
endTime,
|
|
||||||
durationMs: endTime - options.startTime,
|
|
||||||
status: "pending",
|
|
||||||
outputFile: null,
|
|
||||||
participants: Array.from(participants.values()),
|
|
||||||
segments: [...segments],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import { promises as fsPromises } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { VoiceConnection } from "@discordjs/voice";
|
|
||||||
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
|
||||||
import type { EventBroadcaster } from "../../event-broadcaster/eventBroadcaster.js";
|
|
||||||
import type {
|
|
||||||
SegmentState,
|
|
||||||
UserMetadata,
|
|
||||||
} from "../../message-capture/types.js";
|
|
||||||
import { collectUserMetadata } from "./metadata.js";
|
|
||||||
import { finalizeSegment } from "./segmentFinalizer.js";
|
|
||||||
import type { RecordingSession } from "./sessionRecording.js";
|
|
||||||
import { setupUserStream } from "./streamSetup.js";
|
|
||||||
|
|
||||||
const logger = createChildLogger("speaking-handler");
|
|
||||||
|
|
||||||
export interface SpeakingHandlerContext {
|
|
||||||
client: Client;
|
|
||||||
channel: VoiceChannel;
|
|
||||||
receiver: VoiceConnection["receiver"];
|
|
||||||
eventBroadcaster: EventBroadcaster | undefined;
|
|
||||||
activeSessions: Map<string, RecordingSession>;
|
|
||||||
recordingsDir: string;
|
|
||||||
/** Direct WS sender for real-time PCM — takes priority over Redis if set. */
|
|
||||||
pcmSender?: (pcm: Buffer, userId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the event handler for `receiver.speaking.on("start", handler)`.
|
|
||||||
*
|
|
||||||
* # Why this is structured the way it is (recording-completeness)
|
|
||||||
*
|
|
||||||
* `receiver.speaking"start"` fires from `onUdpMessage` the moment the FIRST
|
|
||||||
* Opus packet for a user arrives, and the opus bytes are forwarded to the
|
|
||||||
* subscription stream **only if a subscription already exists** — otherwise
|
|
||||||
* they are dropped (`subscriptions.get(userId)` is undefined → `return`).
|
|
||||||
*
|
|
||||||
* That means the subscription MUST be created synchronously (no `await` before
|
|
||||||
* it): every frame between "start" and the subscribe call is otherwise silently
|
|
||||||
* discarded → the START of a burst is MISSING (the user's core complaint).
|
|
||||||
* The previous code `await`ed `collectUserMetadata` (a Discord REST roundtrip,
|
|
||||||
* slow on cache miss) BEFORE subscribing — that was the main source of misses.
|
|
||||||
*
|
|
||||||
* Metadata is now fetched in the background. If the speaker turns out to be a
|
|
||||||
* bot, the just-started burst is discarded (unlinked, never uploaded).
|
|
||||||
*/
|
|
||||||
export function createSpeakingHandler(
|
|
||||||
ctx: SpeakingHandlerContext,
|
|
||||||
): (userId: string) => Promise<void> {
|
|
||||||
const {
|
|
||||||
client,
|
|
||||||
channel,
|
|
||||||
receiver,
|
|
||||||
eventBroadcaster,
|
|
||||||
activeSessions,
|
|
||||||
recordingsDir,
|
|
||||||
pcmSender,
|
|
||||||
} = ctx;
|
|
||||||
|
|
||||||
return async (userId: string) => {
|
|
||||||
// Skip the bot's own audio.
|
|
||||||
if (userId === client.user?.id) return;
|
|
||||||
|
|
||||||
// Synchronous guard BEFORE any await. Two "start" events can race during a
|
|
||||||
// subscription; guard here so we never create a second subscription.
|
|
||||||
if (receiver.subscriptions.has(userId)) return;
|
|
||||||
|
|
||||||
logger.debug({ userId }, "Voice activity detected");
|
|
||||||
|
|
||||||
// Optimistic speaking:true (real metadata arrives async).
|
|
||||||
eventBroadcaster?.voiceActiveUser(userId, {
|
|
||||||
username: userId,
|
|
||||||
avatar: "",
|
|
||||||
speaking: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscribe IMMEDIATELY (synchronously — guard is already done above, no
|
|
||||||
// `await` since then), so the subscription exists before any Opus frame
|
|
||||||
// arrives. Frames are then buffered by the AudioReceiveStream while we
|
|
||||||
// finish setup below (mkdir is non-network and fast).
|
|
||||||
const userDir = path.join(recordingsDir, userId);
|
|
||||||
const { audioStream, packetFilter, segmentManager, decoder } =
|
|
||||||
setupUserStream({
|
|
||||||
userId,
|
|
||||||
receiver,
|
|
||||||
userDir,
|
|
||||||
onPcmData: (pcm) => {
|
|
||||||
if (pcmSender) {
|
|
||||||
pcmSender(pcm, userId);
|
|
||||||
} else {
|
|
||||||
eventBroadcaster?.voicePcmData(pcm, userId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ensure per-user recording directory (subscription is already live, so
|
|
||||||
// this await does NOT drop audio — frames buffer in the subscription).
|
|
||||||
await fsPromises.mkdir(userDir, { recursive: true }).catch(() => {
|
|
||||||
// Directory already exists, ignore.
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeSession = activeSessions.get(channel.guild.id);
|
|
||||||
|
|
||||||
// Mutable per-burst state.
|
|
||||||
let userMetadata: UserMetadata | null = null;
|
|
||||||
let accepted = true; // false once we learn it's a bot → discard burst
|
|
||||||
let finalized = false;
|
|
||||||
|
|
||||||
/** Unlink the given segment's files (bot/error discard path). */
|
|
||||||
const discardSegmentFiles = (seg: SegmentState | null): void => {
|
|
||||||
if (!seg) return;
|
|
||||||
fsPromises.unlink(seg.filename).catch(() => {});
|
|
||||||
fsPromises.unlink(seg.jsonFilename).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitSpeakingFalse = (): void => {
|
|
||||||
eventBroadcaster?.voiceActiveUser(userId, {
|
|
||||||
username: userMetadata?.username ?? userId,
|
|
||||||
avatar: userMetadata?.avatarUrl ?? "",
|
|
||||||
speaking: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close the current (per-burst) segment and — once the underlying file has
|
|
||||||
* finished flushing to disk — finalize it (or discard it if bot/error).
|
|
||||||
* Safe to call multiple times (guarded by `finalized`).
|
|
||||||
*/
|
|
||||||
const finishBurst = (): void => {
|
|
||||||
if (finalized) return;
|
|
||||||
finalized = true;
|
|
||||||
const seg = segmentManager.close(oggPacketStream);
|
|
||||||
decoder.destroy();
|
|
||||||
|
|
||||||
const doFinalize = (): void => {
|
|
||||||
Promise.resolve(userMetadata).then((meta) => {
|
|
||||||
if (meta && accepted && seg) {
|
|
||||||
try {
|
|
||||||
finalizeSegment({
|
|
||||||
currentSegment: seg,
|
|
||||||
userMetadata: meta,
|
|
||||||
activeSession,
|
|
||||||
guildId: channel.guild.id,
|
|
||||||
channelId: channel.id,
|
|
||||||
channelName: channel.name,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
logger.error({ userId, error: msg }, "Segment finalize failed");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Bot audio, metadata failure, or no segment opened — discard.
|
|
||||||
discardSegmentFiles(seg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// finalizeSegment reads the OGG file, so wait for the write stream to
|
|
||||||
// flush before touching it.
|
|
||||||
if (seg?.out.writableFinished) {
|
|
||||||
doFinalize();
|
|
||||||
} else {
|
|
||||||
seg?.out.once("finish", doFinalize);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach audioStream handlers BEFORE pipe() (prevents data loss).
|
|
||||||
audioStream.on("data", (chunk: Buffer) => {
|
|
||||||
if (chunk.length < 8) return;
|
|
||||||
// One segment per burst — no time-based rotation here. Only rotate the
|
|
||||||
// web-PCM broadcast decoder to bound its memory.
|
|
||||||
decoder.rotateIfNeeded();
|
|
||||||
decoder.write(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
audioStream.on("end", () => {
|
|
||||||
finishBurst();
|
|
||||||
emitSpeakingFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
audioStream.on("error", (error: Error) => {
|
|
||||||
logger.error({ userId, error: error.message }, "Audio stream error");
|
|
||||||
finishBurst();
|
|
||||||
emitSpeakingFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pipe for OGG recording (handlers already attached).
|
|
||||||
const oggPacketStream = audioStream.pipe(packetFilter);
|
|
||||||
|
|
||||||
// Open the (single, per-burst) segment.
|
|
||||||
const currentSegment = segmentManager.open(oggPacketStream);
|
|
||||||
|
|
||||||
// Handle file-write errors on the underlying write stream.
|
|
||||||
currentSegment.out.on("error", (err: unknown) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
logger.error({ userId, error: msg }, "File write error");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle packet-filter errors (rare) by closing the burst.
|
|
||||||
packetFilter.on("error", (err: Error) => {
|
|
||||||
logger.error({ userId, error: err.message }, "PacketFilter error");
|
|
||||||
finishBurst();
|
|
||||||
emitSpeakingFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch user metadata in the background; reject bots by discarding burst.
|
|
||||||
collectUserMetadata(client, userId, channel)
|
|
||||||
.then((meta) => {
|
|
||||||
if (meta.bot) {
|
|
||||||
accepted = false;
|
|
||||||
finishBurst();
|
|
||||||
emitSpeakingFalse();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userMetadata = meta;
|
|
||||||
eventBroadcaster?.voiceActiveUser(userId, {
|
|
||||||
username: meta.username,
|
|
||||||
avatar: meta.avatarUrl,
|
|
||||||
speaking: true,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
logger.warn(
|
|
||||||
{ userId, error: msg },
|
|
||||||
"Metadata fetch failed, dropping burst",
|
|
||||||
);
|
|
||||||
accepted = false;
|
|
||||||
finishBurst();
|
|
||||||
emitSpeakingFalse();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user