Files
DarkflameServer/tools/chat-bridge/chat_bridge/dashboard.py
Aaron Kimbrell e213a7aeff feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch:
accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy
reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live
events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved
captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes
they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:30:43 -05:00

94 lines
3.8 KiB
Python

"""Talks to the dashboard's public API: GET /api/chat, POST /api/chat/send, and the /ws chat_message topic."""
from __future__ import annotations
import asyncio
import json
import logging
import urllib.error
import urllib.parse
import urllib.request
log = logging.getLogger("chat_bridge.dashboard")
class ApiError(Exception):
def __init__(self, status: int, message: str):
super().__init__(f"HTTP {status}: {message}")
self.status = status
class Dashboard:
def __init__(self, url: str, token: str, timeout: float = 15.0):
self.url = url.rstrip("/")
self.token = token
self.timeout = timeout
# --- HTTP (stdlib, run in a thread so the event loop keeps going) ---
def _request(self, method: str, path: str, params: dict | None = None, body: dict | None = None) -> dict:
url = self.url + path
if params:
url += "?" + urllib.parse.urlencode(params)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"Bearer {self.token}",
"Accept": "application/json",
**({"Content-Type": "application/json"} if data is not None else {}),
})
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
return json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as e:
try:
message = json.loads(e.read()).get("message", e.reason)
except Exception:
message = e.reason
raise ApiError(e.code, str(message)) from None
async def get(self, path: str, params: dict | None = None) -> dict:
return await asyncio.to_thread(self._request, "GET", path, params)
async def post(self, path: str, body: dict) -> dict:
return await asyncio.to_thread(self._request, "POST", path, None, body)
async def me(self) -> dict:
return await self.get("/api/auth/me")
async def chat_page(self, params: dict) -> dict:
return await self.get("/api/chat", params)
async def send_chat(self, body: dict) -> dict:
return await self.post("/api/chat/send", body)
# --- WebSocket ---
def ws_url(self) -> str:
parts = urllib.parse.urlsplit(self.url)
scheme = "wss" if parts.scheme == "https" else "ws"
return urllib.parse.urlunsplit((scheme, parts.netloc, parts.path.rstrip("/") + "/ws", "", ""))
async def watch_chat(self, on_message, on_connected=None) -> None:
"""Connect to /ws, subscribe to chat_message and call on_message(event) for each. Returns when the socket closes."""
import websockets # only needed for live updates; polling works without it
headers = {"Authorization": f"Bearer {self.token}"}
try:
from websockets.asyncio.client import connect # websockets 13+
ctx = connect(self.ws_url(), additional_headers=headers, open_timeout=self.timeout)
except ImportError: # older websockets
ctx = websockets.connect(self.ws_url(), extra_headers=headers, open_timeout=self.timeout)
async with ctx as ws:
await ws.send(json.dumps({"event": "subscribe", "subscription": "chat_message"}))
reply = json.loads(await asyncio.wait_for(ws.recv(), self.timeout))
if reply.get("error"):
raise ApiError(403, f"can't subscribe to chat_message: {reply['error']} (the account needs chat_view)")
if on_connected:
on_connected()
async for raw in ws:
try:
event = json.loads(raw)
except ValueError:
continue
if event.get("event") == "chat_message":
on_message(event)