import { getVoiceConnection, type VoiceConnection } from "@discordjs/voice"; import type { Client, Guild, VoiceChannel } from "discord.js-selfbot-v13"; import { AppError } from "./errors"; import { createChildLogger } from "./logger"; import { discordPlayer } from "./player"; import { startRecording, stopRecording } from "./recorder"; const logger = createChildLogger("voice-controller"); export interface VoiceStatus { ready: boolean; connected: boolean; activeGuildId: string | null; activeChannelId: string | null; activeChannelName: string | null; } export interface GuildSummary { id: string; name: string; } export interface VoiceChannelSummary { id: string; name: string; } export interface ChannelSummary { id: string; name: string; type: string; } export class VoiceController { private activeGuildId: string | null = null; private activeChannelId: string | null = null; private activeChannelName: string | null = null; private connecting = false; constructor(private readonly client: Client) {} getStatus(): VoiceStatus { const connection = this.activeGuildId ? getVoiceConnection(this.activeGuildId) : undefined; return { ready: this.client.isReady(), connected: Boolean(connection), activeGuildId: this.activeGuildId, activeChannelId: this.activeChannelId, activeChannelName: this.activeChannelName, }; } listGuilds(): GuildSummary[] { return this.client.guilds.cache .map((guild) => ({ id: guild.id, name: guild.name })) .sort((a, b) => a.name.localeCompare(b.name)); } async listVoiceChannels(guildId: string): Promise { const guild = this.getGuild(guildId); await guild.channels.fetch().catch(() => null); return guild.channels.cache .filter((channel) => channel.type === "GUILD_VOICE") .map((channel) => ({ id: channel.id, name: channel.name })) .sort((a, b) => a.name.localeCompare(b.name)); } async listWatchableChannels(guildId: string): Promise { const guild = this.getGuild(guildId); await guild.channels.fetch().catch(() => null); return guild.channels.cache .filter((channel) => channel.type === "GUILD_TEXT") .map((channel) => ({ id: channel.id, name: channel.name, type: channel.type, })) .sort((a, b) => a.name.localeCompare(b.name)); } async listThreads(guildId: string): Promise { const guild = this.getGuild(guildId); await guild.channels.fetch().catch(() => null); const threads: ChannelSummary[] = []; for (const channel of guild.channels.cache.values()) { const threadParent = channel as typeof channel & { threads?: { fetch: (options: { archived: boolean; limit: number; }) => Promise; }; }; if (!threadParent.threads?.fetch) continue; for (const archived of [false, true]) { const fetched = await threadParent.threads .fetch({ archived, limit: 100 }) .catch(() => null); if (!fetched?.threads) continue; for (const thread of fetched.threads.values()) { threads.push({ id: thread.id, name: `${channel.name} / ${thread.name}`, type: thread.type, }); } } } return Array.from( new Map(threads.map((thread) => [thread.id, thread])).values(), ).sort((a, b) => a.name.localeCompare(b.name)); } async connect(guildId: string, channelId: string): Promise { if (!this.client.isReady()) { throw new AppError( "Discord client is not ready", "CLIENT_NOT_READY", 409, ); } if (this.connecting) { throw new AppError( "Voice connection is already in progress", "CONNECT_IN_PROGRESS", 409, ); } this.connecting = true; try { await this.disconnect(); const guild = this.getGuild(guildId); const channel = guild.channels.cache.get(channelId) ?? (await guild.channels.fetch(channelId).catch(() => null)); if (!channel) { throw new AppError( "Voice channel not found", "VOICE_CHANNEL_NOT_FOUND", 404, ); } if (channel.type !== "GUILD_VOICE") { throw new AppError( "Selected channel is not a voice channel", "INVALID_CHANNEL_TYPE", 400, ); } const connection = await startRecording( this.client, channel as VoiceChannel, ); if (!connection) { throw new AppError( "Failed to connect to voice channel", "VOICE_CONNECT_FAILED", 500, ); } discordPlayer.setConnection(connection as VoiceConnection); this.activeGuildId = guildId; this.activeChannelId = channelId; this.activeChannelName = channel.name; logger.info( { guildId, channelId, channelName: channel.name }, "Voice connected", ); return this.getStatus(); } finally { this.connecting = false; } } async disconnect(): Promise { if (this.activeGuildId) { stopRecording(this.activeGuildId); } discordPlayer.pause(); this.activeGuildId = null; this.activeChannelId = null; this.activeChannelName = null; return this.getStatus(); } private getGuild(guildId: string): Guild { const guild = this.client.guilds.cache.get(guildId); if (!guild) { throw new AppError("Guild not found", "GUILD_NOT_FOUND", 404); } return guild; } }