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

61
wsai/bot_control.py Normal file
View 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