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:
@@ -210,24 +210,24 @@ export class DashboardRepository {
|
|||||||
[...params, limit + 1],
|
[...params, limit + 1],
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = ((rows as Record<string, unknown>[]) || []).slice(0, limit).map((r) => ({
|
const data = ((rows as Record<string, unknown>[]) || [])
|
||||||
channel_id: String(r.channel_id),
|
.slice(0, limit)
|
||||||
channel_name: r.channel_name as string | null,
|
.map((r) => ({
|
||||||
guild_id: r.guild_id as string | null,
|
channel_id: String(r.channel_id),
|
||||||
total_messages: Number(r.total_messages),
|
channel_name: r.channel_name as string | null,
|
||||||
flagged_count: Number(r.flagged_count),
|
guild_id: r.guild_id as string | null,
|
||||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
total_messages: Number(r.total_messages),
|
||||||
culture_summary: r.culture_summary as string | null,
|
flagged_count: Number(r.flagged_count),
|
||||||
last_analyzed_at: r.last_analyzed_at
|
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||||
? Number(r.last_analyzed_at)
|
culture_summary: r.culture_summary as string | null,
|
||||||
: null,
|
last_analyzed_at: r.last_analyzed_at
|
||||||
}));
|
? Number(r.last_analyzed_at)
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
rows.length > limit
|
rows.length > limit ? String(lastRow?.total_messages ?? "") : null;
|
||||||
? String(lastRow?.total_messages ?? "")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return { data, nextCursor };
|
return { data, nextCursor };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,7 @@ export function createDashboardRouter(): Router {
|
|||||||
const search =
|
const search =
|
||||||
typeof req.query.search === "string" ? req.query.search : undefined;
|
typeof req.query.search === "string" ? req.query.search : undefined;
|
||||||
const guildId =
|
const guildId =
|
||||||
typeof req.query.guild_id === "string"
|
typeof req.query.guild_id === "string" ? req.query.guild_id : undefined;
|
||||||
? req.query.guild_id
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result = await dashboardService.listChannels({
|
const result = await dashboardService.listChannels({
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ export class DashboardService {
|
|||||||
return dashboardRepository.getUserDetail(userId);
|
return dashboardRepository.getUserDetail(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listChannels(query: { limit: number; search?: string; guildId?: string }) {
|
async listChannels(query: {
|
||||||
|
limit: number;
|
||||||
|
search?: string;
|
||||||
|
guildId?: string;
|
||||||
|
}) {
|
||||||
logger.debug({ query }, "Listing dashboard channels");
|
logger.debug({ query }, "Listing dashboard channels");
|
||||||
return dashboardRepository.listChannels(query);
|
return dashboardRepository.listChannels(query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ export function createRecordingsRouter(): Router {
|
|||||||
"/recordings",
|
"/recordings",
|
||||||
asyncHandler(async (req: Request, res: Response) => {
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
const limit = Number(req.query.limit) || 50;
|
const limit = Number(req.query.limit) || 50;
|
||||||
logger.debug({ limit }, "Fetching recordings");
|
const channelId = req.query.channelId as string | undefined;
|
||||||
const result = await recordingsService.getRecent(limit);
|
const userId = req.query.userId as string | undefined;
|
||||||
|
logger.debug({ limit, channelId, userId }, "Fetching recordings");
|
||||||
|
const result = await recordingsService.getRecent(limit, {
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,17 +5,37 @@ import { getDatabase } from "../../shared/database/index.js";
|
|||||||
const logger = createChildLogger("recordings.service");
|
const logger = createChildLogger("recordings.service");
|
||||||
|
|
||||||
export class RecordingsService {
|
export class RecordingsService {
|
||||||
async getRecent(limit = 50) {
|
async getRecent(
|
||||||
|
limit = 50,
|
||||||
|
filters?: { channelId?: string; userId?: string },
|
||||||
|
) {
|
||||||
logger.info({ limit }, "getRecent called");
|
logger.info({ limit }, "getRecent called");
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
logger.debug({ limit }, "Fetching recent voice recordings");
|
logger.debug({ limit }, "Fetching recent voice recordings");
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (filters?.channelId) {
|
||||||
|
params.push(filters.channelId);
|
||||||
|
conditions.push(`channel_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (filters?.userId) {
|
||||||
|
params.push(filters.userId);
|
||||||
|
conditions.push(`user_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
const { rows } = await db.execute(sql`
|
const { rows } = await db.execute(sql`
|
||||||
SELECT
|
SELECT
|
||||||
id, user_id, username, avatar_url, guild_id, channel_id,
|
id, user_id, username, avatar_url, guild_id, channel_id,
|
||||||
channel_name, filename, size_bytes, download_url,
|
channel_name, filename, size_bytes, download_url,
|
||||||
upload_status, upload_error, created_at, uploaded_at
|
upload_status, upload_error, created_at, uploaded_at,
|
||||||
|
COALESCE(size_bytes, 0) AS duration_bytes
|
||||||
FROM voice_recordings
|
FROM voice_recordings
|
||||||
|
${sql.raw(whereClause)}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
COMMAND_VOICE_CHANNELS,
|
COMMAND_VOICE_CHANNELS,
|
||||||
COMMAND_VOICE_CONNECT,
|
COMMAND_VOICE_CONNECT,
|
||||||
COMMAND_VOICE_DISCONNECT,
|
COMMAND_VOICE_DISCONNECT,
|
||||||
|
COMMAND_VOICE_DISCONNECT_GUILD,
|
||||||
CommandReply,
|
CommandReply,
|
||||||
VOICE_STATUS_KEY,
|
VOICE_STATUS_KEY,
|
||||||
} from "@bete/shared";
|
} from "@bete/shared";
|
||||||
@@ -28,11 +29,19 @@ export interface Channel {
|
|||||||
type: "voice" | "text";
|
type: "voice" | "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GuildVoiceEntry {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
connectedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VoiceStatus {
|
export interface VoiceStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
activeGuildId: string | null;
|
activeGuildId: string | null;
|
||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
activeChannelName: string | null;
|
activeChannelName: string | null;
|
||||||
|
connections: GuildVoiceEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
||||||
@@ -40,6 +49,7 @@ export const DEFAULT_VOICE_STATUS: VoiceStatus = {
|
|||||||
activeGuildId: null,
|
activeGuildId: null,
|
||||||
activeChannelId: null,
|
activeChannelId: null,
|
||||||
activeChannelName: null,
|
activeChannelName: null,
|
||||||
|
connections: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,3 +167,18 @@ export async function disconnectVoice(): Promise<VoiceStatus> {
|
|||||||
"disconnectVoice",
|
"disconnectVoice",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from a specific guild's voice channel.
|
||||||
|
*/
|
||||||
|
export async function disconnectVoiceGuild(
|
||||||
|
guildId: string,
|
||||||
|
): Promise<VoiceStatus> {
|
||||||
|
logger.info({ guildId }, "disconnectVoiceGuild called");
|
||||||
|
return withFallback(
|
||||||
|
() =>
|
||||||
|
publishCommand<VoiceStatus>(COMMAND_VOICE_DISCONNECT_GUILD, { guildId }),
|
||||||
|
() => readVoiceStatusFallback(),
|
||||||
|
"disconnectVoiceGuild",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,19 @@ import {
|
|||||||
DISCORD_ANALYSIS_QUEUE_STATUS,
|
DISCORD_ANALYSIS_QUEUE_STATUS,
|
||||||
DISCORD_ATTACHMENT_CREATED,
|
DISCORD_ATTACHMENT_CREATED,
|
||||||
DISCORD_ATTACHMENT_UPLOADED,
|
DISCORD_ATTACHMENT_UPLOADED,
|
||||||
|
DISCORD_CHANNEL_TOPIC_UPDATED,
|
||||||
|
DISCORD_GUILD_MEMBER_ADDED,
|
||||||
|
DISCORD_GUILD_MEMBER_REMOVED,
|
||||||
DISCORD_MESSAGE_ANALYZED,
|
DISCORD_MESSAGE_ANALYZED,
|
||||||
DISCORD_MESSAGE_CREATED,
|
DISCORD_MESSAGE_CREATED,
|
||||||
DISCORD_MESSAGE_DELETED,
|
DISCORD_MESSAGE_DELETED,
|
||||||
DISCORD_MESSAGE_UPDATED,
|
DISCORD_MESSAGE_UPDATED,
|
||||||
|
DISCORD_PRESENCE_UPDATED,
|
||||||
|
DISCORD_REACTION_ADDED,
|
||||||
|
DISCORD_REACTION_REMOVED,
|
||||||
|
DISCORD_THREAD_CREATED,
|
||||||
|
DISCORD_THREAD_DELETED,
|
||||||
|
DISCORD_THREAD_UPDATED,
|
||||||
DISCORD_VOICE_ACTIVE_USER,
|
DISCORD_VOICE_ACTIVE_USER,
|
||||||
DISCORD_VOICE_PCM,
|
DISCORD_VOICE_PCM,
|
||||||
DISCORD_VOICE_STARTED,
|
DISCORD_VOICE_STARTED,
|
||||||
@@ -40,6 +49,18 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
|
|||||||
},
|
},
|
||||||
{ channel: DISCORD_VOICE_ACTIVE_USER, eventType: "voice_active_user" },
|
{ channel: DISCORD_VOICE_ACTIVE_USER, eventType: "voice_active_user" },
|
||||||
{ channel: DISCORD_VOICE_PCM, eventType: "voice_pcm_data" },
|
{ channel: DISCORD_VOICE_PCM, eventType: "voice_pcm_data" },
|
||||||
|
{ channel: DISCORD_REACTION_ADDED, eventType: "reaction_added" },
|
||||||
|
{ channel: DISCORD_REACTION_REMOVED, eventType: "reaction_removed" },
|
||||||
|
{ channel: DISCORD_THREAD_CREATED, eventType: "thread_created" },
|
||||||
|
{ channel: DISCORD_THREAD_DELETED, eventType: "thread_deleted" },
|
||||||
|
{ channel: DISCORD_THREAD_UPDATED, eventType: "thread_updated" },
|
||||||
|
{
|
||||||
|
channel: DISCORD_CHANNEL_TOPIC_UPDATED,
|
||||||
|
eventType: "channel_topic_updated",
|
||||||
|
},
|
||||||
|
{ channel: DISCORD_PRESENCE_UPDATED, eventType: "presence_updated" },
|
||||||
|
{ channel: DISCORD_GUILD_MEMBER_ADDED, eventType: "guild_member_added" },
|
||||||
|
{ channel: DISCORD_GUILD_MEMBER_REMOVED, eventType: "guild_member_removed" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { createChildLogger } from "@bete/shared/logger";
|
|||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import type { VoiceController } from "../voice-recording/voiceController.js";
|
import type {
|
||||||
|
VoiceController,
|
||||||
|
VoiceStatus,
|
||||||
|
} from "../voice-recording/voiceController.js";
|
||||||
import { GuildHandler } from "./guild.handler.js";
|
import { GuildHandler } from "./guild.handler.js";
|
||||||
import {
|
import {
|
||||||
type CommandHandlerFn,
|
type CommandHandlerFn,
|
||||||
@@ -30,6 +33,12 @@ interface VoiceStatusPayload {
|
|||||||
activeGuildId: string | null;
|
activeGuildId: string | null;
|
||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
activeChannelName: string | null;
|
activeChannelName: string | null;
|
||||||
|
connections: Array<{
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
connectedAt: number;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -69,7 +78,11 @@ export class CommandHandler {
|
|||||||
this.voiceController = voiceController;
|
this.voiceController = voiceController;
|
||||||
|
|
||||||
// Create domain-specific handlers with their dependencies
|
// Create domain-specific handlers with their dependencies
|
||||||
this.voiceHandler = new VoiceHandler(client, voiceController, this.redisPub);
|
this.voiceHandler = new VoiceHandler(
|
||||||
|
client,
|
||||||
|
voiceController,
|
||||||
|
this.redisPub,
|
||||||
|
);
|
||||||
this.mediaHandler = new MediaHandler();
|
this.mediaHandler = new MediaHandler();
|
||||||
this.guildHandler = new GuildHandler(client);
|
this.guildHandler = new GuildHandler(client);
|
||||||
this.moderationHandler = new ModerationHandler(client);
|
this.moderationHandler = new ModerationHandler(client);
|
||||||
@@ -164,14 +177,23 @@ export class CommandHandler {
|
|||||||
// ---- Status publishing ----
|
// ---- Status publishing ----
|
||||||
|
|
||||||
private publishVoiceStatus(): void {
|
private publishVoiceStatus(): void {
|
||||||
const status: VoiceStatusPayload = this.voiceController
|
const raw = this.voiceController
|
||||||
? this.voiceController.getStatus()
|
? this.voiceController.getStatus()
|
||||||
: {
|
: {
|
||||||
|
ready: false,
|
||||||
connected: false,
|
connected: false,
|
||||||
activeGuildId: null,
|
activeGuildId: null,
|
||||||
activeChannelId: null,
|
activeChannelId: null,
|
||||||
activeChannelName: 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));
|
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
COMMAND_VOICE_CHANNELS,
|
COMMAND_VOICE_CHANNELS,
|
||||||
COMMAND_VOICE_CONNECT,
|
COMMAND_VOICE_CONNECT,
|
||||||
COMMAND_VOICE_DISCONNECT,
|
COMMAND_VOICE_DISCONNECT,
|
||||||
|
COMMAND_VOICE_DISCONNECT_GUILD,
|
||||||
COMMAND_VOICE_TRANSMIT_START,
|
COMMAND_VOICE_TRANSMIT_START,
|
||||||
COMMAND_VOICE_TRANSMIT_STOP,
|
COMMAND_VOICE_TRANSMIT_STOP,
|
||||||
type CommandMessage,
|
type CommandMessage,
|
||||||
@@ -46,6 +47,9 @@ export function createHandlerRegistry(
|
|||||||
registry.set(COMMAND_VOICE_DISCONNECT, (cmd) =>
|
registry.set(COMMAND_VOICE_DISCONNECT, (cmd) =>
|
||||||
voiceHandler.handleVoiceDisconnect(cmd),
|
voiceHandler.handleVoiceDisconnect(cmd),
|
||||||
);
|
);
|
||||||
|
registry.set(COMMAND_VOICE_DISCONNECT_GUILD, (cmd) =>
|
||||||
|
voiceHandler.handleVoiceDisconnectGuild(cmd),
|
||||||
|
);
|
||||||
registry.set(COMMAND_VOICE_CHANNELS, (cmd) =>
|
registry.set(COMMAND_VOICE_CHANNELS, (cmd) =>
|
||||||
voiceHandler.handleVoiceChannels(cmd),
|
voiceHandler.handleVoiceChannels(cmd),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { type CommandMessage, type CommandReply } from "@bete/shared";
|
import {
|
||||||
|
COMMAND_VOICE_DISCONNECT_GUILD,
|
||||||
|
type CommandMessage,
|
||||||
|
type CommandReply,
|
||||||
|
} from "@bete/shared";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { Client } from "discord.js-selfbot-v13";
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import type Redis from "ioredis";
|
import type Redis from "ioredis";
|
||||||
@@ -72,6 +76,33 @@ export class VoiceHandler {
|
|||||||
return { id: cmd.id, success: true, data: status };
|
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);
|
||||||
|
const status = this.voiceController.getStatus();
|
||||||
|
return { id: cmd.id, success: true, data: status };
|
||||||
|
}
|
||||||
|
|
||||||
async handleVoiceChannels(
|
async handleVoiceChannels(
|
||||||
cmd: CommandMessage,
|
cmd: CommandMessage,
|
||||||
): Promise<CommandReply<unknown>> {
|
): Promise<CommandReply<unknown>> {
|
||||||
@@ -126,7 +157,9 @@ export class VoiceHandler {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Reuse shared Redis connection from CommandHandler
|
// Reuse shared Redis connection from CommandHandler
|
||||||
const transmitRedis = this.sharedRedis ?? new (await import("ioredis")).default(config.REDIS_URL);
|
const transmitRedis =
|
||||||
|
this.sharedRedis ??
|
||||||
|
new (await import("ioredis")).default(config.REDIS_URL);
|
||||||
await voiceTransmitter.start(transmitRedis);
|
await voiceTransmitter.start(transmitRedis);
|
||||||
|
|
||||||
const status = voiceTransmitter.getStatus();
|
const status = voiceTransmitter.getStatus();
|
||||||
|
|||||||
@@ -307,7 +307,9 @@ export async function extractMediaInfo(url: string): Promise<MediaInfo> {
|
|||||||
if (proc.stderr) {
|
if (proc.stderr) {
|
||||||
proc.stderr.on("data", (chunk: Buffer) => {
|
proc.stderr.on("data", (chunk: Buffer) => {
|
||||||
if (stderrBuf.length < MAX_STDERR) {
|
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 snapshot = session.snapshot(Date.now());
|
||||||
const stoppedAt = Date.now();
|
const stoppedAt = Date.now();
|
||||||
|
|
||||||
_eventBroadcaster.voiceRecordingStopped({
|
_eventBroadcaster
|
||||||
guild_id: guildId,
|
.voiceRecordingStopped({
|
||||||
session_id: session.sessionId,
|
guild_id: guildId,
|
||||||
duration_ms: snapshot.durationMs,
|
session_id: session.sessionId,
|
||||||
participants: snapshot.participants.length,
|
duration_ms: snapshot.durationMs,
|
||||||
segment_count: snapshot.segments.length,
|
participants: snapshot.participants.length,
|
||||||
status: snapshot.status,
|
segment_count: snapshot.segments.length,
|
||||||
stopped_at: stoppedAt,
|
status: snapshot.status,
|
||||||
}).catch(() => {});
|
stopped_at: stoppedAt,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
// Auto-enqueue muxer job if there are multiple segments
|
// Auto-enqueue muxer job if there are multiple segments
|
||||||
const segments = snapshot.segments;
|
const segments = snapshot.segments;
|
||||||
if (segments.length >= 2) {
|
if (segments.length >= 2) {
|
||||||
const outputFile = `${config.RECORDINGS_DIR}/merged/${session.sessionId}.ogg`;
|
const outputFile = `${config.RECORDINGS_DIR}/merged/${session.sessionId}.ogg`;
|
||||||
import("./muxer.js").then(({ enqueueMuxerJob }) => {
|
import("./muxer.js")
|
||||||
enqueueMuxerJob({
|
.then(({ enqueueMuxerJob }) => {
|
||||||
inputs: segments.map((s) => s.oggPath),
|
enqueueMuxerJob({
|
||||||
output: outputFile,
|
inputs: segments.map((s) => s.oggPath),
|
||||||
guildId,
|
output: outputFile,
|
||||||
channelId: snapshot.channelId,
|
guildId,
|
||||||
sessionId: session.sessionId,
|
channelId: snapshot.channelId,
|
||||||
}).catch(() => {});
|
sessionId: session.sessionId,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,34 +7,49 @@ import { startRecording, stopRecording } from "./recorder.js";
|
|||||||
|
|
||||||
const logger = createChildLogger("voice-controller");
|
const logger = createChildLogger("voice-controller");
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GuildVoiceState {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
connectedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VoiceStatus {
|
export interface VoiceStatus {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
activeGuildId: string | null;
|
activeGuildId: string | null;
|
||||||
activeChannelId: string | null;
|
activeChannelId: string | null;
|
||||||
activeChannelName: string | null;
|
activeChannelName: string | null;
|
||||||
|
/** Multi-guild: list of all active connections */
|
||||||
|
connections: GuildVoiceState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── VoiceController ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class VoiceController {
|
export class VoiceController {
|
||||||
private activeGuildId: string | null = null;
|
private connections = new Map<string, GuildVoiceState>();
|
||||||
private activeChannelId: string | null = null;
|
private connecting = new Set<string>();
|
||||||
private activeChannelName: string | null = null;
|
|
||||||
private connecting = false;
|
|
||||||
|
|
||||||
constructor(private readonly client: Client) {}
|
constructor(private readonly client: Client) {}
|
||||||
|
|
||||||
getStatus(): VoiceStatus {
|
getStatus(): VoiceStatus {
|
||||||
logger.debug("getStatus called");
|
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;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: this.client.isReady(),
|
ready: this.client.isReady(),
|
||||||
connected: Boolean(connection),
|
connected: this.connections.size > 0,
|
||||||
activeGuildId: this.activeGuildId,
|
activeGuildId: primary?.guildId ?? null,
|
||||||
activeChannelId: this.activeChannelId,
|
activeChannelId: primary?.channelId ?? null,
|
||||||
activeChannelName: this.activeChannelName,
|
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(
|
throw new AppError(
|
||||||
"Voice connection is already in progress",
|
`Voice connection for guild ${guildId} is already in progress`,
|
||||||
"CONNECT_IN_PROGRESS",
|
"CONNECT_IN_PROGRESS",
|
||||||
409,
|
409,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connecting = true;
|
this.connecting.add(guildId);
|
||||||
|
|
||||||
try {
|
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 guild = this.getGuild(guildId);
|
||||||
const channel =
|
const channel =
|
||||||
@@ -94,10 +112,18 @@ export class VoiceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
discordPlayer.setConnection(connection as VoiceConnection);
|
// If this is the first connection, set it as the player's connection
|
||||||
this.activeGuildId = guildId;
|
if (this.connections.size === 0) {
|
||||||
this.activeChannelId = channelId;
|
discordPlayer.setConnection(connection as VoiceConnection);
|
||||||
this.activeChannelName = channel.name;
|
}
|
||||||
|
|
||||||
|
const state: GuildVoiceState = {
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
channelName: channel.name,
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
};
|
||||||
|
this.connections.set(guildId, state);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ guildId, channelId, channelName: channel.name },
|
{ guildId, channelId, channelName: channel.name },
|
||||||
@@ -106,24 +132,31 @@ export class VoiceController {
|
|||||||
|
|
||||||
return this.getStatus();
|
return this.getStatus();
|
||||||
} finally {
|
} finally {
|
||||||
this.connecting = false;
|
this.connecting.delete(guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<VoiceStatus> {
|
async disconnect(): Promise<VoiceStatus> {
|
||||||
logger.info("disconnect called");
|
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();
|
discordPlayer.stop();
|
||||||
this.activeGuildId = null;
|
|
||||||
this.activeChannelId = null;
|
|
||||||
this.activeChannelName = null;
|
|
||||||
|
|
||||||
return this.getStatus();
|
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 {
|
private getGuild(guildId: string): Guild {
|
||||||
const guild = this.client.guilds.cache.get(guildId);
|
const guild = this.client.guilds.cache.get(guildId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { AlertCircle, ArrowLeft, Hash, RefreshCw } from "lucide-react";
|
import { AlertCircle, ArrowLeft, Hash, RefreshCw } from "lucide-react";
|
||||||
import type { DashboardChannelDetail } from "../../../shared/api/client";
|
import type { DashboardChannelDetail } from "../../../shared/api/client";
|
||||||
import {
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||||
cardItem,
|
|
||||||
cardStagger,
|
|
||||||
} from "../../../shared/hooks/useFramerStagger";
|
|
||||||
import { cn } from "../../../shared/lib/utils";
|
import { cn } from "../../../shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import { AlertCircle, Hash, Loader2, RefreshCw, Search } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
Hash,
|
|
||||||
Loader2,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { DashboardChannel } from "../../../shared/api/client";
|
import type { DashboardChannel } from "../../../shared/api/client";
|
||||||
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||||
import { cn } from "../../../shared/lib/utils";
|
import { cn } from "../../../shared/lib/utils";
|
||||||
import {
|
import { Card, CardContent, Input, Skeleton } from "../../../shared/ui";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
Skeleton,
|
|
||||||
} from "../../../shared/ui";
|
|
||||||
|
|
||||||
interface ChannelSummaryListProps {
|
interface ChannelSummaryListProps {
|
||||||
channels: DashboardChannel[];
|
channels: DashboardChannel[];
|
||||||
|
|||||||
@@ -281,7 +281,11 @@ export interface DashboardStats {
|
|||||||
today_messages: number;
|
today_messages: number;
|
||||||
today_flagged: number;
|
today_flagged: number;
|
||||||
active_users_24h: number;
|
active_users_24h: number;
|
||||||
top_channels: Array<{ channel_id: string; channel_name: string | null; message_count: number }>;
|
top_channels: Array<{
|
||||||
|
channel_id: string;
|
||||||
|
channel_name: string | null;
|
||||||
|
message_count: number;
|
||||||
|
}>;
|
||||||
moderation_overview: {
|
moderation_overview: {
|
||||||
pending: number;
|
pending: number;
|
||||||
processing: number;
|
processing: number;
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ export interface WsEventMap {
|
|||||||
voice_active_user: { data: unknown };
|
voice_active_user: { data: unknown };
|
||||||
attachment_created: { data: unknown };
|
attachment_created: { data: unknown };
|
||||||
analysis_queue_status: { data: unknown };
|
analysis_queue_status: { data: unknown };
|
||||||
|
reaction_added: { data: unknown };
|
||||||
|
reaction_removed: { data: unknown };
|
||||||
|
thread_created: { data: unknown };
|
||||||
|
thread_deleted: { data: unknown };
|
||||||
|
thread_updated: { data: unknown };
|
||||||
|
channel_topic_updated: { data: unknown };
|
||||||
|
presence_updated: { data: unknown };
|
||||||
|
guild_member_added: { data: unknown };
|
||||||
|
guild_member_removed: { data: unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsEventType = keyof WsEventMap;
|
export type WsEventType = keyof WsEventMap;
|
||||||
|
|||||||
Reference in New Issue
Block a user