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:
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")
|
||||
elif path == "/api/prompt":
|
||||
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":
|
||||
self._stream_events()
|
||||
else:
|
||||
@@ -94,6 +98,10 @@ def _make_handler(dash: "Dashboard"):
|
||||
self._handle_log_mutate("delete")
|
||||
elif path == "/api/logs/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:
|
||||
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(),
|
||||
"application/json; charset=utf-8")
|
||||
return
|
||||
speaker = urllib.parse.unquote(self.headers.get("X-User-Name", "") or "")
|
||||
try:
|
||||
res = dash.voice_turn(raw)
|
||||
res = dash.voice_turn(raw, speaker=speaker)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("voice-turn failed")
|
||||
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")
|
||||
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:
|
||||
"""Per-line log delete/edit by event id."""
|
||||
raw = self._read_body()
|
||||
@@ -267,12 +312,14 @@ class Dashboard:
|
||||
|
||||
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:
|
||||
from .bot_control import BotControl
|
||||
self.monitor = monitor
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.stt = stt
|
||||
self.tts = tts
|
||||
self.brain = brain
|
||||
self.bot = BotControl() # dashboard <-> Discord bot control plane
|
||||
self._history: list[tuple[str, str]] = []
|
||||
self._history_turns = history_turns
|
||||
self._server: ThreadingHTTPServer | None = None
|
||||
@@ -325,7 +372,7 @@ class Dashboard:
|
||||
if self.tts is not None:
|
||||
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
|
||||
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
|
||||
@@ -344,6 +391,8 @@ class Dashboard:
|
||||
with open(src, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
turn = self.monitor.turn(source="discord")
|
||||
if speaker:
|
||||
turn.speaker = speaker # who spoke (for the "누가 말했는지" log)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -545,6 +594,18 @@ PAGE = r"""<!DOCTYPE html>
|
||||
.sttres .txt{color:var(--heard);font-weight:600;line-height:1.5}
|
||||
.sttres .meta{color:var(--muted);font-size:12px;margin-top: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{position:fixed;inset:0;z-index:20;background:rgba(4,7,11,.66);
|
||||
display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
@@ -614,6 +675,12 @@ PAGE = r"""<!DOCTYPE html>
|
||||
</div>
|
||||
</header>
|
||||
<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">
|
||||
<h2>🎤 음성 인식(STT) 테스트 · GPU</h2>
|
||||
<div class="sttrow">
|
||||
@@ -734,6 +801,7 @@ function turnEl(t){
|
||||
wrap.innerHTML =
|
||||
'<div class="trow">'+badge
|
||||
+'<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>'
|
||||
+'<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>'
|
||||
@@ -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();
|
||||
// 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);
|
||||
|
||||
@@ -80,6 +80,7 @@ class Turn:
|
||||
self._monitor = monitor
|
||||
self.id = turn_id
|
||||
self.source = source
|
||||
self.speaker = "" # who spoke (Discord display name), when known
|
||||
self.wall = _now_wall()
|
||||
self._t0 = _now_mono()
|
||||
self.heard_text = ""
|
||||
@@ -137,6 +138,7 @@ class Turn:
|
||||
return {
|
||||
"id": self.id,
|
||||
"source": self.source,
|
||||
"speaker": self.speaker,
|
||||
"wall": self.wall,
|
||||
"heard": self.heard_text,
|
||||
"thought": self.thought_text,
|
||||
|
||||
Reference in New Issue
Block a user