realtime.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 the
  4. protocol-4 variant of that channel: it opens the WebSocket, sends a single
  5. :class:`RealtimeSubscribe` handshake message, then yields the decoded
  6. server frames — live events, the initial snapshot, recovery boundaries,
  7. heartbeats, and close frames.
  8. Usage::
  9. async with ChattoClient(token="cht_...") as client:
  10. async for frame in stream_events(client):
  11. if isinstance(frame, RealtimeEvent):
  12. print(frame.kind, frame.payload)
  13. Requires the ``chattolib[realtime]`` extra (which pulls in ``websockets`` and
  14. ``protobuf``).
  15. """
  16. from __future__ import annotations
  17. from collections.abc import AsyncIterator
  18. from dataclasses import dataclass, field
  19. from datetime import datetime
  20. from typing import TYPE_CHECKING, Any
  21. from chattolib import _pb # noqa: F401 — installs pb import path
  22. from chattolib.exceptions import ChattoError
  23. from chattolib.types import parse_datetime
  24. if TYPE_CHECKING:
  25. from chattolib.client import ChattoClient
  26. REALTIME_PATH = "/api/realtime"
  27. REALTIME_PROTOCOL_VERSION = 4
  28. # RealtimeInitialState values (see realtime.proto).
  29. INITIAL_STATE_LIVE_ONLY = 1
  30. INITIAL_STATE_SNAPSHOT = 2
  31. class ChattoRealtimeError(ChattoError):
  32. """Server returned a protocol error over the realtime WebSocket."""
  33. def __init__(self, code: str, message: str, *, fatal: bool = False) -> None:
  34. self.code = code
  35. self.message = message
  36. self.fatal = fatal
  37. super().__init__(f"{code}: {message}")
  38. class ChattoRealtimeCloseError(ChattoError):
  39. """Server sent a close frame.
  40. ``code`` is the short name of the :class:`RealtimeCloseCode` enum value
  41. (e.g. ``"SESSION_TERMINATED"``), ``reconnect`` mirrors the server's
  42. reconnect guidance, and ``retry_after_ms`` is the suggested delay in
  43. milliseconds before reconnecting.
  44. """
  45. def __init__(
  46. self,
  47. code: str,
  48. message: str,
  49. *,
  50. reconnect: bool = False,
  51. retry_after_ms: int = 0,
  52. ) -> None:
  53. self.code = code
  54. self.message = message
  55. self.reconnect = reconnect
  56. self.retry_after_ms = retry_after_ms
  57. super().__init__(f"{code}: {message}")
  58. def realtime_url(base_url: str) -> str:
  59. """Convert an HTTP(S) base URL to the realtime WebSocket URL."""
  60. base = base_url.rstrip("/")
  61. if base.startswith("https://"):
  62. return "wss://" + base[len("https://") :] + REALTIME_PATH
  63. if base.startswith("http://"):
  64. return "ws://" + base[len("http://") :] + REALTIME_PATH
  65. return "wss://" + base + REALTIME_PATH
  66. @dataclass
  67. class RealtimeEvent:
  68. """One live event delivered over the realtime WebSocket.
  69. ``kind`` names the ``oneof event`` case set on the envelope
  70. (``message_posted``, ``presence_changed``, ``reaction_added``, …).
  71. ``payload`` is the concrete protobuf sub-message; access its fields
  72. directly (e.g. ``event.payload.room_id``). In protocol 4 most payloads are
  73. thin, caller-scoped hints (identifiers rather than full resources), so
  74. callers that need the full resource should hydrate it through the
  75. corresponding ConnectRPC with the event's ``cursor`` as the boundary.
  76. ``cursor`` is the opaque resume cursor safe to retain after this complete
  77. event has been applied; it is ``None`` when the event cannot be replayed
  78. (its state is recoverable from a snapshot or a ConnectRPC read).
  79. """
  80. id: str
  81. created_at: datetime | None
  82. actor_id: str | None
  83. cursor: str | None
  84. kind: str
  85. payload: Any
  86. raw: Any # the full RealtimeEvent
  87. @dataclass
  88. class RealtimeSnapshot:
  89. """One exact authorized snapshot of current server content.
  90. The server sends at most one snapshot (when the subscription's
  91. ``initial_state`` requested one) before the recovery-to-live boundary.
  92. List-valued fields replace the complete local family; large paginated
  93. resources (such as message history) are not part of the snapshot and
  94. remain available through ConnectRPC.
  95. """
  96. server: Any # chatto.api.v1.ServerPublicProfile, or None if absent
  97. rooms: list[Any] = field(default_factory=list)
  98. room_groups: list[Any] = field(default_factory=list)
  99. users: list[Any] = field(default_factory=list)
  100. active_calls: list[Any] = field(default_factory=list)
  101. raw: Any = None # the full RealtimeSnapshot
  102. def _close_code_name(code: int) -> str:
  103. """Map a ``RealtimeCloseCode`` enum value to its short name."""
  104. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  105. try:
  106. raw = realtime_pb2.RealtimeCloseCode.Name(code)
  107. except ValueError:
  108. return f"UNKNOWN_{code}"
  109. name: str = raw
  110. prefix = "REALTIME_CLOSE_CODE_"
  111. return name[len(prefix) :] if name.startswith(prefix) else name
  112. def _duration_to_ms(duration: Any) -> int:
  113. if duration is None:
  114. return 0
  115. return int(duration.seconds) * 1000 + int(duration.nanos) // 1_000_000
  116. class RealtimeConnection:
  117. """Live realtime WebSocket session.
  118. Prefer :func:`stream_events` for the common case; use this class directly
  119. when you need to inspect the negotiated subscription state.
  120. """
  121. def __init__(
  122. self,
  123. client: ChattoClient,
  124. *,
  125. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  126. resume_cursor: str | None = None,
  127. initial_state: int | None = None,
  128. ) -> None:
  129. self._client = client
  130. self._protocol_version = protocol_version
  131. self._resume_cursor = resume_cursor
  132. # When no resume cursor is supplied the server starts from the current
  133. # boundary; the caller may opt into an initial snapshot instead.
  134. self._initial_state = INITIAL_STATE_LIVE_ONLY if initial_state is None else initial_state
  135. self._ws: Any = None
  136. self.last_cursor: str | None = None
  137. async def __aenter__(self) -> RealtimeConnection:
  138. await self.connect()
  139. return self
  140. async def __aexit__(self, *exc: Any) -> None:
  141. await self.close()
  142. async def connect(self) -> None:
  143. try:
  144. import websockets
  145. except ImportError as exc: # pragma: no cover
  146. raise ChattoError(
  147. "The realtime channel requires the 'websockets' package. "
  148. "Install with `pip install chattolib[realtime]`."
  149. ) from exc
  150. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  151. url = realtime_url(self._client.base_url)
  152. headers: dict[str, str] = {}
  153. if self._client.session_cookie:
  154. headers["Cookie"] = f"chatto_session={self._client.session_cookie}"
  155. self._ws = await websockets.connect(url, additional_headers=headers)
  156. subscribe = realtime_pb2.RealtimeSubscribe()
  157. subscribe.protocol_version = self._protocol_version
  158. if self._client.token:
  159. subscribe.bearer_token = self._client.token
  160. if self._resume_cursor:
  161. subscribe.resume_cursor = self._resume_cursor
  162. subscribe.initial_state = self._initial_state
  163. await self._ws.send(subscribe.SerializeToString())
  164. async def close(self) -> None:
  165. if self._ws is None:
  166. return
  167. try:
  168. await self._ws.close()
  169. finally:
  170. self._ws = None
  171. async def events(
  172. self,
  173. ) -> AsyncIterator[RealtimeEvent | RealtimeSnapshot]:
  174. """Yield decoded live events and snapshots until the connection
  175. closes or a close frame arrives."""
  176. if self._ws is None:
  177. raise ChattoError("realtime connection is not open")
  178. from chattolib._pb.chatto.realtime.v1 import realtime_pb2
  179. async for raw in self._ws:
  180. if isinstance(raw, str):
  181. # The protocol is binary; a text frame indicates a protocol violation.
  182. raise ChattoRealtimeError(
  183. "unexpected_text_frame",
  184. "server sent a text frame; realtime protocol expects binary",
  185. )
  186. frame = realtime_pb2.RealtimeServerFrame()
  187. frame.ParseFromString(raw)
  188. case = frame.WhichOneof("frame")
  189. if case == "event":
  190. event = _wrap_event(frame.event)
  191. if event.cursor:
  192. self.last_cursor = event.cursor
  193. yield event
  194. elif case == "snapshot":
  195. yield _wrap_snapshot(frame.snapshot)
  196. elif case == "caught_up":
  197. if frame.caught_up.cursor:
  198. self.last_cursor = frame.caught_up.cursor
  199. continue
  200. elif case == "heartbeat":
  201. if frame.heartbeat.HasField("cursor") and frame.heartbeat.cursor:
  202. self.last_cursor = frame.heartbeat.cursor
  203. continue
  204. elif case == "close":
  205. close = frame.close
  206. raise ChattoRealtimeCloseError(
  207. _close_code_name(int(close.code)),
  208. close.message,
  209. reconnect=close.reconnect,
  210. retry_after_ms=_duration_to_ms(close.retry_after),
  211. )
  212. else:
  213. raise ChattoRealtimeError(
  214. "unexpected_frame",
  215. f"unknown server frame: {case!r}",
  216. )
  217. def _wrap_event(envelope: Any) -> RealtimeEvent:
  218. kind = envelope.WhichOneof("event") or ""
  219. payload = getattr(envelope, kind, None) if kind else None
  220. created_at = None
  221. if envelope.HasField("created_at"):
  222. created_at = parse_datetime(envelope.created_at.ToJsonString())
  223. actor_id: str | None = None
  224. if envelope.HasField("actor_id"):
  225. actor_id = envelope.actor_id
  226. cursor: str | None = None
  227. if envelope.HasField("cursor"):
  228. cursor = envelope.cursor
  229. return RealtimeEvent(
  230. id=envelope.id,
  231. created_at=created_at,
  232. actor_id=actor_id,
  233. cursor=cursor,
  234. kind=kind,
  235. payload=payload,
  236. raw=envelope,
  237. )
  238. def _wrap_snapshot(frame: Any) -> RealtimeSnapshot:
  239. return RealtimeSnapshot(
  240. server=frame.server if frame.HasField("server") else None,
  241. rooms=list(frame.rooms),
  242. room_groups=list(frame.room_groups),
  243. users=list(frame.users),
  244. active_calls=list(frame.active_calls),
  245. raw=frame,
  246. )
  247. async def stream_events(
  248. client: ChattoClient,
  249. *,
  250. protocol_version: int = REALTIME_PROTOCOL_VERSION,
  251. resume_cursor: str | None = None,
  252. initial_state: int | None = None,
  253. ) -> AsyncIterator[RealtimeEvent | RealtimeSnapshot]:
  254. """Open a realtime connection and yield frames until the server closes.
  255. ``resume_cursor`` resumes from a previously received event/caught-up
  256. cursor. ``initial_state`` selects the fallback behavior when the cursor
  257. cannot resume: :data:`INITIAL_STATE_LIVE_ONLY` (default, start at the
  258. current boundary without current resources) or
  259. :data:`INITIAL_STATE_SNAPSHOT` (send one authorized snapshot first).
  260. Raises :class:`ChattoRealtimeCloseError` when the server sends a close
  261. frame, :class:`ChattoRealtimeError` on protocol errors, or
  262. :class:`ChattoConnectError` if the initial WebSocket handshake fails.
  263. """
  264. conn = RealtimeConnection(
  265. client,
  266. protocol_version=protocol_version,
  267. resume_cursor=resume_cursor,
  268. initial_state=initial_state,
  269. )
  270. try:
  271. await conn.connect()
  272. async for event in conn.events():
  273. yield event
  274. finally:
  275. await conn.close()
  276. __all__ = [
  277. "INITIAL_STATE_LIVE_ONLY",
  278. "INITIAL_STATE_SNAPSHOT",
  279. "REALTIME_PATH",
  280. "REALTIME_PROTOCOL_VERSION",
  281. "ChattoRealtimeCloseError",
  282. "ChattoRealtimeError",
  283. "RealtimeConnection",
  284. "RealtimeEvent",
  285. "RealtimeSnapshot",
  286. "realtime_url",
  287. "stream_events",
  288. ]