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.
238 lines
8.5 KiB
TypeScript
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);
|
|
});
|