"""Synchronous realtime WebSocket client. Hand-written sync twin of :mod:`chattolib.realtime` (the async module remains the reference). It reuses that module's shared dataclasses, helpers and exceptions verbatim and only reimplements the WebSocket I/O on top of ``websockets.sync``. Usage:: with SyncChattoClient.login(...) as client: for event in stream_events(client): print(event.kind, event.payload) Requires the ``chattolib[realtime]`` extra (``websockets``). """ from __future__ import annotations from collections.abc import Iterator from typing import TYPE_CHECKING, Any from chattolib import _pb # noqa: F401 — installs pb import path from chattolib.exceptions import ChattoError from chattolib.realtime import ( REALTIME_PROTOCOL_VERSION, ChattoRealtimeCloseError, ChattoRealtimeError, RealtimeEvent, ServerHello, _raise_for_control, _wrap_event, realtime_url, ) if TYPE_CHECKING: from chattolib._sync.client import SyncChattoClient class SyncRealtimeConnection: """Synchronous twin of :class:`chattolib.realtime.RealtimeConnection`.""" def __init__( self, client: SyncChattoClient, *, protocol_version: int = REALTIME_PROTOCOL_VERSION, ) -> None: self._client = client self._protocol_version = protocol_version self._ws: Any = None self._server_hello: ServerHello | None = None @property def server_hello(self) -> ServerHello | None: return self._server_hello def __enter__(self) -> SyncRealtimeConnection: self.connect() return self def __exit__(self, *exc: Any) -> None: self.close() def connect(self) -> None: try: from websockets.sync.client import connect as ws_connect except ImportError as exc: # pragma: no cover raise ChattoError( "The realtime channel requires the 'websockets' package. " "Install with `pip install chattolib[realtime]`." ) from exc from chattolib._pb.chatto.realtime.v1 import realtime_pb2 url = realtime_url(self._client.base_url) headers: dict[str, str] = {} if self._client.session_cookie: headers["Cookie"] = f"chatto_session={self._client.session_cookie}" self._ws = ws_connect(url, additional_headers=headers) client_hello = realtime_pb2.RealtimeClientFrame() client_hello.hello.protocol_version = self._protocol_version if self._client.token: client_hello.hello.bearer_token = self._client.token self._ws.send(client_hello.SerializeToString()) first = self._ws.recv() first_frame = realtime_pb2.RealtimeServerFrame() first_frame.ParseFromString(first) if first_frame.WhichOneof("frame") != "hello": _raise_for_control(first_frame) raise ChattoRealtimeError( "unexpected_frame", f"expected server hello, got {first_frame.WhichOneof('frame')!r}", ) hello = first_frame.hello self._server_hello = ServerHello( protocol_version=hello.protocol_version, server_version=hello.server_version, heartbeat_interval_seconds=hello.heartbeat_interval_seconds, capabilities=list(hello.capabilities), ) subscribe = realtime_pb2.RealtimeClientFrame() subscribe.subscribe_events.SetInParent() self._ws.send(subscribe.SerializeToString()) def close(self) -> None: if self._ws is None: return try: self._ws.close() finally: self._ws = None def ping(self, nonce: str = "") -> None: """Send a client ping. The server replies with a matching pong.""" if self._ws is None: raise ChattoError("realtime connection is closed") from chattolib._pb.chatto.realtime.v1 import realtime_pb2 frame = realtime_pb2.RealtimeClientFrame() frame.ping.nonce = nonce self._ws.send(frame.SerializeToString()) def events(self) -> Iterator[RealtimeEvent]: """Yield decoded live events until the connection closes.""" if self._ws is None: raise ChattoError("realtime connection is not open") from chattolib._pb.chatto.realtime.v1 import realtime_pb2 for raw in self._ws: if isinstance(raw, str): raise ChattoRealtimeError( "unexpected_text_frame", "server sent a text frame; realtime protocol expects binary", ) frame = realtime_pb2.RealtimeServerFrame() frame.ParseFromString(raw) case = frame.WhichOneof("frame") if case == "event": yield _wrap_event(frame.event) elif case in ("heartbeat", "pong", "subscribed"): continue elif case == "error": err = frame.error exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal) if err.fatal: raise exc continue elif case == "close": close = frame.close raise ChattoRealtimeCloseError( close.code, close.message, reconnect=close.reconnect, retry_after_ms=close.retry_after_ms, ) else: raise ChattoRealtimeError( "unexpected_frame", f"unknown server frame: {case!r}", ) def stream_events( client: SyncChattoClient, *, protocol_version: int = REALTIME_PROTOCOL_VERSION, ) -> Iterator[RealtimeEvent]: """Open a realtime connection and yield events until the server closes.""" conn = SyncRealtimeConnection(client, protocol_version=protocol_version) try: conn.connect() yield from conn.events() finally: conn.close() __all__ = ["SyncRealtimeConnection", "stream_events"]