The "couple broadcast to voice" feature only wired auto-start into the normal-bot join handler. Userbot mode (the only mode that can actually Go Live) was added later with Go-Live deferred to a "stage 2" that never landed, so the running deployment had no path to start a broadcast. Extract the coupling into a shared bot/src/broadcast.ts (auto-start on join, report live state to the brain each turn, voice toggle) and wire it into both index.ts and userbot.ts so both modes behave identically. Add a behavioural test for the auto-start + toggle contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
90 lines
4.0 KiB
TypeScript
90 lines
4.0 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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 = ({ transcript, reply }) => console.log(`🗣️ ${transcript}\n🤖 ${reply}`);
|
|
// 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);
|
|
}
|