2026-06-04 18:52:56 +07:00
|
|
|
import { createChildLogger } from "@bete/shared/logger";
|
2026-06-02 00:32:38 +07:00
|
|
|
import type { Client } from "discord.js-selfbot-v13";
|
2026-06-02 11:02:54 +07:00
|
|
|
import Redis from "ioredis";
|
2026-06-02 00:32:38 +07:00
|
|
|
import { config } from "../../shared/config/config.js";
|
2026-06-02 11:02:54 +07:00
|
|
|
import { discordPlayer } from "../voice-recording/player.js";
|
2026-06-08 20:48:16 +07:00
|
|
|
import { voiceTransmitter } from "../voice-recording/transmitter.js";
|
2026-06-02 11:02:54 +07:00
|
|
|
import type { VoiceController } from "../voice-recording/voiceController.js";
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("command-handler");
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Types
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
interface BackendCommand {
|
|
|
|
|
id: string;
|
|
|
|
|
type: string;
|
|
|
|
|
payload: Record<string, unknown>;
|
|
|
|
|
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;
|
2026-06-08 18:09:17 +07:00
|
|
|
private redisPub: Redis;
|
2026-06-02 00:32:38 +07:00
|
|
|
private client: Client | null = null;
|
|
|
|
|
private voiceController: VoiceController | null = null;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
this.redisSub = new Redis(config.REDIS_URL);
|
2026-06-08 18:09:17 +07:00
|
|
|
this.redisPub = new Redis(config.REDIS_URL);
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
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<void> {
|
2026-06-08 18:09:17 +07:00
|
|
|
await Promise.allSettled([this.redisSub.quit(), this.redisPub.quit()]);
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Command dispatch ----
|
|
|
|
|
|
|
|
|
|
private async handleCommand(raw: string): Promise<void> {
|
|
|
|
|
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;
|
2026-06-02 11:02:54 +07:00
|
|
|
case "voice:channels":
|
|
|
|
|
reply = await this.handleVoiceChannels(cmd);
|
|
|
|
|
break;
|
2026-06-08 20:48:16 +07:00
|
|
|
case "voice:transmit:start":
|
|
|
|
|
reply = await this.handleVoiceTransmitStart(cmd);
|
|
|
|
|
break;
|
|
|
|
|
case "voice:transmit:stop":
|
|
|
|
|
reply = await this.handleVoiceTransmitStop(cmd);
|
|
|
|
|
break;
|
2026-06-02 11:02:54 +07:00
|
|
|
case "guilds:list":
|
|
|
|
|
reply = await this.handleListGuilds(cmd);
|
|
|
|
|
break;
|
|
|
|
|
case "guilds:text-channels":
|
|
|
|
|
reply = await this.handleTextChannels(cmd);
|
|
|
|
|
break;
|
2026-06-02 00:32:38 +07:00
|
|
|
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);
|
2026-06-02 11:02:54 +07:00
|
|
|
logger.error(
|
|
|
|
|
{ commandId: cmd.id, error: message },
|
|
|
|
|
"Command execution failed",
|
|
|
|
|
);
|
2026-06-02 00:32:38 +07:00
|
|
|
reply = {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: message,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 11:06:14 +07:00
|
|
|
// Publish reply on the designated reply channel using the persistent publisher.
|
2026-06-02 00:32:38 +07:00
|
|
|
try {
|
2026-06-09 11:06:14 +07:00
|
|
|
await this.redisPub.publish(cmd.replyChannel, JSON.stringify(reply));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.error({ err }, "Failed to publish command reply");
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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<CommandReply> {
|
|
|
|
|
if (!this.client || !this.voiceController) {
|
2026-06-02 11:02:54 +07:00
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: "Gateway not initialized",
|
|
|
|
|
};
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 11:02:54 +07:00
|
|
|
private async handleVoiceDisconnect(
|
|
|
|
|
cmd: BackendCommand,
|
|
|
|
|
): Promise<CommandReply> {
|
2026-06-02 00:32:38 +07:00
|
|
|
if (!this.voiceController) {
|
2026-06-02 11:02:54 +07:00
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: "Gateway not initialized",
|
|
|
|
|
};
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = await this.voiceController.disconnect();
|
|
|
|
|
return { id: cmd.id, success: true, data: status };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 11:02:54 +07:00
|
|
|
private async handleVoiceChannels(
|
|
|
|
|
cmd: BackendCommand,
|
|
|
|
|
): Promise<CommandReply> {
|
|
|
|
|
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<CommandReply> {
|
|
|
|
|
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<CommandReply> {
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
private async handleMediaQueue(_cmd: BackendCommand): Promise<CommandReply> {
|
|
|
|
|
// 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<CommandReply> {
|
|
|
|
|
discordPlayer.stop("music");
|
|
|
|
|
return { id: cmd.id, success: true, data: { action: "skipped" } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleMediaStop(cmd: BackendCommand): Promise<CommandReply> {
|
|
|
|
|
discordPlayer.stop("music");
|
|
|
|
|
return { id: cmd.id, success: true, data: { action: "stopped" } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleMediaVolume(cmd: BackendCommand): Promise<CommandReply> {
|
|
|
|
|
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);
|
2026-06-02 11:02:54 +07:00
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: true,
|
|
|
|
|
data: { volume: discordPlayer.getMusicVolume() },
|
|
|
|
|
};
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 10:16:04 +07:00
|
|
|
private async handleVoiceTransmitStart(
|
|
|
|
|
cmd: BackendCommand,
|
|
|
|
|
): Promise<CommandReply> {
|
2026-06-08 20:48:16 +07:00
|
|
|
if (!discordPlayer.isConnected()) {
|
|
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: "Not connected to voice channel",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Create a new Redis connection for the transmitter
|
|
|
|
|
const transmitRedis = new Redis(config.REDIS_URL);
|
|
|
|
|
await voiceTransmitter.start(transmitRedis);
|
|
|
|
|
|
|
|
|
|
const status = voiceTransmitter.getStatus();
|
|
|
|
|
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);
|
|
|
|
|
logger.error({ error: message }, "Failed to start voice transmit");
|
|
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: message,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 10:16:04 +07:00
|
|
|
private async handleVoiceTransmitStop(
|
|
|
|
|
cmd: BackendCommand,
|
|
|
|
|
): Promise<CommandReply> {
|
2026-06-08 20:48:16 +07:00
|
|
|
try {
|
|
|
|
|
await voiceTransmitter.stop();
|
|
|
|
|
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);
|
|
|
|
|
logger.error({ error: message }, "Failed to stop voice transmit");
|
|
|
|
|
return {
|
|
|
|
|
id: cmd.id,
|
|
|
|
|
success: false,
|
|
|
|
|
data: null,
|
|
|
|
|
error: message,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:32:38 +07:00
|
|
|
// ---- Status publishing ----
|
|
|
|
|
|
|
|
|
|
private publishVoiceStatus(): void {
|
|
|
|
|
const status: VoiceStatusPayload = this.voiceController
|
|
|
|
|
? this.voiceController.getStatus()
|
2026-06-02 11:02:54 +07:00
|
|
|
: {
|
|
|
|
|
connected: false,
|
|
|
|
|
activeGuildId: null,
|
|
|
|
|
activeChannelId: null,
|
|
|
|
|
activeChannelName: null,
|
|
|
|
|
};
|
2026-06-02 00:32:38 +07:00
|
|
|
|
|
|
|
|
this.setKey(VOICE_STATUS_KEY, JSON.stringify(status));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private publishMediaStatus(): void {
|
|
|
|
|
const status: MediaStatusPayload = {
|
2026-06-08 19:14:34 +07:00
|
|
|
playing: String(discordPlayer.getStatus()),
|
2026-06-02 00:32:38 +07:00
|
|
|
musicVolume: discordPlayer.getMusicVolume(),
|
|
|
|
|
current: null,
|
|
|
|
|
queue: [],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.setKey(MEDIA_STATUS_KEY, JSON.stringify(status));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-08 18:09:17 +07:00
|
|
|
* Fire-and-forget SET using the persistent Redis publisher connection.
|
2026-06-02 00:32:38 +07:00
|
|
|
*/
|
|
|
|
|
private setKey(key: string, value: string): void {
|
2026-06-09 10:16:04 +07:00
|
|
|
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");
|
|
|
|
|
});
|
2026-06-02 00:32:38 +07:00
|
|
|
}
|
|
|
|
|
}
|