import process from "node:process"; import { GatewayIntentBits, REST, Routes, SlashCommandBuilder, type ChatInputCommandInteraction, type Client, type GuildMember, type VoiceBasedChannel, } from "discord.js"; import { Client as DiscordClient } from "discord.js"; import { GuildVoiceSession } from "./audio/guild-voice-session.js"; import { type DiscordRuntimeConfig } from "./config.js"; import { Logger } from "./logger.js"; import { ElevenLabsSttService } from "./services/elevenlabs-stt.js"; import { ElevenLabsTtsService } from "./services/elevenlabs-tts.js"; import { OllamaLlmService } from "./services/ollama-llm.js"; export async function runDiscordBot(config: DiscordRuntimeConfig, logger: Logger): Promise { const commands = [ new SlashCommandBuilder().setName("join").setDescription("현재 들어가 있는 음성 채널에 봇을 입장시킵니다."), new SlashCommandBuilder().setName("leave").setDescription("현재 음성 세션을 종료합니다."), new SlashCommandBuilder().setName("status").setDescription("현재 음성 세션 상태를 확인합니다."), new SlashCommandBuilder().setName("reset").setDescription("대화 문맥과 재생 큐를 초기화합니다."), new SlashCommandBuilder() .setName("say") .setDescription("텍스트를 바로 음성으로 읽습니다.") .addStringOption((option) => option.setName("text").setDescription("읽을 문장").setRequired(true).setMaxLength(400), ), ].map((command) => command.toJSON()); const client = new DiscordClient({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates], }); const stt = new ElevenLabsSttService(config); const tts = new ElevenLabsTtsService(config); const llm = new OllamaLlmService(config); const sessions = new Map(); function getVoiceChannel(interaction: ChatInputCommandInteraction): VoiceBasedChannel | null { const member = interaction.member as GuildMember | null; return member?.voice.channel ?? null; } async function registerCommands(_appClient: Client): Promise { const rest = new REST({ version: "10" }).setToken(config.DISCORD_BOT_TOKEN); if (config.DISCORD_COMMAND_GUILD_ID) { await rest.put( Routes.applicationGuildCommands(config.DISCORD_APPLICATION_ID, config.DISCORD_COMMAND_GUILD_ID), { body: commands, }, ); logger.info("Registered guild commands", config.DISCORD_COMMAND_GUILD_ID); return; } await rest.put(Routes.applicationCommands(config.DISCORD_APPLICATION_ID), { body: commands, }); logger.info("Registered global commands"); } async function createSession(interaction: ChatInputCommandInteraction): Promise { if (!interaction.guild) { throw new Error("Guild interaction required"); } const voiceChannel = getVoiceChannel(interaction); if (!voiceChannel) { throw new Error("먼저 음성 채널에 들어가 주세요."); } const existing = sessions.get(interaction.guild.id); if (existing && existing.voiceChannelId === voiceChannel.id) { existing.setTextChannel(interaction.channelId); return existing; } if (existing) { await existing.destroy(); sessions.delete(interaction.guild.id); } const session = await GuildVoiceSession.create({ client, config, logger, guild: interaction.guild, voiceChannel, textChannelId: interaction.channelId, stt, tts, llm, }); sessions.set(interaction.guild.id, session); return session; } async function handleJoin(interaction: ChatInputCommandInteraction): Promise { await interaction.deferReply({ ephemeral: true }); try { const session = await createSession(interaction); await interaction.editReply( `음성 비서를 시작했습니다. 채널: ${session.statusSummary().split("\n")[1]?.replace("음성 채널: ", "") ?? "알 수 없음"}`, ); } catch (error) { const message = error instanceof Error ? error.message : "세션 생성에 실패했습니다."; await interaction.editReply(message); } } async function handleLeave(interaction: ChatInputCommandInteraction): Promise { const session = interaction.guild ? sessions.get(interaction.guild.id) : undefined; if (!session) { await interaction.reply({ content: "현재 활성화된 음성 세션이 없습니다.", ephemeral: true }); return; } await session.destroy(); sessions.delete(interaction.guildId!); await interaction.reply({ content: "음성 세션을 종료했습니다.", ephemeral: true }); } async function handleStatus(interaction: ChatInputCommandInteraction): Promise { const session = interaction.guild ? sessions.get(interaction.guild.id) : undefined; if (!session) { await interaction.reply({ content: "현재 활성화된 음성 세션이 없습니다.", ephemeral: true }); return; } await interaction.reply({ content: session.statusSummary(), ephemeral: true, }); } async function handleReset(interaction: ChatInputCommandInteraction): Promise { const session = interaction.guild ? sessions.get(interaction.guild.id) : undefined; if (!session) { await interaction.reply({ content: "현재 활성화된 음성 세션이 없습니다.", ephemeral: true }); return; } session.clearConversation(); await interaction.reply({ content: "대화 문맥과 재생 큐를 초기화했습니다.", ephemeral: true }); } async function handleSay(interaction: ChatInputCommandInteraction): Promise { await interaction.deferReply({ ephemeral: true }); const session = interaction.guild ? sessions.get(interaction.guild.id) : undefined; if (!session) { await interaction.editReply("먼저 `/join` 으로 음성 세션을 시작해 주세요."); return; } const text = interaction.options.getString("text", true).trim(); await session.speakText(text); await interaction.editReply("읽기 요청을 대기열에 추가했습니다."); } async function shutdown(exitCode = 0): Promise { logger.info("Shutting down"); for (const session of sessions.values()) { await session.destroy().catch((error) => { logger.warn("Session shutdown failed", error); }); } sessions.clear(); await client.destroy(); process.exit(exitCode); } client.once("ready", async () => { logger.info("Discord client ready", client.user?.tag ?? "unknown"); try { await registerCommands(client); } catch (error) { logger.error("Command registration failed", error); } }); client.on("interactionCreate", async (interaction) => { if (!interaction.isChatInputCommand()) { return; } try { switch (interaction.commandName) { case "join": await handleJoin(interaction); return; case "leave": await handleLeave(interaction); return; case "status": await handleStatus(interaction); return; case "reset": await handleReset(interaction); return; case "say": await handleSay(interaction); return; default: await interaction.reply({ content: "알 수 없는 명령입니다.", ephemeral: true }); } } catch (error) { logger.error("Interaction handler failed", error); if (interaction.deferred || interaction.replied) { await interaction.editReply("명령 처리 중 오류가 발생했습니다.").catch(() => null); return; } await interaction.reply({ content: "명령 처리 중 오류가 발생했습니다.", ephemeral: true }).catch(() => null); } }); process.on("SIGINT", () => { void shutdown(0); }); process.on("SIGTERM", () => { void shutdown(0); }); await client.login(config.DISCORD_BOT_TOKEN); }