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:
EJClaw
2026-08-22 11:43:48 +09:00
parent 6a2865d899
commit d3cf4e01b5
4 changed files with 325 additions and 80 deletions

View File

@@ -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);