235 lines
7.9 KiB
TypeScript
235 lines
7.9 KiB
TypeScript
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<void> {
|
|
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<string, GuildVoiceSession>();
|
|
|
|
function getVoiceChannel(interaction: ChatInputCommandInteraction): VoiceBasedChannel | null {
|
|
const member = interaction.member as GuildMember | null;
|
|
return member?.voice.channel ?? null;
|
|
}
|
|
|
|
async function registerCommands(_appClient: Client): Promise<void> {
|
|
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<GuildVoiceSession> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|