feat(bot+dashboard): bot info, server/voice-channel picker, participants, speaker
Adds a dashboard<->bot control plane (bot pushes state + polls commands, keeping the bot's single outbound-HTTP direction): - New bot_control.BotControl + endpoints: GET /api/bot/state, /api/bot/commands; POST /api/bot/report, /api/bot/select. - Dashboard header bar: bot identity/connection, server dropdown (top "없음"), voice-channel dropdown (top "없음"), and live participant list. - Turns record who spoke (Turn.speaker, via X-User-Name on the voice-turn POST). - dave/bot.mjs: reports identity/guilds/voice-channels/members, polls join/leave commands and joins dynamically, and sends the speaker's display name. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
226
dave/bot.mjs
226
dave/bot.mjs
@@ -55,6 +55,10 @@ const RUN_MS = process.env.RUN_MS != null ? Number(process.env.RUN_MS) : 0; // 0
|
|||||||
// Python STT+TTS voice-turn endpoint (run `python -m wsai --voice-server`).
|
// Python STT+TTS voice-turn endpoint (run `python -m wsai --voice-server`).
|
||||||
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|
const VOICE_ENDPOINT = process.env.WSAI_VOICE_ENDPOINT || env.WSAI_VOICE_ENDPOINT
|
||||||
|| 'http://127.0.0.1:8787/api/voice-turn';
|
|| 'http://127.0.0.1:8787/api/voice-turn';
|
||||||
|
// Dashboard control plane (state push + command poll), same host as voice-turn.
|
||||||
|
const API_BASE = VOICE_ENDPOINT.replace(/\/api\/voice-turn\/?$/, '');
|
||||||
|
const REPORT_ENDPOINT = API_BASE + '/api/bot/report';
|
||||||
|
const REPORT_INTERVAL_MS = Number(process.env.WSAI_REPORT_INTERVAL_MS || 2500);
|
||||||
// Ignore utterances shorter than this many PCM bytes (48kHz*2ch*2B = 192000 B/s),
|
// Ignore utterances shorter than this many PCM bytes (48kHz*2ch*2B = 192000 B/s),
|
||||||
// so key clicks / brief noise don't trigger a turn. ~0.35s.
|
// so key clicks / brief noise don't trigger a turn. ~0.35s.
|
||||||
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
|
const MIN_UTTERANCE_BYTES = Number(process.env.WSAI_MIN_UTTERANCE_BYTES || 67000);
|
||||||
@@ -109,10 +113,19 @@ async function handleUtterance(userId, pcm) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wav = Buffer.concat([wavHeader(pcm.length), pcm]);
|
const wav = Buffer.concat([wavHeader(pcm.length), pcm]);
|
||||||
|
// Resolve who spoke (Discord display name) so the dashboard log can show it.
|
||||||
|
let speaker = userId;
|
||||||
|
try {
|
||||||
|
const g = currentGuildId && client.guilds.cache.get(currentGuildId);
|
||||||
|
const m = g && (g.members.cache.get(userId) || await g.members.fetch(userId).catch(() => null));
|
||||||
|
if (m) speaker = m.displayName || m.user.username;
|
||||||
|
} catch {}
|
||||||
let resp;
|
let resp;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(VOICE_ENDPOINT, {
|
resp = await fetch(VOICE_ENDPOINT, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav,
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'audio/wav', 'X-User-Name': encodeURIComponent(speaker) },
|
||||||
|
body: wav,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`voice-turn POST failed (is \`python -m wsai --voice-server\` running?): ${e.message}`);
|
log(`voice-turn POST failed (is \`python -m wsai --voice-server\` running?): ${e.message}`);
|
||||||
@@ -147,11 +160,131 @@ const client = new Client({
|
|||||||
|
|
||||||
const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
|
const perUser = new Map(); // userId -> { opusPackets, pcmFrames }
|
||||||
|
|
||||||
|
// --- control-plane state (dashboard drives which channel we're in) --------- #
|
||||||
|
let currentGuildId = null, currentChannelId = null, currentChannelName = null;
|
||||||
|
const speakingSet = new Set(); // userIds currently speaking (for participant list)
|
||||||
|
const activeSubs = new Set(); // userIds with an in-flight receive subscription
|
||||||
|
|
||||||
|
// Attach the bot's audio player (so it can speak) to a fresh connection.
|
||||||
|
function setupPlayer(connection) {
|
||||||
|
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
||||||
|
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
|
||||||
|
connection.subscribe(voicePlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach the receive path: capture each utterance and run the voice turn.
|
||||||
|
function setupReceiver(connection) {
|
||||||
|
const receiver = connection.receiver;
|
||||||
|
receiver.speaking.on('start', (userId) => {
|
||||||
|
speakingSet.add(userId);
|
||||||
|
if (userId === client.user.id || activeSubs.has(userId)) return;
|
||||||
|
activeSubs.add(userId);
|
||||||
|
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
|
||||||
|
const opusStream = receiver.subscribe(userId, {
|
||||||
|
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
|
||||||
|
});
|
||||||
|
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
|
||||||
|
const chunks = [];
|
||||||
|
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
|
||||||
|
opusStream.on('error', (e) => { logThrottled(`recv:${e.message}`, `recv stream error user=${userId}: ${e.message}`); activeSubs.delete(userId); });
|
||||||
|
opusStream.pipe(decoder);
|
||||||
|
decoder.on('data', (d) => { chunks.push(d); perUser.get(userId).pcmFrames++; });
|
||||||
|
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
|
||||||
|
decoder.on('end', () => {
|
||||||
|
activeSubs.delete(userId);
|
||||||
|
const pcm = Buffer.concat(chunks);
|
||||||
|
log(`utterance end user=${userId} pcm=${pcm.length}B — running voice turn`);
|
||||||
|
handleUtterance(userId, pcm).catch((e) => log(`voice turn error: ${e.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
receiver.speaking.on('end', (userId) => speakingSet.delete(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join (or switch to) a voice channel on command from the dashboard.
|
||||||
|
async function joinChannel(guildId, channelId) {
|
||||||
|
const guild = await client.guilds.fetch(guildId).catch(() => null);
|
||||||
|
const channel = guild && await guild.channels.fetch(channelId).catch(() => null);
|
||||||
|
if (!channel || !channel.isVoiceBased()) { log(`join failed: ${guildId}/${channelId} not a voice channel`); return; }
|
||||||
|
if (currentGuildId && currentGuildId !== guildId) leaveChannel();
|
||||||
|
const connection = joinVoiceChannel({
|
||||||
|
channelId, guildId, adapterCreator: guild.voiceAdapterCreator,
|
||||||
|
selfDeaf: false, selfMute: false,
|
||||||
|
});
|
||||||
|
connection.on('error', (e) => log(`voice connection error: ${e.message}`));
|
||||||
|
try {
|
||||||
|
await entersState(connection, VoiceConnectionStatus.Ready, 40_000);
|
||||||
|
} catch (e) { log(`join not Ready in 40s: ${e.message}`); return; }
|
||||||
|
currentGuildId = guildId; currentChannelId = channelId; currentChannelName = channel.name;
|
||||||
|
speakingSet.clear(); activeSubs.clear();
|
||||||
|
setupPlayer(connection);
|
||||||
|
setupReceiver(connection);
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
||||||
|
log('voice: disconnected — attempting to resume…');
|
||||||
|
Promise.race([
|
||||||
|
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||||
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||||
|
]).catch(() => { log('voice: could not resume'); leaveChannel(); });
|
||||||
|
});
|
||||||
|
log(`✅ joined voice: "${guild.name}" / "${channel.name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveChannel() {
|
||||||
|
try { getVoiceConnection(currentGuildId)?.destroy(); } catch {}
|
||||||
|
currentGuildId = currentChannelId = currentChannelName = null;
|
||||||
|
speakingSet.clear(); activeSubs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the state snapshot the dashboard shows (identity, joinable servers +
|
||||||
|
// voice channels, current channel, and who is in it).
|
||||||
|
function buildState() {
|
||||||
|
const guilds = [...client.guilds.cache.values()].map((g) => ({
|
||||||
|
id: g.id, name: g.name,
|
||||||
|
voiceChannels: [...g.channels.cache.values()]
|
||||||
|
.filter((c) => c.isVoiceBased())
|
||||||
|
.map((c) => ({ id: c.id, name: c.name })),
|
||||||
|
}));
|
||||||
|
let members = [];
|
||||||
|
if (currentGuildId && currentChannelId) {
|
||||||
|
const ch = client.guilds.cache.get(currentGuildId)?.channels.cache.get(currentChannelId);
|
||||||
|
if (ch && ch.members) {
|
||||||
|
members = [...ch.members.values()].map((m) => ({
|
||||||
|
id: m.id, name: m.displayName, speaking: speakingSet.has(m.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
identity: { id: client.user.id, username: client.user.username, tag: client.user.tag },
|
||||||
|
guilds,
|
||||||
|
current: { guildId: currentGuildId, channelId: currentChannelId, channelName: currentChannelName },
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(cmd) {
|
||||||
|
if (cmd.type === 'join') await joinChannel(cmd.guildId, cmd.channelId);
|
||||||
|
else if (cmd.type === 'leave') { leaveChannel(); log('left voice on command'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push state to the dashboard and apply any commands it hands back.
|
||||||
|
async function reportLoop() {
|
||||||
|
let j;
|
||||||
|
try {
|
||||||
|
const r = await fetch(REPORT_ENDPOINT, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(buildState()),
|
||||||
|
});
|
||||||
|
j = await r.json();
|
||||||
|
} catch { return; } // dashboard down: keep running, retry next tick
|
||||||
|
for (const cmd of (j?.commands || [])) {
|
||||||
|
try { await handleCommand(cmd); } catch (e) { log(`command ${cmd?.type} failed: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let leaving = false;
|
let leaving = false;
|
||||||
function leaveAndExit(code = 0) {
|
function leaveAndExit(code = 0) {
|
||||||
if (leaving) return;
|
if (leaving) return;
|
||||||
leaving = true;
|
leaving = true;
|
||||||
try { getVoiceConnection(GUILD_ID)?.destroy(); } catch {}
|
try { getVoiceConnection(currentGuildId || GUILD_ID)?.destroy(); } catch {}
|
||||||
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
|
const summary = [...perUser.entries()].map(([u, s]) => `${u}:opus=${s.opusPackets},pcm=${s.pcmFrames}`);
|
||||||
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
log(`leaving. speakers heard: ${summary.length ? summary.join(' ') : '(none)'}`);
|
||||||
try { client.destroy(); } catch {}
|
try { client.destroy(); } catch {}
|
||||||
@@ -165,84 +298,21 @@ if (RUN_MS > 0) setTimeout(() => { log(`RUN_MS=${RUN_MS} hard ceiling elapsed
|
|||||||
|
|
||||||
client.once('clientReady', async () => {
|
client.once('clientReady', async () => {
|
||||||
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
log(`logged in as ${client.user.tag} (${client.user.id})`);
|
||||||
let guild, channel;
|
log(`voice endpoint: ${VOICE_ENDPOINT} · control: ${REPORT_ENDPOINT}`);
|
||||||
try {
|
|
||||||
guild = await client.guilds.fetch(GUILD_ID);
|
// Backward-compat: if a default guild/channel is configured, auto-join it.
|
||||||
channel = await guild.channels.fetch(CHANNEL_ID);
|
// Otherwise idle and wait for the dashboard to pick a channel.
|
||||||
} catch (e) {
|
if (GUILD_ID && CHANNEL_ID) {
|
||||||
log(`FATAL: cannot access guild/channel — is the bot invited to guild ${GUILD_ID}? (${e.message})`);
|
await joinChannel(GUILD_ID, CHANNEL_ID).catch((e) => log(`initial join failed: ${e.message}`));
|
||||||
log('run `node bot.mjs --invite` and have a server admin authorise the bot, then retry.');
|
} else {
|
||||||
return leaveAndExit(1);
|
log('no default channel — waiting for the dashboard to select a server/voice channel…');
|
||||||
}
|
}
|
||||||
if (!channel || !channel.isVoiceBased()) { log(`FATAL: channel ${CHANNEL_ID} is not a voice channel`); return leaveAndExit(1); }
|
|
||||||
|
|
||||||
log(`joining voice: guild="${guild.name}" channel="${channel.name}"`);
|
// Report state + poll commands forever. This is what powers the dashboard's
|
||||||
const connection = joinVoiceChannel({
|
// bot info, server/voice-channel pickers, participant list, and join/leave.
|
||||||
channelId: CHANNEL_ID,
|
reportLoop();
|
||||||
guildId: GUILD_ID,
|
const reportTimer = setInterval(reportLoop, REPORT_INTERVAL_MS);
|
||||||
adapterCreator: guild.voiceAdapterCreator,
|
if (typeof reportTimer.unref === 'function') reportTimer.unref();
|
||||||
selfDeaf: false, // MUST be false to receive audio (the STT input path)
|
|
||||||
selfMute: false, // false so we can also speak later (M5 TTS)
|
|
||||||
});
|
|
||||||
connection.on('error', (e) => log(`voice connection error: ${e.message}`));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The DAVE/MLS handshake here cycles signalling<->connecting several times
|
|
||||||
// and can take ~25s, so give it a generous ceiling before declaring failure.
|
|
||||||
await entersState(connection, VoiceConnectionStatus.Ready, 40_000);
|
|
||||||
} catch (e) {
|
|
||||||
log(`FATAL: voice connection did not become Ready in 40s (${e.message})`);
|
|
||||||
return leaveAndExit(1);
|
|
||||||
}
|
|
||||||
log(`✅ JOINED & READY. channel=${CHANNEL_ID} — staying connected, listening for speakers…`);
|
|
||||||
|
|
||||||
// Playback path (bot speaks): one player, subscribed to the connection.
|
|
||||||
voicePlayer = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });
|
|
||||||
voicePlayer.on('error', (e) => log(`player error: ${e.message}`));
|
|
||||||
connection.subscribe(voicePlayer);
|
|
||||||
log(`voice endpoint: ${VOICE_ENDPOINT}`);
|
|
||||||
|
|
||||||
// ---------- receive path: capture each utterance and run the voice turn ----
|
|
||||||
const receiver = connection.receiver;
|
|
||||||
const active = new Set(); // userIds with an in-flight subscription (avoid dupes)
|
|
||||||
receiver.speaking.on('start', (userId) => {
|
|
||||||
if (userId === client.user.id || active.has(userId)) return; // skip self / dupes
|
|
||||||
active.add(userId);
|
|
||||||
if (!perUser.has(userId)) perUser.set(userId, { opusPackets: 0, pcmFrames: 0 });
|
|
||||||
log(`SPEAKING start user=${userId}`);
|
|
||||||
const opusStream = receiver.subscribe(userId, {
|
|
||||||
// End the utterance after a short silence so natural pauses don't cut words.
|
|
||||||
end: { behavior: EndBehaviorType.AfterSilence, duration: 800 },
|
|
||||||
});
|
|
||||||
// Decode Opus -> 48kHz stereo s16le PCM, buffered until the utterance ends.
|
|
||||||
const decoder = new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 });
|
|
||||||
const chunks = [];
|
|
||||||
opusStream.on('data', () => { perUser.get(userId).opusPackets++; });
|
|
||||||
// A receive-stream error (e.g. a DAVE decrypt/UDP GenericFailure on one
|
|
||||||
// packet) must NOT crash the process — log it and free the slot so the
|
|
||||||
// next utterance still works.
|
|
||||||
opusStream.on('error', (e) => { logThrottled(`recv:${e.message}`, `recv stream error user=${userId}: ${e.message}`); active.delete(userId); });
|
|
||||||
opusStream.pipe(decoder);
|
|
||||||
decoder.on('data', (d) => {
|
|
||||||
chunks.push(d);
|
|
||||||
const s = perUser.get(userId); s.pcmFrames++;
|
|
||||||
});
|
|
||||||
decoder.on('error', (e) => log(`decode error user=${userId}: ${e.message}`));
|
|
||||||
decoder.on('end', () => {
|
|
||||||
active.delete(userId);
|
|
||||||
const pcm = Buffer.concat(chunks);
|
|
||||||
log(`utterance end user=${userId} pcm=${pcm.length}B — running voice turn`);
|
|
||||||
handleUtterance(userId, pcm).catch((e) => log(`voice turn error: ${e.message}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on(VoiceConnectionStatus.Disconnected, () => {
|
|
||||||
log('voice: disconnected — attempting to resume…');
|
|
||||||
Promise.race([
|
|
||||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
|
||||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
|
||||||
]).catch(() => { log('voice: could not resume, leaving'); leaveAndExit(0); });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on('error', (e) => log('client error', e.message));
|
client.on('error', (e) => log('client error', e.message));
|
||||||
|
|||||||
61
wsai/bot_control.py
Normal file
61
wsai/bot_control.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Control plane between the dashboard (Python) and the Discord bot (Node).
|
||||||
|
|
||||||
|
The bot only ever makes *outbound* HTTP (it already POSTs voice turns), so we
|
||||||
|
keep that single direction:
|
||||||
|
|
||||||
|
* the bot PUSHES its live state here (identity, joinable guilds + voice
|
||||||
|
channels, current channel, members, whitelist/blacklist) via
|
||||||
|
``POST /api/bot/report`` → :meth:`report`;
|
||||||
|
* the bot POLLS ``GET /api/bot/commands`` → :meth:`drain` for pending commands
|
||||||
|
(join a channel, leave) that the dashboard UI enqueued via :meth:`enqueue`.
|
||||||
|
|
||||||
|
Thread-safe: the HTTP handler threads read/write from several threads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# The bot is considered offline if it hasn't reported within this window.
|
||||||
|
STALE_AFTER_S = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
class BotControl:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._state: dict[str, Any] = {"connected": False, "ts": 0.0}
|
||||||
|
self._commands: deque[dict[str, Any]] = deque()
|
||||||
|
self._cmd_id = 0
|
||||||
|
|
||||||
|
# -- bot -> dashboard (state push) ----------------------------------- #
|
||||||
|
def report(self, state: dict[str, Any]) -> None:
|
||||||
|
s = dict(state)
|
||||||
|
s["ts"] = time.time()
|
||||||
|
s["connected"] = True
|
||||||
|
with self._lock:
|
||||||
|
self._state = s
|
||||||
|
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
s = dict(self._state)
|
||||||
|
# A report older than STALE_AFTER_S means the bot stopped polling/pushing.
|
||||||
|
if s.get("connected") and (time.time() - s.get("ts", 0.0)) > STALE_AFTER_S:
|
||||||
|
s["connected"] = False
|
||||||
|
return s
|
||||||
|
|
||||||
|
# -- dashboard -> bot (command queue) -------------------------------- #
|
||||||
|
def enqueue(self, cmd: dict[str, Any]) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._cmd_id += 1
|
||||||
|
cmd = {**cmd, "id": self._cmd_id}
|
||||||
|
self._commands.append(cmd)
|
||||||
|
return self._cmd_id
|
||||||
|
|
||||||
|
def drain(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
cmds = list(self._commands)
|
||||||
|
self._commands.clear()
|
||||||
|
return cmds
|
||||||
@@ -74,6 +74,10 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
self._send(200, body, "application/json; charset=utf-8")
|
self._send(200, body, "application/json; charset=utf-8")
|
||||||
elif path == "/api/prompt":
|
elif path == "/api/prompt":
|
||||||
self._handle_prompt_get()
|
self._handle_prompt_get()
|
||||||
|
elif path == "/api/bot/state":
|
||||||
|
self._send_json(dash.bot.state())
|
||||||
|
elif path == "/api/bot/commands":
|
||||||
|
self._send_json({"commands": dash.bot.drain()})
|
||||||
elif path == "/events":
|
elif path == "/events":
|
||||||
self._stream_events()
|
self._stream_events()
|
||||||
else:
|
else:
|
||||||
@@ -94,6 +98,10 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
self._handle_log_mutate("delete")
|
self._handle_log_mutate("delete")
|
||||||
elif path == "/api/logs/edit":
|
elif path == "/api/logs/edit":
|
||||||
self._handle_log_mutate("edit")
|
self._handle_log_mutate("edit")
|
||||||
|
elif path == "/api/bot/report":
|
||||||
|
self._handle_bot_report()
|
||||||
|
elif path == "/api/bot/select":
|
||||||
|
self._handle_bot_select()
|
||||||
else:
|
else:
|
||||||
self._send(404, b"not found", "text/plain; charset=utf-8")
|
self._send(404, b"not found", "text/plain; charset=utf-8")
|
||||||
|
|
||||||
@@ -119,8 +127,9 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
|
self._send(400, json.dumps({"ok": False, "error": "empty upload"}).encode(),
|
||||||
"application/json; charset=utf-8")
|
"application/json; charset=utf-8")
|
||||||
return
|
return
|
||||||
|
speaker = urllib.parse.unquote(self.headers.get("X-User-Name", "") or "")
|
||||||
try:
|
try:
|
||||||
res = dash.voice_turn(raw)
|
res = dash.voice_turn(raw, speaker=speaker)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
log.exception("voice-turn failed")
|
log.exception("voice-turn failed")
|
||||||
self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
|
self._send(500, json.dumps({"ok": False, "error": f"{type(exc).__name__}: {exc}"},
|
||||||
@@ -206,6 +215,42 @@ def _make_handler(dash: "Dashboard"):
|
|||||||
}, ensure_ascii=False).encode("utf-8")
|
}, ensure_ascii=False).encode("utf-8")
|
||||||
self._send(200, body, "application/json; charset=utf-8")
|
self._send(200, body, "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
def _send_json(self, obj, code: int = 200) -> None:
|
||||||
|
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
|
||||||
|
"application/json; charset=utf-8")
|
||||||
|
|
||||||
|
def _handle_bot_report(self) -> None:
|
||||||
|
"""The Discord bot pushes its live state (identity, guilds, voice
|
||||||
|
channels, current channel + members, list settings)."""
|
||||||
|
raw = self._read_body()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8")) if raw else {}
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||||
|
return
|
||||||
|
dash.bot.report(data)
|
||||||
|
# Hand the bot any queued commands in the same round trip so it does
|
||||||
|
# not have to poll a second endpoint.
|
||||||
|
self._send_json({"ok": True, "commands": dash.bot.drain()})
|
||||||
|
|
||||||
|
def _handle_bot_select(self) -> None:
|
||||||
|
"""UI picked a server/voice channel → queue a join (or leave) command."""
|
||||||
|
raw = self._read_body()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8")) if raw else {}
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
self._send_json({"ok": False, "error": "invalid JSON"}, 400)
|
||||||
|
return
|
||||||
|
guild_id = (data.get("guildId") or "").strip()
|
||||||
|
channel_id = (data.get("channelId") or "").strip()
|
||||||
|
if channel_id and guild_id:
|
||||||
|
cid = dash.bot.enqueue({"type": "join", "guildId": guild_id, "channelId": channel_id})
|
||||||
|
monitor.log("info", f"음성채널 참여 요청 (guild={guild_id} channel={channel_id})")
|
||||||
|
else:
|
||||||
|
cid = dash.bot.enqueue({"type": "leave"})
|
||||||
|
monitor.log("info", "음성채널 나가기 요청")
|
||||||
|
self._send_json({"ok": True, "commandId": cid})
|
||||||
|
|
||||||
def _handle_log_mutate(self, action: str) -> None:
|
def _handle_log_mutate(self, action: str) -> None:
|
||||||
"""Per-line log delete/edit by event id."""
|
"""Per-line log delete/edit by event id."""
|
||||||
raw = self._read_body()
|
raw = self._read_body()
|
||||||
@@ -267,12 +312,14 @@ class Dashboard:
|
|||||||
|
|
||||||
def __init__(self, monitor: Monitor, host: str = "0.0.0.0", port: int = 8787,
|
def __init__(self, monitor: Monitor, host: str = "0.0.0.0", port: int = 8787,
|
||||||
stt=None, tts=None, brain=None, history_turns: int = 12) -> None:
|
stt=None, tts=None, brain=None, history_turns: int = 12) -> None:
|
||||||
|
from .bot_control import BotControl
|
||||||
self.monitor = monitor
|
self.monitor = monitor
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.stt = stt
|
self.stt = stt
|
||||||
self.tts = tts
|
self.tts = tts
|
||||||
self.brain = brain
|
self.brain = brain
|
||||||
|
self.bot = BotControl() # dashboard <-> Discord bot control plane
|
||||||
self._history: list[tuple[str, str]] = []
|
self._history: list[tuple[str, str]] = []
|
||||||
self._history_turns = history_turns
|
self._history_turns = history_turns
|
||||||
self._server: ThreadingHTTPServer | None = None
|
self._server: ThreadingHTTPServer | None = None
|
||||||
@@ -325,7 +372,7 @@ class Dashboard:
|
|||||||
if self.tts is not None:
|
if self.tts is not None:
|
||||||
self._submit(self.tts._ensure())
|
self._submit(self.tts._ensure())
|
||||||
|
|
||||||
def voice_turn(self, audio_bytes: bytes) -> dict:
|
def voice_turn(self, audio_bytes: bytes, speaker: str = "") -> dict:
|
||||||
"""One Discord voice turn: decode the uploaded utterance, recognise it
|
"""One Discord voice turn: decode the uploaded utterance, recognise it
|
||||||
on the GPU, think of a reply (Claude brain if wired, else echo),
|
on the GPU, think of a reply (Claude brain if wired, else echo),
|
||||||
synthesise it on the GPU, and return {heard, reply, wav} where wav is the
|
synthesise it on the GPU, and return {heard, reply, wav} where wav is the
|
||||||
@@ -344,6 +391,8 @@ class Dashboard:
|
|||||||
with open(src, "wb") as f:
|
with open(src, "wb") as f:
|
||||||
f.write(audio_bytes)
|
f.write(audio_bytes)
|
||||||
turn = self.monitor.turn(source="discord")
|
turn = self.monitor.turn(source="discord")
|
||||||
|
if speaker:
|
||||||
|
turn.speaker = speaker # who spoke (for the "누가 말했는지" log)
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
@@ -545,6 +594,18 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
|
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
|
||||||
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
|
.sttres .meta{color:var(--muted);font-size:12px;margin-top:5px}
|
||||||
.hbtn{padding:6px 12px;font-size:12.5px}
|
.hbtn{padding:6px 12px;font-size:12.5px}
|
||||||
|
/* Bot control bar (봇 정보 · 서버/채널 선택 · 참여자) */
|
||||||
|
.botbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;background:var(--panel);
|
||||||
|
border:1px solid var(--line);border-radius:12px;padding:10px 14px;margin:0 0 16px;font-size:13px}
|
||||||
|
.botbar label{display:flex;gap:6px;align-items:center;color:var(--muted)}
|
||||||
|
.botbar select{background:var(--panel2);border:1px solid var(--line);color:var(--fg);
|
||||||
|
border-radius:8px;padding:6px 9px;font-size:13px;max-width:230px}
|
||||||
|
.botinfo{display:flex;gap:7px;align-items:center;font-weight:600}
|
||||||
|
.parts{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-left:auto;color:var(--muted)}
|
||||||
|
.part{display:inline-flex;gap:5px;align-items:center;background:var(--panel2);border:1px solid var(--line);
|
||||||
|
border-radius:999px;padding:3px 10px;font-size:12px}
|
||||||
|
.part.spk{border-color:#1f5236;color:#9ff0bd}
|
||||||
|
.speaker{color:var(--muted);font-size:11.5px}
|
||||||
/* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */
|
/* Modal / popup (reused by 프롬프트, 화이트/블랙리스트 …) */
|
||||||
.modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66);
|
.modal{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66);
|
||||||
display:flex;align-items:center;justify-content:center;padding:20px}
|
display:flex;align-items:center;justify-content:center;padding:20px}
|
||||||
@@ -614,6 +675,12 @@ PAGE = r"""<!DOCTYPE html>
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
<section class="botbar" id="botbar">
|
||||||
|
<span class="botinfo" id="botinfo"><span class="dot off"></span>봇: 연결 안 됨</span>
|
||||||
|
<label>서버 <select id="guildSel"><option value="">없음</option></select></label>
|
||||||
|
<label>음성채널 <select id="vcSel"><option value="">없음</option></select></label>
|
||||||
|
<span class="parts" id="parts"></span>
|
||||||
|
</section>
|
||||||
<section class="sttbox" id="sttbox" style="display:none">
|
<section class="sttbox" id="sttbox" style="display:none">
|
||||||
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
|
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
|
||||||
<div class="sttrow">
|
<div class="sttrow">
|
||||||
@@ -734,6 +801,7 @@ function turnEl(t){
|
|||||||
wrap.innerHTML =
|
wrap.innerHTML =
|
||||||
'<div class="trow">'+badge
|
'<div class="trow">'+badge
|
||||||
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
|
+'<span class="badge">#'+t.id+' · '+esc(t.source||'voice')+'</span>'
|
||||||
|
+(t.speaker?'<span class="badge">🗣 '+esc(t.speaker)+'</span>':'')
|
||||||
+'<span class="time">'+fmtTime(t.wall)+'</span></div>'
|
+'<span class="time">'+fmtTime(t.wall)+'</span></div>'
|
||||||
+'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
|
+'<div class="line"><span class="tag">들음</span><span class="heard">'+(t.heard?esc(t.heard):'<i style="color:var(--muted)">(수신 대기)</i>')+'</span></div>'
|
||||||
+'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>'
|
+'<div class="line"><span class="tag">생각</span><span class="thought">'+(t.thought?esc(t.thought):'<i style="color:var(--muted)">…</i>')+'</span></div>'
|
||||||
@@ -924,6 +992,50 @@ $('logbody').addEventListener('click', async (ev)=>{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 봇 제어 바: 정보 표시 / 서버·채널 선택 / 참여자 --------------------- #
|
||||||
|
let botState = null;
|
||||||
|
let userPickedGuild = null; // remember the user's server pick across polls
|
||||||
|
function renderBot(s){
|
||||||
|
botState = s;
|
||||||
|
const info=$('botinfo');
|
||||||
|
if(s && s.connected){
|
||||||
|
const id = s.identity||{};
|
||||||
|
info.innerHTML = '<span class="dot live"></span>봇: <b>'+esc(id.tag||id.username||id.id||'연결됨')+'</b>';
|
||||||
|
} else {
|
||||||
|
info.innerHTML = '<span class="dot off"></span>봇: 연결 안 됨';
|
||||||
|
}
|
||||||
|
const guilds = (s&&s.guilds)||[];
|
||||||
|
const cur = (s&&s.current)||{};
|
||||||
|
const gsel=$('guildSel');
|
||||||
|
const gpick = userPickedGuild!=null ? userPickedGuild : (cur.guildId||'');
|
||||||
|
gsel.innerHTML = '<option value="">없음</option>' + guilds.map(g=>
|
||||||
|
'<option value="'+esc(g.id)+'"'+(g.id===gpick?' selected':'')+'>'+esc(g.name)+'</option>').join('');
|
||||||
|
// Voice channels of the picked guild.
|
||||||
|
const g = guilds.find(g=>g.id===gpick);
|
||||||
|
const vcs = (g&&g.voiceChannels)||[];
|
||||||
|
const vsel=$('vcSel');
|
||||||
|
vsel.innerHTML = '<option value="">없음</option>' + vcs.map(v=>
|
||||||
|
'<option value="'+esc(v.id)+'"'+(v.id===cur.channelId?' selected':'')+'>'+esc(v.name)+'</option>').join('');
|
||||||
|
// Participants in the current voice channel.
|
||||||
|
const members=(s&&s.members)||[];
|
||||||
|
$('parts').innerHTML = members.length
|
||||||
|
? '참여자: '+members.map(m=>'<span class="part'+(m.speaking?' spk':'')+'">'+(m.speaking?'🔊':'👤')+' '+esc(m.name||m.id)+'</span>').join('')
|
||||||
|
: (s&&s.connected&&cur.channelId ? '참여자: (없음)' : '');
|
||||||
|
}
|
||||||
|
$('guildSel').onchange = ()=>{ userPickedGuild=$('guildSel').value; renderBot(botState); };
|
||||||
|
$('vcSel').onchange = async ()=>{
|
||||||
|
const guildId=$('guildSel').value, channelId=$('vcSel').value;
|
||||||
|
try{
|
||||||
|
await fetch('/api/bot/select',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({guildId, channelId})});
|
||||||
|
toast(channelId?'음성채널 참여 요청을 보냈습니다':'나가기 요청을 보냈습니다');
|
||||||
|
}catch(e){ toast('요청 실패: '+e); }
|
||||||
|
};
|
||||||
|
async function pollBot(){
|
||||||
|
try{ renderBot(await (await fetch('/api/bot/state')).json()); }catch(e){}
|
||||||
|
}
|
||||||
|
pollBot(); setInterval(pollBot, 2500);
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
// Refresh uptime label every second from the last known status.
|
// Refresh uptime label every second from the last known status.
|
||||||
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
|
setInterval(()=>{ if(statusData){ statusData.uptime_s=(statusData.uptime_s||0)+1; $('s-up').textContent=fmtUptime(statusData.uptime_s);} }, 1000);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ class Turn:
|
|||||||
self._monitor = monitor
|
self._monitor = monitor
|
||||||
self.id = turn_id
|
self.id = turn_id
|
||||||
self.source = source
|
self.source = source
|
||||||
|
self.speaker = "" # who spoke (Discord display name), when known
|
||||||
self.wall = _now_wall()
|
self.wall = _now_wall()
|
||||||
self._t0 = _now_mono()
|
self._t0 = _now_mono()
|
||||||
self.heard_text = ""
|
self.heard_text = ""
|
||||||
@@ -137,6 +138,7 @@ class Turn:
|
|||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
|
"speaker": self.speaker,
|
||||||
"wall": self.wall,
|
"wall": self.wall,
|
||||||
"heard": self.heard_text,
|
"heard": self.heard_text,
|
||||||
"thought": self.thought_text,
|
"thought": self.thought_text,
|
||||||
|
|||||||
Reference in New Issue
Block a user