Files
SREBOT/BOT/receiver_bridge.py
T
FURRO404 2b399fdb81 add SREBOT, SHARED, TSSBOT contents (fixup for #1223)
PR #1223 only staged the deletions of the old paths because the new
top-level directories were still untracked when the commit was authored.
This commit adds the actual restructured tree: SREBOT/ (existing bot),
SHARED/ (vromfs, data_parser, ICONS/MAPS/FONTS, DAGOR_FILES,
update_game_files), and TSSBOT/ (skeleton).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:17:02 -07:00

233 lines
7.7 KiB
Python

"""
receiver_bridge.py
Bridge helpers for external SREBOT transfer.
This module provides two pieces:
1. A formal SREBOT API client that external consumers can use to query the
SREBOT HTTP API.
2. A persistent outbox for replay and GOB payloads so the external bridge
service can fan them out over websocket.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote
from typing import Any, Optional
import aiofiles
import aiohttp
logger = logging.getLogger(__name__)
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, default)
return value.strip()
_storage_root_raw = _env("SREBOT_STORAGE_VOL_PATH")
if not _storage_root_raw:
raise RuntimeError("SREBOT_STORAGE_VOL_PATH must be set")
_STORAGE_ROOT = Path(_storage_root_raw)
SREBOT_API_BASE_URL = _env("SREBOT_API_BASE_URL", _env("SREBOT_HTTP_URL", "http://127.0.0.1:6000")).rstrip("/")
SREBOT_API_BEARER_TOKEN = _env("SREBOT_API_BEARER_TOKEN")
EXTERNAL_OUTBOX_PATH = Path(_env("SREBOT_EXTERNAL_OUTBOX_PATH", str(_STORAGE_ROOT / "external_bridge_outbox.jsonl")))
EXTERNAL_OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True)
@dataclass(slots=True)
class SREBOTApiClient:
"""Typed HTTP client for the SREBOT read-only API."""
base_url: str = SREBOT_API_BASE_URL
bearer_token: str = SREBOT_API_BEARER_TOKEN
timeout_seconds: float = 30.0
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
return headers
async def _request(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
url = f"{self.base_url}/{path.lstrip('/')}"
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=self._headers(), params=params) as response:
response.raise_for_status()
return await response.json()
async def get_info(self) -> Any:
return await self._request("/api/info")
async def get_player(self, uid: str, **params: Any) -> Any:
return await self._request(f"/api/player/{quote(str(uid), safe='')}", params=params or None)
async def get_player_games(self, uid: str, **params: Any) -> Any:
return await self._request(f"/api/player/{quote(str(uid), safe='')}/games", params=params or None)
async def get_player_history(self, uid: str) -> Any:
return await self._request(f"/api/player/{quote(str(uid), safe='')}/history")
async def search_players(self, nickname: str) -> Any:
return await self._request(f"/api/search/{quote(str(nickname), safe='')}")
async def get_live(self, **params: Any) -> Any:
return await self._request("/api/live", params=params or None)
async def get_match(self, session_id: str) -> Any:
return await self._request(f"/api/match/{quote(str(session_id), safe='')}")
async def get_match_replay(self, session_id: str) -> Any:
return await self._request(f"/api/match/{quote(str(session_id), safe='')}/replay")
async def search_games(self, **params: Any) -> Any:
return await self._request("/api/games/search", params=params or None)
async def get_maps(self) -> Any:
return await self._request("/api/maps")
async def get_squadron(self, squadron_name: str, **params: Any) -> Any:
return await self._request(f"/api/squadrons/{quote(str(squadron_name), safe='')}", params=params or None)
async def get_leaderboard_players(self, **params: Any) -> Any:
return await self._request("/api/leaderboard/players", params=params or None)
async def get_leaderboard_squadrons(self, **params: Any) -> Any:
return await self._request("/api/leaderboard/squadrons", params=params or None)
async def get_leaderboard_vehicles(self, **params: Any) -> Any:
return await self._request("/api/leaderboard/vehicles", params=params or None)
async def get_leaderboard_stats(self) -> Any:
return await self._request("/api/leaderboard/stats")
_default_api_client = SREBOTApiClient()
async def fetch_api_info() -> Any:
return await _default_api_client.get_info()
async def fetch_player(uid: str, **params: Any) -> Any:
return await _default_api_client.get_player(uid, **params)
async def fetch_player_games(uid: str, **params: Any) -> Any:
return await _default_api_client.get_player_games(uid, **params)
async def fetch_player_history(uid: str) -> Any:
return await _default_api_client.get_player_history(uid)
async def search_players(nickname: str) -> Any:
return await _default_api_client.search_players(nickname)
async def fetch_live(**params: Any) -> Any:
return await _default_api_client.get_live(**params)
async def fetch_match(session_id: str) -> Any:
return await _default_api_client.get_match(session_id)
async def fetch_match_replay(session_id: str) -> Any:
return await _default_api_client.get_match_replay(session_id)
async def search_games(**params: Any) -> Any:
return await _default_api_client.search_games(**params)
async def fetch_maps() -> Any:
return await _default_api_client.get_maps()
async def fetch_squadron(squadron_name: str, **params: Any) -> Any:
return await _default_api_client.get_squadron(squadron_name, **params)
async def fetch_leaderboard_players(**params: Any) -> Any:
return await _default_api_client.get_leaderboard_players(**params)
async def fetch_leaderboard_squadrons(**params: Any) -> Any:
return await _default_api_client.get_leaderboard_squadrons(**params)
async def fetch_leaderboard_vehicles(**params: Any) -> Any:
return await _default_api_client.get_leaderboard_vehicles(**params)
async def fetch_leaderboard_stats() -> Any:
return await _default_api_client.get_leaderboard_stats()
_EXTERNAL_OUTBOX_LOCK: asyncio.Lock | None = None
def _get_external_outbox_lock() -> asyncio.Lock:
global _EXTERNAL_OUTBOX_LOCK
if _EXTERNAL_OUTBOX_LOCK is None:
_EXTERNAL_OUTBOX_LOCK = asyncio.Lock()
return _EXTERNAL_OUTBOX_LOCK
async def _append_external_envelope(envelope: dict[str, Any]) -> None:
line = json.dumps(envelope, ensure_ascii=False, separators=(",", ":"))
async with _get_external_outbox_lock():
async with aiofiles.open(EXTERNAL_OUTBOX_PATH, "a", encoding="utf-8") as handle:
await handle.write(line + "\n")
logger.info(
"Bridge envelope queued",
extra={
"event_type": envelope.get("type"),
"outbox_path": str(EXTERNAL_OUTBOX_PATH),
},
)
async def publish_replay_batch(replays: list[dict[str, Any]]) -> None:
"""Queue a replay batch for websocket delivery by the external bridge."""
if not replays:
return
envelope = {
"type": "spectra.replay_batch",
"version": 1,
"source": "srebot",
"payload": {"replays": replays},
}
await _append_external_envelope(envelope)
async def publish_gob_payload(payload: dict[str, Any]) -> None:
"""Queue a GOB payload for websocket delivery by the external bridge."""
envelope = {
"type": "spectra.gob",
"version": 1,
"source": "srebot",
"payload": payload,
}
await _append_external_envelope(envelope)
async def publish_event(event_type: str, payload: dict[str, Any]) -> None:
"""Generic queue helper for future bridge events."""
envelope = {
"type": event_type,
"version": 1,
"source": "srebot",
"payload": payload,
}
await _append_external_envelope(envelope)