- 유저 등록/관리 search now has 4 types: 유저(non-bot members), 봇(bot members), 통화방(current voice-channel members, bot flag enriched), 역할(roles — expandable to the members under each role; register the role OR a member under it). - Persist settings to a JSON state store (WSAI_STATE_FILE, default ~/.config/wsai/state.json) so they survive a service/container restart: per-guild listen lists + bargeIn (BotControl), TTS base/overrides and the chosen STT/LLM models (Dashboard). Applied on voice-server startup before warm. - TTS 감정 선택에 최상단 "전체변경 (모든 감정)" 추가: 적용하면 base를 그 값으로 세팅하고 모든 per-emotion override를 지워 전 감정이 한 번에 그 값으로 말합니다. - Move the component status bar (눈/시각/귀/LLM/입) above the bot bar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
115 lines
4.4 KiB
Python
115 lines
4.4 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.
|
|
# Restored from the persistent state store so it survives a restart.
|
|
from . import state_store
|
|
st = state_store.load()
|
|
self._lists: dict[str, dict[str, Any]] = st.get("lists") or {}
|
|
# 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, **(st.get("botSettings") or {})}
|
|
|
|
# -- 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"])
|
|
out = dict(self._settings)
|
|
from . import state_store
|
|
state_store.patch("botSettings", out)
|
|
return out
|
|
|
|
# -- 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
|
|
snapshot = {g: dict(v) for g, v in self._lists.items()}
|
|
from . import state_store
|
|
state_store.patch("lists", snapshot) # persist per-guild lists across restarts
|
|
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
|