realtime.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. """Chatto realtime WebSocket client.
  2. Chatto exposes a binary-protobuf realtime channel at ``/api/realtime`` (see
  3. ``proto/chatto/realtime/v1/realtime.proto``). This module speaks that
  4. protocol: it opens the WebSocket, exchanges ``hello`` frames, subscribes to
  5. the caller's authorized live-event stream, and yields decoded events.
  6. Usage::
  7. async with await ChattoClient.login(...) as client:
  8. async for event in stream_events(client):
  9. print(event.kind, event.payload)
  10. Requires the ``chattolib[realtime]`` extra (which pulls in ``websockets`` and
  11. ``protobuf``).
  12. """
  13. from __future__ import annotations
  14. from collections.abc import AsyncIterator
  15. from dataclasses import dataclass
  16. from datetime import datetime
  17. from typing import TYPE_CHECKING, Any
  18. from chattolib import _pb # noqa: F401 — installs pb import path
  19. from chattolib.exceptions import ChattoError
  20. from chattolib.types import parse_datetime
  21. if TYPE_CHECKING:
  22. from chattolib.client import ChattoClient
  23. REALTIME_PATH = "/api/realtime"
  24. REALTIME_PROTOCOL_VERSION = 1
  25. class ChattoRealtimeError(ChattoError):
  26. """Server returned a protocol error over the realtime WebSocket."""
  27. def __init__(self, code: str, message: str, *, fatal: bool = False) -> None:
  28. self.code = code
  29. self.message = message
  30. self.fatal = fatal
  31. super().__init__(f"{code}: {message}")
  32. class ChattoRealtimeCloseError(ChattoError):
  33. """Server sent a close frame."""
  34. def __init__(
  35. self,
  36. code: str,
  37. message: str,
  38. *,
  39. reconnect: bool = False,
  40. retry_after_ms: int = 0,
  41. ) -> None:
  42. self.code = code
  43. self.message = message
  44. self.reconnect = reconnect
  45. self.retry_after_ms = retry_after_ms
  46. super().__init__(f"{code}: {message}")
  47. def realtime_url(base_url: str) -> str:
  48. """Convert an HTTP(S) base URL to the realtime WebSocket URL."""
  49. base = base_url.rstrip("/")
  50. if base.startswith("https://"):
  51. return "wss://" + base[len("https://") :] + REALTIME_PATH
  52. if base.startswith("http://"):
  53. return "ws://" + base[len("http://") :] + REALTIME_PATH
  54. return "wss://" + base + REALTIME_PATH
  55. @dataclass
  56. class ServerHello:
  57. """Server's response to the initial hello frame."""
  58. protocol_version: int
  59. server_version: str
  60. heartbeat_interval_seconds: int
  61. capabilities: list[str]
  62. @dataclass
  63. class RealtimeEvent:
  64. """One live event delivered over the realtime WebSocket.
  65. ``kind`` names the ``oneof event`` case set on the envelope
  66. (``message_posted``, ``reaction_added``, ``presence_changed``, …).
  67. ``payload`` is the concrete protobuf sub-message; access its fields
  68. directly (e.g. ``event.payload.room_id``). Callers that want to hydrate
  69. the referenced resource should follow the hydration hints documented on
  70. each event message in ``realtime.proto``.
  71. """
  72. id: str
  73. created_at: datetime | None
  74. actor_id: str | None
  75. kind: str
  76. payload: Any
  77. raw: Any # the full RealtimeEventEnvelope
  78. class RealtimeConnection:
  79. """Live realtime WebSocket session.
  80. Prefer :func:`stream_events` for the common case; use this class directly
  81. when you also need to send client pings, close cleanly, or inspect the
  82. negotiated :class:`ServerHello`.
  83. """
  84. def __init__(
  85. self,
  86. client: ChattoClient,
  87. *,
  88. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  89. ) -> None:
  90. self._client = client
  91. self._protocol_version = protocol_version
  92. self._ws: Any = None
  93. self._server_hello: ServerHello | None = None
  94. @property
  95. def server_hello(self) -> ServerHello | None:
  96. return self._server_hello
  97. async def __aenter__(self) -> RealtimeConnection:
  98. await self.connect()
  99. return self
  100. async def __aexit__(self, *exc: Any) -> None:
  101. await self.close()
  102. async def connect(self) -> None:
  103. try:
  104. import websockets
  105. except ImportError as exc: # pragma: no cover
  106. raise ChattoError(
  107. "The realtime channel requires the 'websockets' package. "
  108. "Install with `pip install chattolib[realtime]`."
  109. ) from exc
  110. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  111. url = realtime_url(self._client.base_url)
  112. headers: dict[str, str] = {}
  113. if self._client.session_cookie:
  114. headers["Cookie"] = f"chatto_session={self._client.session_cookie}"
  115. self._ws = await websockets.connect(url, additional_headers=headers)
  116. client_hello = realtime_pb2.RealtimeClientFrame()
  117. client_hello.hello.protocol_version = self._protocol_version
  118. if self._client.token:
  119. client_hello.hello.bearer_token = self._client.token
  120. await self._ws.send(client_hello.SerializeToString())
  121. first = await self._ws.recv()
  122. first_frame = realtime_pb2.RealtimeServerFrame()
  123. first_frame.ParseFromString(first)
  124. if first_frame.WhichOneof("frame") != "hello":
  125. _raise_for_control(first_frame)
  126. raise ChattoRealtimeError(
  127. "unexpected_frame",
  128. f"expected server hello, got {first_frame.WhichOneof('frame')!r}",
  129. )
  130. hello = first_frame.hello
  131. self._server_hello = ServerHello(
  132. protocol_version=hello.protocol_version,
  133. server_version=hello.server_version,
  134. heartbeat_interval_seconds=hello.heartbeat_interval_seconds,
  135. capabilities=list(hello.capabilities),
  136. )
  137. subscribe = realtime_pb2.RealtimeClientFrame()
  138. subscribe.subscribe_events.SetInParent()
  139. await self._ws.send(subscribe.SerializeToString())
  140. async def close(self) -> None:
  141. if self._ws is None:
  142. return
  143. try:
  144. await self._ws.close()
  145. finally:
  146. self._ws = None
  147. async def ping(self, nonce: str = "") -> None:
  148. """Send a client ping. The server replies with a matching pong."""
  149. if self._ws is None:
  150. raise ChattoError("realtime connection is closed")
  151. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  152. frame = realtime_pb2.RealtimeClientFrame()
  153. frame.ping.nonce = nonce
  154. await self._ws.send(frame.SerializeToString())
  155. async def events(self) -> AsyncIterator[RealtimeEvent]:
  156. """Yield decoded live events until the connection closes."""
  157. if self._ws is None:
  158. raise ChattoError("realtime connection is not open")
  159. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  160. async for raw in self._ws:
  161. if isinstance(raw, str):
  162. # The protocol is binary; a text frame indicates a protocol violation.
  163. raise ChattoRealtimeError(
  164. "unexpected_text_frame",
  165. "server sent a text frame; realtime protocol expects binary",
  166. )
  167. frame = realtime_pb2.RealtimeServerFrame()
  168. frame.ParseFromString(raw)
  169. case = frame.WhichOneof("frame")
  170. if case == "event":
  171. yield _wrap_event(frame.event)
  172. elif case in ("heartbeat", "pong", "subscribed"):
  173. continue
  174. elif case == "error":
  175. err = frame.error
  176. exc = ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
  177. if err.fatal:
  178. raise exc
  179. # Non-fatal errors are surfaced but the stream continues.
  180. # Callers can still receive them if desired; for now we log
  181. # by re-raising on fatal only.
  182. continue
  183. elif case == "close":
  184. close = frame.close
  185. raise ChattoRealtimeCloseError(
  186. close.code,
  187. close.message,
  188. reconnect=close.reconnect,
  189. retry_after_ms=close.retry_after_ms,
  190. )
  191. else:
  192. raise ChattoRealtimeError(
  193. "unexpected_frame",
  194. f"unknown server frame: {case!r}",
  195. )
  196. def _raise_for_control(frame: Any) -> None:
  197. """If ``frame`` is an error or close frame, translate it and raise."""
  198. case = frame.WhichOneof("frame")
  199. if case == "error":
  200. err = frame.error
  201. raise ChattoRealtimeError(err.code, err.message, fatal=err.fatal)
  202. if case == "close":
  203. close = frame.close
  204. raise ChattoRealtimeCloseError(
  205. close.code,
  206. close.message,
  207. reconnect=close.reconnect,
  208. retry_after_ms=close.retry_after_ms,
  209. )
  210. def _wrap_event(envelope: Any) -> RealtimeEvent:
  211. kind = envelope.WhichOneof("event") or ""
  212. payload = getattr(envelope, kind, None) if kind else None
  213. created_at = None
  214. if envelope.HasField("created_at"):
  215. created_at = parse_datetime(envelope.created_at.ToJsonString())
  216. actor_id: str | None = None
  217. if envelope.HasField("actor_id"):
  218. actor_id = envelope.actor_id
  219. return RealtimeEvent(
  220. id=envelope.id,
  221. created_at=created_at,
  222. actor_id=actor_id,
  223. kind=kind,
  224. payload=payload,
  225. raw=envelope,
  226. )
  227. async def stream_events(
  228. client: ChattoClient,
  229. *,
  230. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  231. ) -> AsyncIterator[RealtimeEvent]:
  232. """Open a realtime connection and yield events until the server closes.
  233. Raises :class:`ChattoRealtimeCloseError` when the server sends a close frame,
  234. :class:`ChattoRealtimeError` on fatal protocol errors, or
  235. :class:`ChattoConnectError` if the initial HTTP handshake fails.
  236. """
  237. conn = RealtimeConnection(client, protocol_version=protocol_version)
  238. try:
  239. await conn.connect()
  240. async for event in conn.events():
  241. yield event
  242. finally:
  243. await conn.close()
  244. __all__ = [
  245. "REALTIME_PATH",
  246. "REALTIME_PROTOCOL_VERSION",
  247. "ChattoRealtimeCloseError",
  248. "ChattoRealtimeError",
  249. "RealtimeConnection",
  250. "RealtimeEvent",
  251. "ServerHello",
  252. "realtime_url",
  253. "stream_events",
  254. ]