""" This file is part of the SplendidBear Websites' projects. Copyright (c) 2026 @ www.splendidbear.org For the full copyright and license information, please view the LICENSE file that was distributed with this source code. """ from __future__ import annotations import json import logging import threading import time from collections.abc import Callable from typing import Any import requests from gi.repository import GLib from mineseeker import config from mineseeker.api import client from mineseeker.constants import SSE_RECONNECT_INITIAL, SSE_RECONNECT_MAX log = logging.getLogger(__name__) class SseListener: """ Opens a Mercure SSE connection in a daemon thread and dispatches parsed JSON messages back to the GTK main thread via GLib.idle_add(). Message routing mirrors useServerCommunication.jsx handleMercureMessage(): payload.type == "challenge" → on_challenge(payload) payload.type == "challenge-response" → on_challenge_response(payload) payload.type == "heartbeat" → on_heartbeat(payload) "data" key present → on_topic(payload) "msg" key present → on_unsubscribe(payload) (none of the above) → on_subscribe(payload) """ def __init__( self, game_assoc: str, mercure_jwt: str, *, on_subscribe: Callable[[dict], Any] | None = None, on_unsubscribe: Callable[[dict], Any] | None = None, on_topic: Callable[[dict], Any] | None = None, on_challenge: Callable[[dict], Any] | None = None, on_challenge_response: Callable[[dict], Any] | None = None, on_heartbeat: Callable[[dict], Any] | None = None, ) -> None: self._game_assoc = game_assoc self._mercure_jwt = mercure_jwt self._handlers = { "subscribe": on_subscribe, "unsubscribe": on_unsubscribe, "topic": on_topic, "challenge": on_challenge, "challenge-response": on_challenge_response, "heartbeat": on_heartbeat, } self._stop_event = threading.Event() self._thread: threading.Thread | None = None # ------------------------------------------------------------------ # Public control # ------------------------------------------------------------------ def start(self) -> None: """Start the background SSE listener thread.""" self._stop_event.clear() self._thread = threading.Thread( target=self._run, daemon=True, name="sse-listener" ) self._thread.start() def stop(self) -> None: """Signal the background thread to stop.""" self._stop_event.set() # ------------------------------------------------------------------ # Background thread # ------------------------------------------------------------------ def _build_url(self) -> str: topic = f"mineseeker/channel/{self._game_assoc}" return f"{config.MERCURE_URL}?topic={topic}" def _run(self) -> None: backoff = SSE_RECONNECT_INITIAL while not self._stop_event.is_set(): try: self._stream() backoff = SSE_RECONNECT_INITIAL # reset on clean disconnect except Exception as exc: if self._stop_event.is_set(): break log.warning("SSE connection lost (%s), reconnecting in %.1fs", exc, backoff) time.sleep(backoff) backoff = min(backoff * 2, SSE_RECONNECT_MAX) def _stream(self) -> None: """Open the SSE stream and process events until stopped or error.""" url = self._build_url() headers = { "Authorization": f"Bearer {self._mercure_jwt}", "Accept": "text/event-stream", "Cache-Control": "no-cache", } # Use the requests session from client.py so cookies are included resp = client.get_session().get( url, headers=headers, stream=True, timeout=(10, None) ) resp.raise_for_status() # Parse the raw SSE stream manually (sseclient-py would also work # but avoids an extra dependency on GLib-aware loops) data_lines: list[str] = [] for raw_line in resp.iter_lines(decode_unicode=True): if self._stop_event.is_set(): break if raw_line.startswith("data:"): data_lines.append(raw_line[5:].lstrip(" ")) elif raw_line == "" and data_lines: # Empty line signals end of event — dispatch it payload_str = "\n".join(data_lines) data_lines = [] try: payload = json.loads(payload_str) GLib.idle_add(self._dispatch, payload) except json.JSONDecodeError: log.debug("Non-JSON SSE data ignored: %s", payload_str) def _dispatch(self, payload: dict) -> bool: """Called on the GTK main thread via GLib.idle_add.""" msg_type = payload.get("type") if msg_type == "challenge": self._call("challenge", payload) elif msg_type == "challenge-response": self._call("challenge-response", payload) elif msg_type == "heartbeat": self._call("heartbeat", payload) elif "data" in payload: self._call("topic", payload) elif "msg" in payload: self._call("unsubscribe", payload) else: self._call("subscribe", payload) return GLib.SOURCE_REMOVE # run once only def _call(self, key: str, payload: dict) -> None: handler = self._handlers.get(key) if handler is not None: try: handler(payload) except Exception: log.exception("Error in SSE handler '%s'", key)