| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- """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"]
|