Files
dc-recorder/src/voiceController.ts
2026-05-14 15:02:23 +07:00

217 lines
5.6 KiB
TypeScript

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<VoiceChannelSummary[]> {
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<ChannelSummary[]> {
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<ChannelSummary[]> {
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<any>;
};
};
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<VoiceStatus> {
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<VoiceStatus> {
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;
}
}