Resolve the Discord user ID to a server nickname / global name (cached) and display that in the transcript channel + console logs.
178 lines
7.7 KiB
TypeScript
178 lines
7.7 KiB
TypeScript
/**
|
||
* Userbot (selfbot) entry — runs the assistant on a single USER account token,
|
||
* which is the only kind of account Discord lets Go Live (screen-share).
|
||
*
|
||
* Voice goes through @discordjs/voice (>=0.19 + @snazzah/davey for Discord's
|
||
* DAVE E2EE) using the selfbot client's `voiceAdapterCreator`. The selfbot
|
||
* library's OWN native voice connection does NOT complete the current
|
||
* (DAVE/v8) handshake and times out, so we deliberately reuse the normal-bot
|
||
* VoiceSession (receive -> Whisper -> reply -> TTS playback) instead, just with
|
||
* a user-account channel.
|
||
*
|
||
* Stage 1 (this module): log in, join (auto from DISCORD_VOICE_CHANNEL_ID or via
|
||
* "!자비스 join"), converse by voice. Go-Live broadcast + broadcast-coupled
|
||
* search routing land in stage 2.
|
||
*
|
||
* ToS WARNING: automating a user account ("selfbot") violates Discord's ToS and
|
||
* can get the account banned. Use a throwaway/burner account only.
|
||
*/
|
||
import type { VoiceBasedChannel } from "discord.js";
|
||
import { config } from "./config.ts";
|
||
import { joinChannel, leaveGuild } from "./voice.ts";
|
||
import { maybeCoupleBroadcast, stopBroadcast } from "./broadcast.ts";
|
||
|
||
type AnyClient = any;
|
||
|
||
async function loadSelfbot(): Promise<any> {
|
||
try {
|
||
return await import("discord.js-selfbot-v13");
|
||
} catch (e) {
|
||
throw new Error(
|
||
"유저봇 의존성이 없습니다. 설치: bun add discord.js-selfbot-v13\n" +
|
||
`원본 오류: ${(e as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
export interface TurnInfo {
|
||
/** Discord user ID of the speaker, so the transcript shows whose audio
|
||
* produced each turn (and which user a dropped/VAD turn belongs to). */
|
||
user?: string;
|
||
/** Resolved display name (server nickname / global name); shown instead of
|
||
* the raw user ID when available. */
|
||
userName?: string;
|
||
transcript: string;
|
||
reply: string;
|
||
note?: string;
|
||
/** Wall-clock epoch-ms markers for each pipeline stage. STT is the gap
|
||
* between listenEndMs and llmStartMs. */
|
||
listenStartMs?: number;
|
||
listenEndMs?: number;
|
||
llmStartMs?: number;
|
||
llmEndMs?: number;
|
||
ttsStartMs?: number;
|
||
ttsEndMs?: number;
|
||
}
|
||
|
||
/** Local wall-clock HH:MM:SS for an epoch-ms instant. */
|
||
function clock(ms?: number): string {
|
||
if (ms == null) return "?";
|
||
const d = new Date(ms);
|
||
const p = (n: number) => String(n).padStart(2, "0");
|
||
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||
}
|
||
|
||
/** Seconds between two epoch-ms instants, 1 decimal, or null if either side is
|
||
* missing or the span is negative (clock skew / out-of-order markers). */
|
||
function durSec(a?: number, b?: number): string | null {
|
||
if (a == null || b == null) return null;
|
||
const s = (b - a) / 1000;
|
||
if (s < 0) return null;
|
||
return s.toFixed(1);
|
||
}
|
||
|
||
/** Build the transcript-channel message: transcript + reply, plus a per-stage
|
||
* timing breakdown (listening / LLM / TTS) with start→end wall-clock times and
|
||
* durations, so it's obvious what took long. Pure + exported for testing. */
|
||
export function formatTurnMessage(info: TurnInfo): string {
|
||
const who = info.userName || info.user ? `👤 ${info.userName || info.user} ` : "";
|
||
const head = info.transcript
|
||
? `${who}🎤 들음 → 🗣️ "${info.transcript}"\n🤖 답변: ${(info.reply || "").trim() || "(무응답)"}`
|
||
: `${who}🎤 들음 → ❌ ${info.note || "무시됨"}`;
|
||
|
||
const lines: string[] = [];
|
||
const listen = durSec(info.listenStartMs, info.listenEndMs);
|
||
if (listen != null) {
|
||
lines.push(` 👂 듣기 ${listen}초 ${clock(info.listenStartMs)} → ${clock(info.listenEndMs)}`);
|
||
}
|
||
const llm = durSec(info.llmStartMs, info.llmEndMs);
|
||
if (llm != null) {
|
||
const stt = durSec(info.listenEndMs, info.llmStartMs);
|
||
const gap = stt != null && Number(stt) >= 0.1 ? ` (STT/전송 ${stt}초)` : "";
|
||
lines.push(` 🧠 LLM ${llm}초 ${clock(info.llmStartMs)} → ${clock(info.llmEndMs)}${gap}`);
|
||
}
|
||
const tts = durSec(info.ttsStartMs, info.ttsEndMs);
|
||
if (tts != null) {
|
||
lines.push(` 🔊 TTS ${tts}초 ${clock(info.ttsStartMs)} → ${clock(info.ttsEndMs)}`);
|
||
}
|
||
|
||
if (!lines.length) return head;
|
||
const lastEnd = info.ttsEndMs ?? info.llmEndMs ?? info.listenEndMs;
|
||
const total = durSec(info.listenStartMs, lastEnd);
|
||
const totalNote = total != null ? ` (합계 ${total}초)` : "";
|
||
return `${head}\n⏱️ 타이밍${totalNote}\n${lines.join("\n")}`;
|
||
}
|
||
|
||
/** Mirror EVERY heard utterance (and why it did/didn't answer) to a text
|
||
* channel, so misses and latency are diagnosable without hearing the bot. */
|
||
async function postTranscript(client: AnyClient, info: TurnInfo): Promise<void> {
|
||
const chId = config.transcriptChannelId;
|
||
if (!chId) return;
|
||
const msg = formatTurnMessage(info);
|
||
try {
|
||
const ch: any = await client.channels.fetch(chId).catch(() => null);
|
||
if (ch?.send) await ch.send(msg);
|
||
} catch (e) {
|
||
console.error("[userbot] transcript post failed:", e);
|
||
}
|
||
}
|
||
|
||
async function joinAndListen(client: AnyClient, channelId: string): Promise<void> {
|
||
const channel: any = await client.channels.fetch(channelId).catch(() => null);
|
||
if (!channel || channel.isVoice?.() === false) {
|
||
console.error(`[userbot] voice channel ${channelId} not found / not a voice channel`);
|
||
return;
|
||
}
|
||
// The selfbot VoiceChannel is runtime-compatible with @discordjs/voice's
|
||
// joinVoiceChannel (it exposes id, guild.id and guild.voiceAdapterCreator).
|
||
const session = await joinChannel(channel as unknown as VoiceBasedChannel);
|
||
session.onTurn = (info) => {
|
||
console.log(`👤 ${info.userName || info.user || "?"} 🗣️ ${info.transcript || "(" + (info.note || "empty") + ")"}\n🤖 ${info.reply}`);
|
||
// Mirror every heard utterance (and the reply / drop reason) to a text
|
||
// channel so you can see what the bot understood even when it doesn't answer.
|
||
void postTranscript(client, info);
|
||
};
|
||
// Screen-share mode (STREAM_BROWSER=true): auto-start the broadcast on join,
|
||
// report its live state to the brain each turn, and let the brain toggle it by
|
||
// voice. Userbot is the only mode that can actually Go Live, so without this
|
||
// wiring the broadcast never starts. No-op when STREAM_BROWSER=false.
|
||
await maybeCoupleBroadcast(session, { guildId: channel.guild.id, voiceChannelId: channel.id });
|
||
console.log(`🎙️ 유저봇이 '${channel.name}' 음성채널에 참여했습니다.`);
|
||
}
|
||
|
||
export async function runUserbot(): Promise<void> {
|
||
if (!config.selfbotToken) {
|
||
throw new Error("DISCORD_SELFBOT_TOKEN이 설정되지 않았습니다 (.env). 버너 계정 토큰을 넣어주세요.");
|
||
}
|
||
const selfbot = await loadSelfbot();
|
||
const client = new selfbot.Client();
|
||
|
||
client.on("ready", async () => {
|
||
console.log(`✓ 유저봇 로그인: ${client.user?.tag}`);
|
||
if (config.autoJoinChannelId) {
|
||
await joinAndListen(client, config.autoJoinChannelId).catch((e) =>
|
||
console.error("[userbot] auto-join failed:", e),
|
||
);
|
||
} else {
|
||
console.log('[userbot] DISCORD_VOICE_CHANNEL_ID 미설정 — "!자비스 join" 명령을 기다립니다.');
|
||
}
|
||
});
|
||
|
||
// Text-command control (userbots cannot use slash commands): join the
|
||
// caller's current voice channel, or leave.
|
||
client.on("messageCreate", async (msg: any) => {
|
||
const content = (msg.content || "").trim();
|
||
if (content === "!자비스 join" || content === "!jarvis join") {
|
||
const ch = msg.member?.voice?.channel || msg.author?.voice?.channel;
|
||
if (ch) await joinAndListen(client, ch.id).catch((e) => console.error("[userbot] join cmd:", e));
|
||
} else if (content === "!자비스 leave" || content === "!jarvis leave") {
|
||
// Leaving voice also ends the broadcast — don't leave a stream live with
|
||
// no session driving it.
|
||
await stopBroadcast(config.guildId);
|
||
leaveGuild(config.guildId);
|
||
}
|
||
});
|
||
|
||
await client.login(config.selfbotToken);
|
||
}
|