Files
javis_bot/bot/src/index.ts
javis-bot 932aacef6e feat(bot): delay login until MeloTTS voice is warm
The bot, bridge and melo worker boot together, but the MeloTTS model takes
tens of seconds to load. If the bot logged in and auto-joined the voice channel
before the voice was warm, the first reply synthesised to nothing and was
silently dropped.

- bridge /health now reports `tts_ready`. For MeloTTS this pings the worker,
  which only binds its HTTP port AFTER the model is loaded (main() warms before
  serve_forever()), so a successful ping is a precise "voice is warm" signal.
- The bot polls /health and waits for `tts_ready` before logging in. It does
  not wait on brain_ready (the reply engine / Whisper load lazily on the first
  turn — a slow first turn is fine, a silent one is the bug). After a 180s cap
  it proceeds anyway so a TTS load failure degrades to text-only.

Live-verified: startup logs show " MeloTTS 준비 대기 중" then
"✓ 음성(MeloTTS) 준비 완료 — 로그인 진행" then "✓ 유저봇 로그인", in that order.
2026-06-12 21:59:50 +09:00

238 lines
8.5 KiB
TypeScript

/**
* 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<string, ScreenStreamer>();
async function getStreamer(guildId: string): Promise<ScreenStreamer> {
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<void> {
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);
});