feat(discord-gateway): implement voice & push improvements

- Voice disconnect broadcast on stopRecording
- Multi-guild voice support (VoiceController Map<guildId>)
- Session finalization + auto-enqueue muxer job
- Recordings API: duration field, channelId/userId filters
- Transmitter Redis connection reuse (shared conn)
- FFmpeg stderr memory cap (4KB limit)
- 10 new Redis event channels + Redis bridge subscriptions
- New DB tables: message_reactions, message_edits
- Webhook notification module
- Gateway metrics / Prometheus endpoint
- Multi-guild message capture (MONITOR_GUILD_IDS array)
- Thread tracking, presence, channel topic, guild member events
- Edit history snapshot on message update
- Muxer audio post-processing worker

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-13 13:51:21 +07:00
co-authored by Claude
parent fd3b5c5ca5
commit 14c0081f01
17 changed files with 260 additions and 90 deletions
@@ -307,7 +307,9 @@ export async function extractMediaInfo(url: string): Promise<MediaInfo> {
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
if (stderrBuf.length < MAX_STDERR) {
stderrBuf += chunk.toString("utf8").slice(0, MAX_STDERR - stderrBuf.length);
stderrBuf += chunk
.toString("utf8")
.slice(0, MAX_STDERR - stderrBuf.length);
}
});
}
@@ -194,29 +194,33 @@ export function stopRecording(guildId: string): void {
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(() => {});
_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(() => {});
// 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}.ogg`;
import("./muxer.js").then(({ enqueueMuxerJob }) => {
enqueueMuxerJob({
inputs: segments.map((s) => s.oggPath),
output: outputFile,
guildId,
channelId: snapshot.channelId,
sessionId: session.sessionId,
}).catch(() => {});
}).catch(() => {});
import("./muxer.js")
.then(({ enqueueMuxerJob }) => {
enqueueMuxerJob({
inputs: segments.map((s) => s.oggPath),
output: outputFile,
guildId,
channelId: snapshot.channelId,
sessionId: session.sessionId,
}).catch(() => {});
})
.catch(() => {});
}
}
}
@@ -7,34 +7,49 @@ import { startRecording, stopRecording } from "./recorder.js";
const logger = createChildLogger("voice-controller");
// ─── Types ───────────────────────────────────────────────────────────────
export interface GuildVoiceState {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
export interface VoiceStatus {
ready: boolean;
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
/** Multi-guild: list of all active connections */
connections: GuildVoiceState[];
}
// ─── VoiceController ─────────────────────────────────────────────────────
export class VoiceController {
private activeGuildId: string | null = null;
private activeChannelId: string | null = null;
private activeChannelName: string | null = null;
private connecting = false;
private connections = new Map<string, GuildVoiceState>();
private connecting = new Set<string>();
constructor(private readonly client: Client) {}
getStatus(): VoiceStatus {
logger.debug("getStatus called");
const connection = this.activeGuildId
? getVoiceConnection(this.activeGuildId)
// Primary connection (legacy compat — first entry or explicitly set)
const primaryGuildId = this.connections.keys().next().value ?? null;
const primary = primaryGuildId
? this.connections.get(primaryGuildId)
: undefined;
return {
ready: this.client.isReady(),
connected: Boolean(connection),
activeGuildId: this.activeGuildId,
activeChannelId: this.activeChannelId,
activeChannelName: this.activeChannelName,
connected: this.connections.size > 0,
activeGuildId: primary?.guildId ?? null,
activeChannelId: primary?.channelId ?? null,
activeChannelName: primary?.channelName ?? null,
connections: Array.from(this.connections.values()),
};
}
@@ -48,18 +63,21 @@ export class VoiceController {
);
}
if (this.connecting) {
if (this.connecting.has(guildId)) {
throw new AppError(
"Voice connection is already in progress",
`Voice connection for guild ${guildId} is already in progress`,
"CONNECT_IN_PROGRESS",
409,
);
}
this.connecting = true;
this.connecting.add(guildId);
try {
await this.disconnect();
// Disconnect existing connection for this guild first
if (this.connections.has(guildId)) {
await this.disconnectGuild(guildId);
}
const guild = this.getGuild(guildId);
const channel =
@@ -94,10 +112,18 @@ export class VoiceController {
);
}
discordPlayer.setConnection(connection as VoiceConnection);
this.activeGuildId = guildId;
this.activeChannelId = channelId;
this.activeChannelName = channel.name;
// If this is the first connection, set it as the player's connection
if (this.connections.size === 0) {
discordPlayer.setConnection(connection as VoiceConnection);
}
const state: GuildVoiceState = {
guildId,
channelId,
channelName: channel.name,
connectedAt: Date.now(),
};
this.connections.set(guildId, state);
logger.info(
{ guildId, channelId, channelName: channel.name },
@@ -106,24 +132,31 @@ export class VoiceController {
return this.getStatus();
} finally {
this.connecting = false;
this.connecting.delete(guildId);
}
}
async disconnect(): Promise<VoiceStatus> {
logger.info("disconnect called");
if (this.activeGuildId) {
stopRecording(this.activeGuildId);
// Disconnect all guilds
const guildIds = Array.from(this.connections.keys());
for (const gid of guildIds) {
await this.disconnectGuild(gid);
}
discordPlayer.stop();
this.activeGuildId = null;
this.activeChannelId = null;
this.activeChannelName = null;
return this.getStatus();
}
async disconnectGuild(guildId: string): Promise<void> {
logger.info({ guildId }, "disconnectGuild called");
if (this.connections.has(guildId)) {
stopRecording(guildId);
this.connections.delete(guildId);
}
}
private getGuild(guildId: string): Guild {
const guild = this.client.guilds.cache.get(guildId);