import { createChildLogger } from "@bete/shared/logger"; import type { Client } from "discord.js-selfbot-v13"; import Redis from "ioredis"; import { config } from "../../shared/config/config.js"; import { discordPlayer } from "../voice-recording/player.js"; import type { VoiceController } from "../voice-recording/voiceController.js"; const logger = createChildLogger("command-handler"); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface BackendCommand { id: string; type: string; payload: Record; replyChannel: string; } interface CommandReply { id: string; success: boolean; data: unknown; error?: string; } interface VoiceStatusPayload { connected: boolean; activeGuildId: string | null; activeChannelId: string | null; activeChannelName: string | null; } interface MediaStatusPayload { playing: string; musicVolume: number; current: unknown; queue: unknown[]; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const COMMAND_CHANNEL = "backend:command"; const VOICE_STATUS_KEY = "voice:status"; const MEDIA_STATUS_KEY = "media:status"; // --------------------------------------------------------------------------- // CommandHandler // --------------------------------------------------------------------------- export class CommandHandler { private redisSub: Redis; private redisPub: Redis; private client: Client | null = null; private voiceController: VoiceController | null = null; constructor() { this.redisSub = new Redis(config.REDIS_URL); this.redisPub = new Redis(config.REDIS_URL); this.redisSub.on("error", (err) => { logger.error({ error: err }, "Redis subscriber connection error"); }); this.redisSub.on("connect", () => { logger.info("Redis subscriber connected"); }); } // ---- Lifecycle ---- /** * Attach the Discord client and VoiceController, then subscribe to the Redis * command channel. Must be called *after* the Discord client is created. */ start(client: Client, voiceController: VoiceController): void { this.client = client; this.voiceController = voiceController; this.redisSub.on("message", (_channel, message) => { this.handleCommand(message).catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); logger.error({ error: msg }, "Failed to handle command"); }); }); this.redisSub.subscribe(COMMAND_CHANNEL, (err) => { if (err) { logger.error({ error: err }, "Failed to subscribe to command channel"); } else { logger.info(`Subscribed to Redis channel "${COMMAND_CHANNEL}"`); } }); // Publish initial status snapshots so the backend knows the starting state. this.publishVoiceStatus(); this.publishMediaStatus(); } async close(): Promise { await Promise.allSettled([this.redisSub.quit(), this.redisPub.quit()]); } // ---- Command dispatch ---- private async handleCommand(raw: string): Promise { let cmd: BackendCommand; try { cmd = JSON.parse(raw) as BackendCommand; } catch { logger.warn({ raw }, "Received invalid JSON on command channel"); return; } logger.info({ commandId: cmd.id, type: cmd.type }, "Received command"); let reply: CommandReply; try { switch (cmd.type) { case "voice:connect": reply = await this.handleVoiceConnect(cmd); break; case "voice:disconnect": reply = await this.handleVoiceDisconnect(cmd); break; case "voice:channels": reply = await this.handleVoiceChannels(cmd); break; case "guilds:list": reply = await this.handleListGuilds(cmd); break; case "guilds:text-channels": reply = await this.handleTextChannels(cmd); break; case "media:queue": reply = await this.handleMediaQueue(cmd); break; case "media:skip": reply = await this.handleMediaSkip(cmd); break; case "media:stop": reply = await this.handleMediaStop(cmd); break; case "media:volume": reply = await this.handleMediaVolume(cmd); break; default: logger.warn({ type: cmd.type }, "Unknown command type"); reply = { id: cmd.id, success: false, data: null, error: `Unknown command type: ${cmd.type}`, }; } } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error( { commandId: cmd.id, error: message }, "Command execution failed", ); reply = { id: cmd.id, success: false, data: null, error: message, }; } // Publish reply on the designated reply channel. const redisPub = new Redis(config.REDIS_URL); try { await redisPub.publish(cmd.replyChannel, JSON.stringify(reply)); } finally { await redisPub.quit(); } // Always refresh status keys after every command so the backend has // the latest snapshot without polling. this.publishVoiceStatus(); this.publishMediaStatus(); } // ---- Command handlers ---- private async handleVoiceConnect(cmd: BackendCommand): Promise { 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 }; } private async handleVoiceDisconnect( cmd: BackendCommand, ): Promise { if (!this.voiceController) { return { id: cmd.id, success: false, data: null, error: "Gateway not initialized", }; } const status = await this.voiceController.disconnect(); return { id: cmd.id, success: true, data: status }; } private async handleVoiceChannels( cmd: BackendCommand, ): Promise { 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, })); 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 }; } } private async handleListGuilds(cmd: BackendCommand): Promise { if (!this.client) { return { id: cmd.id, success: false, data: null, error: "Gateway not initialized", }; } try { const guilds = this.client.guilds.cache.map((g) => ({ id: g.id, name: g.name, icon: g.iconURL() ?? null, })); return { id: cmd.id, success: true, data: guilds }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { id: cmd.id, success: false, data: null, error: msg }; } } private async handleTextChannels(cmd: BackendCommand): Promise { 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 textChannels = channels .filter((c) => c?.type === "GUILD_TEXT") .map((c) => ({ id: c.id, name: c.name, type: "text" as const, })); return { id: cmd.id, success: true, data: textChannels }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { id: cmd.id, success: false, data: null, error: msg }; } } private async handleMediaQueue(_cmd: BackendCommand): Promise { // Media queueing is handled at a higher level (frontend / backend streams // audio directly). Log the request for now. logger.info("media:queue received — media queueing is handled externally"); return { id: _cmd.id, success: true, data: { note: "media queueing handled externally" }, }; } private async handleMediaSkip(cmd: BackendCommand): Promise { discordPlayer.stop("music"); return { id: cmd.id, success: true, data: { action: "skipped" } }; } private async handleMediaStop(cmd: BackendCommand): Promise { discordPlayer.stop("music"); return { id: cmd.id, success: true, data: { action: "stopped" } }; } private async handleMediaVolume(cmd: BackendCommand): Promise { 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: { volume: discordPlayer.getMusicVolume() }, }; } // ---- Status publishing ---- private publishVoiceStatus(): void { const status: VoiceStatusPayload = this.voiceController ? this.voiceController.getStatus() : { connected: false, activeGuildId: null, activeChannelId: null, activeChannelName: null, }; this.setKey(VOICE_STATUS_KEY, JSON.stringify(status)); } private publishMediaStatus(): void { const status: MediaStatusPayload = { playing: String(discordPlayer.getStatus()), musicVolume: discordPlayer.getMusicVolume(), current: null, queue: [], }; this.setKey(MEDIA_STATUS_KEY, JSON.stringify(status)); } /** * 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"); }); } }