Files
javis_bot/bot/src/userbot.ts
javis-bot 54c3ce7d1b feat: show speaker nickname instead of raw user ID in voice logs
Resolve the Discord user ID to a server nickname / global name (cached) and
display that in the transcript channel + console logs.
2026-06-14 22:39:06 +09:00

178 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}