realtime.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Synchronous realtime WebSocket client.
  2. Hand-written sync twin of :mod:`chattolib.realtime` (the async module remains
  3. the reference). It reuses that module's shared dataclasses, helpers and
  4. exceptions verbatim and only reimplements the WebSocket I/O on top of
  5. ``websockets.sync``. Usage::
  6. with SyncChattoClient.login(...) as client:
  7. for event in stream_events(client):
  8. print(event.kind, event.payload)
  9. Requires the ``chattolib[realtime]`` extra (``websockets``).
  10. """
  11. from __future__ import annotations
  12. from collections.abc import Iterator
  13. from typing import TYPE_CHECKING, Any
  14. from chattolib import _pb # noqa: F401 — installs pb import path
  15. from chattolib.exceptions import ChattoError
  16. from chattolib.realtime import (
  17. REALTIME_PROTOCOL_VERSION,
  18. ChattoRealtimeCloseError,
  19. ChattoRealtimeError,
  20. RealtimeEvent,
  21. ServerHello,
  22. _raise_for_control,
  23. _wrap_event,
  24. realtime_url,
  25. )
  26. if TYPE_CHECKING:
  27. from chattolib._sync.client import SyncChattoClient
  28. class SyncRealtimeConnection:
  29. """Synchronous twin of :class:`chattolib.realtime.RealtimeConnection`."""
  30. def __init__(
  31. self,
  32. client: SyncChattoClient,
  33. *,
  34. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  35. ) -> None:
  36. self._client = client
  37. self._protocol_version = protocol_version
  38. self._ws: Any = None
  39. self._server_hello: ServerHello | None = None
  40. @property
  41. def server_hello(self) -> ServerHello | None:
  42. return self._server_hello
  43. def __enter__(self) -> SyncRealtimeConnection:
  44. self.connect()
  45. return self
  46. def __exit__(self, *exc: Any) -> None:
  47. self.close()
  48. def connect(self) -> None:
  49. try:
  50. from websockets.sync.client import connect as ws_connect
  51. except ImportError as exc: # pragma: no cover
  52. raise ChattoError(
  53. "The realtime channel requires the 'websockets' package. "
  54. "Install with `pip install chattolib[realtime]`."
  55. ) from exc
  56. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  57. url = realtime_url(self._client.base_url)
  58. headers: dict[str, str] = {}
  59. if self._client.session_cookie:
  60. headers["Cookie"] = f"chatto_session={self._client.session_cookie}"
  61. self._ws = ws_connect(url, additional_headers=headers)
  62. client_hello = realtime_pb2.RealtimeClientFrame()
  63. client_hello.hello.protocol_version = self._protocol_version
  64. if self._client.token:
  65. client_hello.hello.bearer_token = self._client.token
  66. self._ws.send(client_hello.SerializeToString())
  67. first = self._ws.recv()
  68. first_frame = realtime_pb2.RealtimeServerFrame()
  69. first_frame.ParseFromString(first)
  70. if first_frame.WhichOneof("frame") != "hello":
  71. _raise_for_control(first_frame)
  72. raise ChattoRealtimeError(
  73. "unexpected_frame",
  74. f"expected server hello, got {first_frame.WhichOneof('frame')!r}",
  75. )
  76. hello = first_frame.hello
  77. self._server_hello = ServerHello(
  78. protocol_version=hello.protocol_version,
  79. server_version=hello.server_version,
  80. heartbeat_interval_seconds=hello.heartbeat_interval_seconds,
  81. capabilities=list(hello.capabilities),
  82. )
  83. subscribe = realtime_pb2.RealtimeClientFrame()
  84. subscribe.subscribe_events.SetInParent()
  85. self._ws.send(subscribe.SerializeToString())
  86. def close(self) -> None:
  87. if self._ws is None:
  88. return
  89. try:
  90. self._ws.close()
  91. finally:
  92. self._ws = None
  93. def ping(self, nonce: str = "") -> None:
  94. """Send a client ping. The server replies with a matching pong."""
  95. if self._ws is None:
  96. raise ChattoError("realtime connection is closed")
  97. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  98. frame = realtime_pb2.RealtimeClientFrame()
  99. frame.ping.nonce = nonce
  100. self._ws.send(frame.SerializeToString())
  101. def events(self) -> Iterator[RealtimeEvent]:
  102. """Yield decoded live events until the connection closes."""
  103. if self._ws is None:
  104. raise ChattoError("realtime connection is not open")
  105. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  106. for raw in self._ws:
  107. if isinstance(raw, str):
  108. raise ChattoRealtimeError(
  109. "unexpected_text_frame",
  110. "server sent a text frame; realtime protocol expects binary",
  111. )
  112. frame = realtime_pb2.RealtimeServerFrame()
  113. frame.ParseFromString(raw)
  114. case = frame.WhichOneof("frame")
  115. if case == "event":
  116. yield _wrap_event(frame.event)
  117. elif case in ("heartbeat", "pong", "subscribed"):
  118. continue
  119. elif case == "error":
  120. err = frame.error
  121. exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
  122. if err.fatal:
  123. raise exc
  124. continue
  125. elif case == "close":
  126. close = frame.close
  127. raise ChattoRealtimeCloseError(
  128. close.code,
  129. close.message,
  130. reconnect=close.reconnect,
  131. retry_after_ms=close.retry_after_ms,
  132. )
  133. else:
  134. raise ChattoRealtimeError(
  135. "unexpected_frame",
  136. f"unknown server frame: {case!r}",
  137. )
  138. def stream_events(
  139. client: SyncChattoClient,
  140. *,
  141. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  142. ) -> Iterator[RealtimeEvent]:
  143. """Open a realtime connection and yield events until the server closes."""
  144. conn = SyncRealtimeConnection(client, protocol_version=protocol_version)
  145. try:
  146. conn.connect()
  147. yield from conn.events()
  148. finally:
  149. conn.close()
  150. __all__ = ["SyncRealtimeConnection", "stream_events"]