refactor: atomic, DRY, and logging improvements
- Shared Redis channel constants as single source of truth (redis-channels.ts) - commandHandler.ts split into VoiceHandler, MediaHandler, GuildHandler, ModerationHandler with handler-registry.ts dispatch - messageStore.ts (1322 lines) split into domain-specific DB files: messages.db.ts, attachments.db.ts, reviews.db.ts, moderation-actions.db.ts, retention.db.ts - recorder.ts startSpeaking callback extracted into speakingHandler.ts, streamSetup.ts, segmentFinalizer.ts - autoDeleteManager.ts split into autoDeleteEligibility.ts, autoDeleteNotify.ts, autoDeleteLogger.ts - Added createChildLogger() logging across 8 service files - Backend messages.repository.ts migrated from raw SQL to Drizzle ORM - Fixed biome.json to exclude packages/**/dist/* from lint - Fixed config.ts GUILD_ID pre-existing type error Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7108f6bb47
commit
b68789fffc
@@ -1,11 +1,23 @@
|
||||
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 { createModerationAction } from "../message-capture/messageStore.js";
|
||||
import { discordPlayer } from "../voice-recording/player.js";
|
||||
import { voiceTransmitter } from "../voice-recording/transmitter.js";
|
||||
import type { VoiceController } 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");
|
||||
|
||||
@@ -13,20 +25,6 @@ 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;
|
||||
@@ -34,21 +32,6 @@ interface VoiceStatusPayload {
|
||||
activeChannelName: string | null;
|
||||
}
|
||||
|
||||
interface MediaStatusPayload {
|
||||
playing: boolean;
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -56,8 +39,12 @@ const MEDIA_STATUS_KEY = "media:status";
|
||||
export class CommandHandler {
|
||||
private redisSub: Redis;
|
||||
private redisPub: Redis;
|
||||
private client: Client | null = null;
|
||||
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);
|
||||
@@ -79,9 +66,22 @@ export class CommandHandler {
|
||||
* command channel. Must be called *after* the Discord client is created.
|
||||
*/
|
||||
start(client: Client, voiceController: VoiceController): void {
|
||||
this.client = client;
|
||||
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);
|
||||
@@ -89,11 +89,11 @@ export class CommandHandler {
|
||||
});
|
||||
});
|
||||
|
||||
this.redisSub.subscribe(COMMAND_CHANNEL, (err) => {
|
||||
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 "${COMMAND_CHANNEL}"`);
|
||||
logger.info(`Subscribed to Redis channel "${BACKEND_COMMAND}"`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,9 +109,9 @@ export class CommandHandler {
|
||||
// ---- Command dispatch ----
|
||||
|
||||
private async handleCommand(raw: string): Promise<void> {
|
||||
let cmd: BackendCommand;
|
||||
let cmd: CommandMessage;
|
||||
try {
|
||||
cmd = JSON.parse(raw) as BackendCommand;
|
||||
cmd = JSON.parse(raw) as CommandMessage;
|
||||
} catch {
|
||||
logger.warn({ raw }, "Received invalid JSON on command channel");
|
||||
return;
|
||||
@@ -119,54 +119,20 @@ export class CommandHandler {
|
||||
|
||||
logger.info({ commandId: cmd.id, type: cmd.type }, "Received command");
|
||||
|
||||
let reply: CommandReply;
|
||||
let reply: CommandReply<unknown>;
|
||||
|
||||
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 "voice:transmit:start":
|
||||
reply = await this.handleVoiceTransmitStart(cmd);
|
||||
break;
|
||||
case "voice:transmit:stop":
|
||||
reply = await this.handleVoiceTransmitStop(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;
|
||||
case "moderation:action":
|
||||
reply = await this.handleModerationAction(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}`,
|
||||
};
|
||||
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);
|
||||
@@ -195,383 +161,6 @@ export class CommandHandler {
|
||||
this.publishMediaStatus();
|
||||
}
|
||||
|
||||
// ---- Command handlers ----
|
||||
|
||||
private async handleVoiceConnect(cmd: BackendCommand): Promise<CommandReply> {
|
||||
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<CommandReply> {
|
||||
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<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 };
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentMediaStatus(): MediaStatusPayload {
|
||||
return {
|
||||
playing: discordPlayer.getStatus() === "playing",
|
||||
musicVolume: discordPlayer.getMusicVolume(),
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
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: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
private async handleMediaSkip(cmd: BackendCommand): Promise<CommandReply> {
|
||||
discordPlayer.stop("music");
|
||||
return { id: cmd.id, success: true, data: this.getCurrentMediaStatus() };
|
||||
}
|
||||
|
||||
private async handleMediaStop(cmd: BackendCommand): Promise<CommandReply> {
|
||||
discordPlayer.stop("music");
|
||||
return { id: cmd.id, success: true, data: this.getCurrentMediaStatus() };
|
||||
}
|
||||
|
||||
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);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
private async handleVoiceTransmitStart(
|
||||
cmd: BackendCommand,
|
||||
): Promise<CommandReply> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVoiceTransmitStop(
|
||||
cmd: BackendCommand,
|
||||
): Promise<CommandReply> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async handleModerationAction(
|
||||
cmd: BackendCommand,
|
||||
): Promise<CommandReply> {
|
||||
const payload = cmd.payload as {
|
||||
message_id?: string;
|
||||
user_id?: string;
|
||||
guild_id?: string;
|
||||
channel_id?: string;
|
||||
action_type?: string;
|
||||
reason?: string;
|
||||
executed_by?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload.message_id ||
|
||||
!payload.user_id ||
|
||||
!payload.guild_id ||
|
||||
!payload.action_type
|
||||
) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "message_id, user_id, guild_id, and action_type are required",
|
||||
};
|
||||
}
|
||||
|
||||
const validActions = [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
] as const;
|
||||
if (
|
||||
!validActions.includes(
|
||||
payload.action_type as (typeof validActions)[number],
|
||||
)
|
||||
) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: `Invalid action_type: ${payload.action_type}. Must be one of: ${validActions.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// For delete_message, also actually delete via Discord if client is available
|
||||
if (payload.action_type === "delete_message" && this.client) {
|
||||
try {
|
||||
const channelId = String(cmd.payload.channel_id ?? "");
|
||||
if (channelId) {
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (channel?.isText()) {
|
||||
const msg = await channel.messages
|
||||
.fetch(payload.message_id)
|
||||
.catch(() => null);
|
||||
if (msg) {
|
||||
await msg.delete().catch((err: unknown) => {
|
||||
logger.warn(
|
||||
{ error: err, messageId: payload.message_id },
|
||||
"Failed to delete message via Discord",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ error: err, messageId: payload.message_id },
|
||||
"Failed to fetch channel/message for deletion",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const action = await createModerationAction({
|
||||
message_id: payload.message_id,
|
||||
user_id: payload.user_id,
|
||||
guild_id: payload.guild_id,
|
||||
action_type: payload.action_type as
|
||||
| "delete_message"
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user",
|
||||
reason: payload.reason ?? null,
|
||||
executed_by: payload.executed_by ?? "command-handler",
|
||||
status: "executed",
|
||||
error: null,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: payload.action_type,
|
||||
userId: payload.user_id,
|
||||
},
|
||||
"Moderation action executed",
|
||||
);
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: action,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(
|
||||
{ error: message, commandId: cmd.id },
|
||||
"Failed to execute moderation action",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Status publishing ----
|
||||
|
||||
private publishVoiceStatus(): void {
|
||||
@@ -588,7 +177,10 @@ export class CommandHandler {
|
||||
}
|
||||
|
||||
private publishMediaStatus(): void {
|
||||
this.setKey(MEDIA_STATUS_KEY, JSON.stringify(this.getCurrentMediaStatus()));
|
||||
this.setKey(
|
||||
MEDIA_STATUS_KEY,
|
||||
JSON.stringify(this.mediaHandler.getCurrentMediaStatus()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type CommandMessage, type CommandReply } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GuildHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class GuildHandler {
|
||||
private logger = createChildLogger("guild-handler");
|
||||
|
||||
constructor(private client: Client | null) {}
|
||||
|
||||
setClient(client: Client): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async handleListGuilds(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
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);
|
||||
this.logger.error({ error: msg }, "Failed to list guilds");
|
||||
return { id: cmd.id, success: false, data: null, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
async handleTextChannels(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
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);
|
||||
this.logger.error(
|
||||
{ error: msg, guildId },
|
||||
"Failed to list text channels",
|
||||
);
|
||||
return { id: cmd.id, success: false, data: null, error: msg };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
COMMAND_GUILDS_LIST,
|
||||
COMMAND_GUILDS_TEXT_CHANNELS,
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
COMMAND_MODERATION_ACTION,
|
||||
COMMAND_VOICE_CHANNELS,
|
||||
COMMAND_VOICE_CONNECT,
|
||||
COMMAND_VOICE_DISCONNECT,
|
||||
COMMAND_VOICE_TRANSMIT_START,
|
||||
COMMAND_VOICE_TRANSMIT_STOP,
|
||||
type CommandMessage,
|
||||
type CommandReply,
|
||||
} from "@bete/shared";
|
||||
import type { GuildHandler } from "./guild.handler.js";
|
||||
import type { MediaHandler } from "./media.handler.js";
|
||||
import type { ModerationHandler } from "./moderation.handler.js";
|
||||
import type { VoiceHandler } from "./voice.handler.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CommandHandlerFn = (
|
||||
cmd: CommandMessage,
|
||||
) => Promise<CommandReply<unknown>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createHandlerRegistry(
|
||||
voiceHandler: VoiceHandler,
|
||||
mediaHandler: MediaHandler,
|
||||
guildHandler: GuildHandler,
|
||||
moderationHandler: ModerationHandler,
|
||||
): Map<string, CommandHandlerFn> {
|
||||
const registry = new Map<string, CommandHandlerFn>();
|
||||
|
||||
// Voice commands
|
||||
registry.set(COMMAND_VOICE_CONNECT, (cmd) =>
|
||||
voiceHandler.handleVoiceConnect(cmd),
|
||||
);
|
||||
registry.set(COMMAND_VOICE_DISCONNECT, (cmd) =>
|
||||
voiceHandler.handleVoiceDisconnect(cmd),
|
||||
);
|
||||
registry.set(COMMAND_VOICE_CHANNELS, (cmd) =>
|
||||
voiceHandler.handleVoiceChannels(cmd),
|
||||
);
|
||||
registry.set(COMMAND_VOICE_TRANSMIT_START, (cmd) =>
|
||||
voiceHandler.handleVoiceTransmitStart(cmd),
|
||||
);
|
||||
registry.set(COMMAND_VOICE_TRANSMIT_STOP, (cmd) =>
|
||||
voiceHandler.handleVoiceTransmitStop(cmd),
|
||||
);
|
||||
|
||||
// Media commands
|
||||
registry.set(COMMAND_MEDIA_QUEUE, (cmd) =>
|
||||
mediaHandler.handleMediaQueue(cmd),
|
||||
);
|
||||
registry.set(COMMAND_MEDIA_SKIP, (cmd) => mediaHandler.handleMediaSkip(cmd));
|
||||
registry.set(COMMAND_MEDIA_STOP, (cmd) => mediaHandler.handleMediaStop(cmd));
|
||||
registry.set(COMMAND_MEDIA_VOLUME, (cmd) =>
|
||||
mediaHandler.handleMediaVolume(cmd),
|
||||
);
|
||||
|
||||
// Guild commands
|
||||
registry.set(COMMAND_GUILDS_LIST, (cmd) =>
|
||||
guildHandler.handleListGuilds(cmd),
|
||||
);
|
||||
registry.set(COMMAND_GUILDS_TEXT_CHANNELS, (cmd) =>
|
||||
guildHandler.handleTextChannels(cmd),
|
||||
);
|
||||
|
||||
// Moderation commands
|
||||
registry.set(COMMAND_MODERATION_ACTION, (cmd) =>
|
||||
moderationHandler.handleModerationAction(cmd),
|
||||
);
|
||||
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type CommandMessage, type CommandReply } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { discordPlayer } from "../voice-recording/player.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MediaStatusPayload {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current: unknown;
|
||||
queue: unknown[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MediaHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class MediaHandler {
|
||||
private logger = createChildLogger("media-handler");
|
||||
|
||||
getCurrentMediaStatus(): MediaStatusPayload {
|
||||
return {
|
||||
playing: discordPlayer.getStatus() === "playing",
|
||||
musicVolume: discordPlayer.getMusicVolume(),
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
this.logger.info(
|
||||
"media:queue received — media queueing is handled externally",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaSkip(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
discordPlayer.stop("music");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
discordPlayer.stop("music");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async handleMediaVolume(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
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: this.getCurrentMediaStatus(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type CommandMessage, type CommandReply } from "@bete/shared";
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { createModerationAction } from "../message-capture/messageStore.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModerationHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ModerationHandler {
|
||||
private logger = createChildLogger("moderation-handler");
|
||||
|
||||
constructor(private client: Client | null) {}
|
||||
|
||||
setClient(client: Client): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async handleModerationAction(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
const payload = cmd.payload as {
|
||||
message_id?: string;
|
||||
user_id?: string;
|
||||
guild_id?: string;
|
||||
channel_id?: string;
|
||||
action_type?: string;
|
||||
reason?: string;
|
||||
executed_by?: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload.message_id ||
|
||||
!payload.user_id ||
|
||||
!payload.guild_id ||
|
||||
!payload.action_type
|
||||
) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "message_id, user_id, guild_id, and action_type are required",
|
||||
};
|
||||
}
|
||||
|
||||
const validActions = [
|
||||
"delete_message",
|
||||
"mute_user",
|
||||
"warn_user",
|
||||
"kick_user",
|
||||
"ban_user",
|
||||
] as const;
|
||||
if (
|
||||
!validActions.includes(
|
||||
payload.action_type as (typeof validActions)[number],
|
||||
)
|
||||
) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: `Invalid action_type: ${payload.action_type}. Must be one of: ${validActions.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// For delete_message, also actually delete via Discord if client is available
|
||||
if (payload.action_type === "delete_message" && this.client) {
|
||||
try {
|
||||
const channelId = String(cmd.payload.channel_id ?? "");
|
||||
if (channelId) {
|
||||
const channel = await this.client.channels.fetch(channelId);
|
||||
if (channel?.isText()) {
|
||||
const msg = await channel.messages
|
||||
.fetch(payload.message_id)
|
||||
.catch(() => null);
|
||||
if (msg) {
|
||||
await msg.delete().catch((err: unknown) => {
|
||||
this.logger.warn(
|
||||
{ error: err, messageId: payload.message_id },
|
||||
"Failed to delete message via Discord",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
{ error: err, messageId: payload.message_id },
|
||||
"Failed to fetch channel/message for deletion",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const action = await createModerationAction({
|
||||
message_id: payload.message_id,
|
||||
user_id: payload.user_id,
|
||||
guild_id: payload.guild_id,
|
||||
action_type: payload.action_type as
|
||||
| "delete_message"
|
||||
| "mute_user"
|
||||
| "warn_user"
|
||||
| "kick_user"
|
||||
| "ban_user",
|
||||
reason: payload.reason ?? null,
|
||||
executed_by: payload.executed_by ?? "command-handler",
|
||||
status: "executed",
|
||||
error: null,
|
||||
executed_at: Date.now(),
|
||||
});
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
actionId: action.id,
|
||||
actionType: payload.action_type,
|
||||
userId: payload.user_id,
|
||||
},
|
||||
"Moderation action executed",
|
||||
);
|
||||
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: true,
|
||||
data: action,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
{ error: message, commandId: cmd.id },
|
||||
"Failed to execute moderation action",
|
||||
);
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { type CommandMessage, type CommandReply } 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 { discordPlayer } from "../voice-recording/player.js";
|
||||
import { voiceTransmitter } from "../voice-recording/transmitter.js";
|
||||
import type { VoiceController } from "../voice-recording/voiceController.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VoiceHandler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VoiceHandler {
|
||||
private logger = createChildLogger("voice-handler");
|
||||
|
||||
constructor(
|
||||
private client: Client | null,
|
||||
private voiceController: VoiceController | null,
|
||||
) {}
|
||||
|
||||
setClient(client: Client): void {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
setVoiceController(voiceController: VoiceController): void {
|
||||
this.voiceController = voiceController;
|
||||
}
|
||||
|
||||
async handleVoiceConnect(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
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 };
|
||||
}
|
||||
|
||||
async handleVoiceDisconnect(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
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 };
|
||||
}
|
||||
|
||||
async handleVoiceChannels(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
async handleVoiceTransmitStart(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
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();
|
||||
this.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);
|
||||
this.logger.error({ error: message }, "Failed to start voice transmit");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async handleVoiceTransmitStop(
|
||||
cmd: CommandMessage,
|
||||
): Promise<CommandReply<unknown>> {
|
||||
try {
|
||||
await voiceTransmitter.stop();
|
||||
this.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);
|
||||
this.logger.error({ error: message }, "Failed to stop voice transmit");
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user