Redis root cause: VoiceHandler passed redisPub to transmitter.start(), which called .subscribe() on it — permanently converting the publish connection to subscriber mode. Every subsequent command reply and status update failed. Fixes: 1. Transmitter now creates its own Redis client via new IORedis() 2. Mutual exclusion gate serialises start/stop to prevent null-deref races 3. PassThrough drain listeners cleaned up to stop MaxListenersExceeded 4. FFmpeg SIGTERM flagged as expected exit (no more false level-50 errors) 5. Voice recording repo captures PG error code/detail for diagnostics Co-Authored-By: Claude <noreply@anthropic.com>
217 lines
6.3 KiB
TypeScript
217 lines
6.3 KiB
TypeScript
import {
|
|
BACKEND_COMMAND,
|
|
type CommandMessage,
|
|
type CommandReply,
|
|
MEDIA_STATUS_KEY,
|
|
VOICE_STATUS_KEY,
|
|
} from "@bete/shared";
|
|
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 type {
|
|
VoiceController,
|
|
VoiceStatus,
|
|
} from "../voice-recording/voiceController.js";
|
|
import { GuildHandler } from "./guild.handler.js";
|
|
import {
|
|
type CommandHandlerFn,
|
|
createHandlerRegistry,
|
|
} from "./handler-registry.js";
|
|
import { MediaHandler } from "./media.handler.js";
|
|
import { ModerationHandler } from "./moderation.handler.js";
|
|
import { VoiceHandler } from "./voice.handler.js";
|
|
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class CommandHandler {
|
|
private redisSub: Redis;
|
|
private redisPub: Redis;
|
|
private voiceController: VoiceController | null = null;
|
|
private registry: Map<string, CommandHandlerFn> = new Map();
|
|
private voiceHandler!: VoiceHandler;
|
|
private mediaHandler!: MediaHandler;
|
|
private guildHandler!: GuildHandler;
|
|
private moderationHandler!: ModerationHandler;
|
|
|
|
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.voiceController = voiceController;
|
|
|
|
// Create domain-specific handlers with their dependencies
|
|
this.voiceHandler = new VoiceHandler(
|
|
client,
|
|
voiceController,
|
|
);
|
|
this.mediaHandler = new MediaHandler();
|
|
this.guildHandler = new GuildHandler(client);
|
|
this.moderationHandler = new ModerationHandler(client);
|
|
|
|
// Build the command registry
|
|
this.registry = createHandlerRegistry(
|
|
this.voiceHandler,
|
|
this.mediaHandler,
|
|
this.guildHandler,
|
|
this.moderationHandler,
|
|
);
|
|
|
|
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(BACKEND_COMMAND, (err) => {
|
|
if (err) {
|
|
logger.error({ error: err }, "Failed to subscribe to command channel");
|
|
} else {
|
|
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> {
|
|
await Promise.allSettled([this.redisSub.quit(), this.redisPub.quit()]);
|
|
}
|
|
|
|
// ---- Command dispatch ----
|
|
|
|
private async handleCommand(raw: string): Promise<void> {
|
|
let cmd: CommandMessage;
|
|
try {
|
|
cmd = JSON.parse(raw) as CommandMessage;
|
|
} catch {
|
|
logger.warn({ raw }, "Received invalid JSON on command channel");
|
|
return;
|
|
}
|
|
|
|
logger.info({ commandId: cmd.id, type: cmd.type }, "Received command");
|
|
|
|
let reply: CommandReply<unknown>;
|
|
|
|
try {
|
|
const handler = this.registry.get(cmd.type);
|
|
if (handler) {
|
|
reply = await handler(cmd);
|
|
} else {
|
|
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 using the persistent publisher.
|
|
try {
|
|
await this.redisPub.publish(cmd.replyChannel, JSON.stringify(reply));
|
|
} catch (err) {
|
|
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");
|
|
});
|
|
}
|
|
}
|