"""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]] = {} # -- 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