/** * Javis bot entry point. * * A normal Discord bot that: * - exposes /자비스 (join / leave / ask / stream / stop / status) * - replies to every slash command EPHEMERALLY (only the invoker sees it) * - joins the caller's voice channel for live voice conversation (brain in bridge/) * - broadcasts the VNC screen via a pluggable backend (selfbot / novnc / screenshot) */ import { Client, GatewayIntentBits, MessageFlags, type ChatInputCommandInteraction, type GuildMember, type TextBasedChannel, } from "discord.js"; import { AttachmentBuilder } from "discord.js"; import { config } from "./config.ts"; import { ask, health } from "./bridge.ts"; import { joinChannel, leaveGuild, getSession } from "./voice.ts"; import { createStreamer, type ScreenStreamer, type StreamContext } from "./stream/index.ts"; const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates], }); const streamers = new Map(); async function getStreamer(guildId: string): Promise { let s = streamers.get(guildId); if (!s) { s = await createStreamer(config); streamers.set(guildId, s); } return s; } const eph = { flags: MessageFlags.Ephemeral } as const; client.once("clientReady", () => { console.log(`✓ 로그인: ${client.user?.tag} | stream backend: ${config.streamBackend}`); }); client.on("interactionCreate", async (interaction) => { if (!interaction.isChatInputCommand()) return; if (interaction.commandName !== "자비스") return; const i = interaction as ChatInputCommandInteraction; const sub = i.options.getSubcommand(); try { switch (sub) { case "join": return void (await handleJoin(i)); case "leave": return void (await handleLeave(i)); case "ask": return void (await handleAsk(i)); case "stream": return void (await handleStream(i)); case "stop": return void (await handleStop(i)); case "status": return void (await handleStatus(i)); } } catch (err) { console.error(`[/자비스 ${sub}]`, err); const msg = `오류: ${(err as Error).message}`; if (i.deferred || i.replied) await i.editReply(msg); else await i.reply({ content: msg, ...eph }); } }); async function handleJoin(i: ChatInputCommandInteraction) { const member = i.member as GuildMember; const channel = member?.voice?.channel; if (!channel) { return i.reply({ content: "먼저 음성 채널에 들어간 뒤 다시 호출해주세요.", ...eph }); } await i.deferReply(eph); const session = await joinChannel(channel); session.onTurn = ({ transcript, reply }) => console.log(`🗣️ ${transcript}\n🤖 ${reply}`); // Screen-share mode (STREAM_BROWSER=true): the broadcast is coupled to the // voice session. Auto-start it on join, report its live state to the brain // each turn (search routes Chrome while live / Gemini when off), and let the // brain toggle it via voice ("방송 켜줘 / 꺼줘"). In voice-only mode // (STREAM_BROWSER=false) none of this runs and the broadcast stays off. if (config.screenBrowser) { const streamer = await getStreamer(i.guildId!); const ctx: StreamContext = { guildId: i.guildId!, voiceChannelId: channel.id }; session.getBroadcasting = () => streamer.isActive(); session.onBroadcastAction = async (action) => { if (action === "start") { if (!streamer.isActive()) await streamer.start(ctx); } else if (streamer.isActive()) { await streamer.stop(); } }; try { if (!streamer.isActive()) await streamer.start(ctx); } catch (e) { console.error("[join] auto-broadcast failed:", e); } } await i.editReply(`🎙️ '${channel.name}' 채널에 접속했습니다. 말씀하세요.`); } async function handleLeave(i: ChatInputCommandInteraction) { const left = leaveGuild(i.guildId!); // Leaving voice also ends the broadcast — don't leave a stream live with no // session driving it. const streamer = streamers.get(i.guildId!); if (streamer?.isActive()) { try { await streamer.stop(); } catch (e) { console.error("[leave] stopping broadcast failed:", e); } } await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph }); } async function handleAsk(i: ChatInputCommandInteraction) { const q = i.options.getString("질문", true); await i.deferReply(eph); const res = await ask(q); const reply = res.reply || res.error || "(응답 없음)"; await i.editReply(reply.slice(0, 1900)); } async function handleStream(i: ChatInputCommandInteraction) { const member = i.member as GuildMember; await i.deferReply(eph); const streamer = await getStreamer(i.guildId!); const ctx: StreamContext = { guildId: i.guildId!, voiceChannelId: member?.voice?.channelId ?? "", postImage: async (png, name) => { const ch = i.channel as TextBasedChannel | null; if (ch && "send" in ch) { await (ch as any).send({ files: [new AttachmentBuilder(png, { name })] }); } }, }; if (!config.screenBrowser) { return i.editReply( "화면 공유(브라우저) 모드가 꺼져 있습니다 (STREAM_BROWSER=false). 음성 + API/MCP 모드로만 동작합니다.", ); } if (config.streamBackend === "selfbot" && !ctx.voiceChannelId) { return i.editReply("셀프봇 송출은 음성 채널 안에서 호출해야 합니다. 음성 채널에 들어간 뒤 다시 시도하세요."); } const msg = await streamer.start(ctx); await i.editReply(msg); } async function handleStop(i: ChatInputCommandInteraction) { const streamer = streamers.get(i.guildId!); if (!streamer) return i.reply({ content: "송출 중이 아닙니다.", ...eph }); await streamer.stop(); await i.reply({ content: "송출을 중단했습니다.", ...eph }); } async function handleStatus(i: ChatInputCommandInteraction) { await i.deferReply(eph); let brain = "unreachable"; try { const h = await health(); brain = h.brain_ready ? "ready" : `not-ready${h.brain_error ? " (" + h.brain_error + ")" : ""}`; } catch { /* keep unreachable */ } const session = getSession(i.guildId!); const streamer = streamers.get(i.guildId!); await i.editReply( [ `브릿지 두뇌: ${brain}`, `음성 세션: ${session ? "접속 중" : "없음"}`, `송출 백엔드: ${config.streamBackend} (${streamer?.isActive() ? "활성" : "대기"})`, ].join("\n"), ); } // Block until the brain bridge reports the TTS voice (MeloTTS worker) is warm. // The bot, bridge and melo worker all boot together; the voice model takes tens // of seconds to load. If we log in and auto-join the voice channel before // MeloTTS is ready, the first reply synthesises to nothing and is silently // dropped. Gating login on TTS readiness guarantees the first spoken turn has // audio. We deliberately do NOT wait on brain_ready: the reply engine / Whisper // load lazily on the first turn (so brain_ready stays false on an idle boot) — // a slow first turn is fine, a silent one is the bug. After `maxMs` we proceed // anyway so a TTS load failure degrades to text-only rather than taking the // whole bot offline. async function waitForBridgeReady(maxMs = 180_000): Promise { const start = Date.now(); let announced = false; while (Date.now() - start < maxMs) { try { const h = await health(); if (h.tts_ready) { console.log("✓ 음성(MeloTTS) 준비 완료 — 로그인 진행"); return; } if (!announced) { console.log("⏳ MeloTTS 준비 대기 중 (음성 모델 로딩)..."); announced = true; } } catch { /* bridge not listening yet — keep polling */ } await new Promise((r) => setTimeout(r, 2000)); } console.warn("⚠️ MeloTTS 준비 시간 초과 — 음성(TTS) 없이 진행합니다."); } // Mode select: a USER account is the only kind Discord lets Go Live, so when // there's no normal-bot token but a selfbot token is present, run as a userbot // (voice + broadcast on one user account). Otherwise run the legacy normal bot. (async () => { await waitForBridgeReady(); if (!config.botToken && config.selfbotToken) { const { runUserbot } = await import("./userbot.ts"); await runUserbot(); } else if (config.botToken) { await client.login(config.botToken); } else { throw new Error( "DISCORD_BOT_TOKEN(일반 봇) 또는 DISCORD_SELFBOT_TOKEN(유저봇) 중 하나는 .env에 필요합니다.", ); } })().catch((e) => { console.error("[bot] fatal:", e); process.exit(1); });