Files
watch_sceen_ai/wsai/bot_control.py
EJClaw b0272d2171 feat(dashboard): merge lists button, drop STT test, add bot/log settings, barge-in
Dashboard changes requested by the user:
- Merge 화이트리스트/블랙리스트 into one "👤 유저 등록/관리" button (the modal
  already holds both lists).
- Remove the 음성 인식(STT) 테스트 section and its client JS.
- Add collapsible "⚙️ 봇 관련 설정" with a barge-in toggle: "유저 목소리 들을 때
  봇이 말하던 것 중지" (default on). Stored via /api/bot/settings; the bot reads
  it on its report round-trip and calls voicePlayer.stop() when an allowed user
  starts speaking (dave/bot.mjs).
- Add collapsible "🧹 로그 관련 설정" with a "잡음 로그 표시 안 함" toggle (default
  on, localStorage-backed): hides turns whose 들음 is (빈 결과) or 답변 is [잡음].
- Fix the fixed event/error log dock covering the bottom-most turn: syncDockPad()
  pads main by the dock height (on load, log render, toggle, resize).

BotControl gains a settings store; /api/bot/report now also returns settings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-26 23:19:21 +09:00

106 lines
3.9 KiB
Python

"""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
# Per-guild listen filter. Empty whitelist => listen to everyone;
# blacklist always excludes. Users and roles both supported.
self._lists: dict[str, dict[str, Any]] = {}
# Bot behaviour settings the dashboard toggles and the bot reads on each
# report. bargeIn: stop the bot's current TTS the moment a user speaks.
self._settings: dict[str, Any] = {"bargeIn": True}
# -- bot behaviour settings ------------------------------------------ #
def get_settings(self) -> dict[str, Any]:
with self._lock:
return dict(self._settings)
def set_settings(self, data: dict[str, Any]) -> dict[str, Any]:
with self._lock:
if "bargeIn" in data:
self._settings["bargeIn"] = bool(data["bargeIn"])
return dict(self._settings)
# -- whitelist / blacklist (per guild) ------------------------------- #
@staticmethod
def _empty_lists() -> dict[str, Any]:
return {"whitelistUsers": [], "blacklistUsers": [],
"whitelistRoles": [], "blacklistRoles": []}
def get_lists(self, guild_id: str) -> dict[str, Any]:
with self._lock:
return dict(self._lists.get(guild_id) or self._empty_lists())
def all_lists(self) -> dict[str, Any]:
with self._lock:
return {g: dict(v) for g, v in self._lists.items()}
def set_lists(self, guild_id: str, lists: dict[str, Any]) -> dict[str, Any]:
clean = self._empty_lists()
for key in clean:
items = lists.get(key) or []
# Normalise to [{id, name}] and drop anything without an id.
clean[key] = [
{"id": str(it["id"]), "name": str(it.get("name", it["id"]))}
for it in items if isinstance(it, dict) and it.get("id")
]
with self._lock:
self._lists[guild_id] = clean
return clean
# -- 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