weflow-sdk 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. weflow_sdk/LICENSE +21 -0
  2. weflow_sdk/__init__.py +37 -0
  3. weflow_sdk/client.py +949 -0
  4. weflow_sdk/generated/spec.json +1782 -0
  5. weflow_sdk/generated/weflow_sdk/__init__.py +135 -0
  6. weflow_sdk/generated/weflow_sdk/api/__init__.py +4 -0
  7. weflow_sdk/generated/weflow_sdk/api/default_api.py +5224 -0
  8. weflow_sdk/generated/weflow_sdk/api_client.py +836 -0
  9. weflow_sdk/generated/weflow_sdk/api_response.py +21 -0
  10. weflow_sdk/generated/weflow_sdk/configuration.py +595 -0
  11. weflow_sdk/generated/weflow_sdk/exceptions.py +218 -0
  12. weflow_sdk/generated/weflow_sdk/models/__init__.py +59 -0
  13. weflow_sdk/generated/weflow_sdk/models/account_conflict.py +95 -0
  14. weflow_sdk/generated/weflow_sdk/models/account_deregistered.py +100 -0
  15. weflow_sdk/generated/weflow_sdk/models/account_not_registered.py +97 -0
  16. weflow_sdk/generated/weflow_sdk/models/account_phase.py +37 -0
  17. weflow_sdk/generated/weflow_sdk/models/account_registered.py +95 -0
  18. weflow_sdk/generated/weflow_sdk/models/account_state_view.py +101 -0
  19. weflow_sdk/generated/weflow_sdk/models/account_status.py +37 -0
  20. weflow_sdk/generated/weflow_sdk/models/account_wxid_mismatch.py +102 -0
  21. weflow_sdk/generated/weflow_sdk/models/accounts_list.py +95 -0
  22. weflow_sdk/generated/weflow_sdk/models/chatlab_header.py +90 -0
  23. weflow_sdk/generated/weflow_sdk/models/chatlab_member.py +92 -0
  24. weflow_sdk/generated/weflow_sdk/models/chatlab_message.py +111 -0
  25. weflow_sdk/generated/weflow_sdk/models/chatlab_messages.py +125 -0
  26. weflow_sdk/generated/weflow_sdk/models/chatlab_meta.py +94 -0
  27. weflow_sdk/generated/weflow_sdk/models/contact.py +98 -0
  28. weflow_sdk/generated/weflow_sdk/models/contacts.py +102 -0
  29. weflow_sdk/generated/weflow_sdk/models/delete_api_v1_accounts_wxid200_response.py +149 -0
  30. weflow_sdk/generated/weflow_sdk/models/event_media.py +102 -0
  31. weflow_sdk/generated/weflow_sdk/models/event_new.py +111 -0
  32. weflow_sdk/generated/weflow_sdk/models/event_revoke.py +105 -0
  33. weflow_sdk/generated/weflow_sdk/models/event_sync.py +98 -0
  34. weflow_sdk/generated/weflow_sdk/models/group_member.py +104 -0
  35. weflow_sdk/generated/weflow_sdk/models/group_members.py +104 -0
  36. weflow_sdk/generated/weflow_sdk/models/health.py +91 -0
  37. weflow_sdk/generated/weflow_sdk/models/media_brief.py +95 -0
  38. weflow_sdk/generated/weflow_sdk/models/media_envelope.py +91 -0
  39. weflow_sdk/generated/weflow_sdk/models/media_object.py +123 -0
  40. weflow_sdk/generated/weflow_sdk/models/message_native.py +134 -0
  41. weflow_sdk/generated/weflow_sdk/models/messages_native.py +108 -0
  42. weflow_sdk/generated/weflow_sdk/models/notification_frame.py +99 -0
  43. weflow_sdk/generated/weflow_sdk/models/page.py +93 -0
  44. weflow_sdk/generated/weflow_sdk/models/post_api_v1_accounts200_response.py +135 -0
  45. weflow_sdk/generated/weflow_sdk/models/pull_envelope.py +120 -0
  46. weflow_sdk/generated/weflow_sdk/models/pull_message.py +118 -0
  47. weflow_sdk/generated/weflow_sdk/models/pull_sync.py +93 -0
  48. weflow_sdk/generated/weflow_sdk/models/quote.py +94 -0
  49. weflow_sdk/generated/weflow_sdk/models/session_chatlab.py +104 -0
  50. weflow_sdk/generated/weflow_sdk/models/session_native.py +106 -0
  51. weflow_sdk/generated/weflow_sdk/models/sessions_chatlab.py +102 -0
  52. weflow_sdk/generated/weflow_sdk/models/sessions_native.py +98 -0
  53. weflow_sdk/generated/weflow_sdk/models/sync_frame.py +98 -0
  54. weflow_sdk/generated/weflow_sdk/models/sync_result.py +91 -0
  55. weflow_sdk/generated/weflow_sdk/models/watermark_entry.py +92 -0
  56. weflow_sdk/generated/weflow_sdk/models/watermark_value.py +90 -0
  57. weflow_sdk/generated/weflow_sdk/py.typed +0 -0
  58. weflow_sdk/generated/weflow_sdk/rest.py +201 -0
  59. weflow_sdk/py.typed +0 -0
  60. weflow_sdk-0.9.0.dist-info/METADATA +59 -0
  61. weflow_sdk-0.9.0.dist-info/RECORD +63 -0
  62. weflow_sdk-0.9.0.dist-info/WHEEL +4 -0
  63. weflow_sdk-0.9.0.dist-info/licenses/LICENSE +21 -0
weflow_sdk/client.py ADDED
@@ -0,0 +1,949 @@
1
+ """Handwritten behavior layer over :mod:`weflow_sdk.generated`.
2
+
3
+ The generated face covers shapes: request URLs, response models, error
4
+ decoding. It cannot cover behavior - pagination loops, reconnection,
5
+ readiness polling - and its operations carry no query parameters (the
6
+ description currently declares only responses), so every request here
7
+ builds its own query string and reuses the generated models purely for
8
+ deserialization. The transport is ``httpx.AsyncClient``; the generated
9
+ ``api_client`` stays in the tree as the typed request face but the
10
+ behavior layer does not route through it.
11
+
12
+ Mirrors ``clients/rust/src/client.rs`` method by method, including the
13
+ failure semantics: 503 while indexing is *waiting*, not an error.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json as _json
20
+ import logging
21
+ import urllib.parse
22
+ from collections.abc import AsyncIterator, Callable
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ import httpx
27
+ from pydantic import ValidationError
28
+
29
+ from .generated.weflow_sdk import models as gen
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+ # Published connect budget (seconds) for every request the SDK makes: a server
34
+ # that is not there must fail fast rather than hang a caller.
35
+ CONNECT_TIMEOUT = 5.0
36
+
37
+ # Published read budget for ordinary JSON requests, charged per read operation.
38
+ READ_TIMEOUT = 30.0
39
+
40
+ # Stall budget for the requests whose cost is set by the data or by server-side
41
+ # work (a full-roster message count, a media body, an export trigger, a sync
42
+ # pass, the SSE stream). httpx charges it per read operation and resets it on
43
+ # progress, so a 500 MB download or a minutes-long first pull never trips it *while
44
+ # advancing* - but a connection that goes silent (peer killed without a FIN, a NAT
45
+ # that dropped the mapping, a laptop resuming from sleep) surfaces instead of
46
+ # hanging the caller forever. 90s is about four times the server keep-alive
47
+ # interval (25s): one missed ping plus a scheduling hiccup stays healthy, a black
48
+ # hole does not.
49
+ STALL_TIMEOUT = 90.0
50
+
51
+ # 200-with-refusal vocabulary across the two servers: the weflow server
52
+ # currently emits only account_conflict, the qqflow sibling adds
53
+ # invalid_key / invalid_db_path / unknown_qq. One shared set keeps the two
54
+ # SDKs symmetric against future server-side additions.
55
+ _REFUSAL_STATES = frozenset({
56
+ "account_conflict",
57
+ "invalid_key",
58
+ "invalid_db_path",
59
+ "unknown_qq",
60
+ })
61
+
62
+ # SSE framing is byte-level: U+0085/U+2028/U+2029 are legal *inside* JSON
63
+ # bodies, and str.splitlines (what ``aiter_lines`` uses) would split the
64
+ # body there and corrupt the frame. Only LF terminates a line; a lone CR is
65
+ # stripped for CRLF servers. The cap bounds BOTH the unconsumed
66
+ # accumulation of a malformed stream that never emits a blank line AND a
67
+ # single complete frame: without the accumulation bound one such stream
68
+ # grows without limit, and without the frame-bound check an over-cap frame
69
+ # would be delivered before any check could stop it - the cap would have
70
+ # no defined semantics for frames.
71
+ _SSE_BUFFER_CAP = 1 << 20
72
+
73
+
74
+ class ClientError(Exception):
75
+ """Base error for the behavior layer."""
76
+
77
+
78
+ class TransportError(ClientError):
79
+ """The request never produced an HTTP response (connect, timeout, reset),
80
+ or was never sent at all because httpx could not parse the URL.
81
+
82
+ Why this exists: ``httpx`` raises its own exception family, so a caller doing
83
+ ``except ClientError`` used to miss **every** network failure while catching
84
+ every server refusal. Wrapping puts both under one root.
85
+ """
86
+
87
+
88
+ class StatusError(ClientError):
89
+ """The server answered, but not with a usable body."""
90
+
91
+ def __init__(self, status: int, url: str, detail: str | None = None) -> None:
92
+ note = f" ({detail})" if detail else ""
93
+ super().__init__(f"HTTP {status} on {url}{note}")
94
+ self.status = status
95
+ self.url = url
96
+ #: Why this status matters (e.g. the refusal state a 200 carried).
97
+ self.detail = detail
98
+
99
+
100
+ class ShapeError(ClientError):
101
+ """The body did not match the documented shape."""
102
+
103
+
104
+ class NotReady(ClientError):
105
+ """``wait_ready``/``ensure_ready`` hit its deadline before the account reached ready."""
106
+
107
+ def __init__(self, timeout: float, last_state: str) -> None:
108
+ super().__init__(
109
+ f"account did not become ready within {timeout}s (last state: {last_state})"
110
+ )
111
+ self.timeout = timeout
112
+ self.last_state = last_state
113
+
114
+
115
+ class BadDate(ClientError):
116
+ """A time bound was neither ``YYYYMMDD`` nor unix seconds."""
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class RegisterOutcome:
121
+ """Raw ``POST /api/v1/accounts`` outcome.
122
+
123
+ HTTP 200 covers several business states with **different shapes**
124
+ (``accepted`` / ``in_progress`` / ``already_ready`` carry a status; a
125
+ conflict carries who holds the binding; a mismatch carries neither), so
126
+ only the two fields every state has are typed here and the decoded body
127
+ stays available for the caller's own classification.
128
+
129
+ A refusal is a **value**, not an error: what to do about it is the
130
+ caller's decision - the caller is the one holding the configuration that
131
+ identity is compared against.
132
+ """
133
+
134
+ state: str
135
+ status: str | None
136
+ body: dict[str, Any]
137
+
138
+
139
+ def _encode_path_segment(s: str) -> str:
140
+ """Percent-encode one path segment (unreserved set plus ``@``, which ids use).
141
+
142
+ Why: an id containing ``#`` or ``?`` interpolated straight into a URL string is
143
+ read as a fragment or query, so the request silently targets a different path.
144
+ The Rust client encodes too - the same input must not mean two things.
145
+ """
146
+ return urllib.parse.quote(s, safe="-._~@")
147
+
148
+
149
+ def _validate_time_bound(field: str, value: str) -> None:
150
+ """Accept a bare ``YYYYMMDD`` date or unix seconds - the server parses
151
+ both, so rejecting either here would block a legal request."""
152
+ # `str.isdigit()` also accepts non-ASCII digits (full-width, superscript),
153
+ # which the server rejects with a 400. The Rust client refuses them locally,
154
+ # so the same input must yield the same error class on both sides.
155
+ if not value.isascii() or not value.isdigit():
156
+ raise BadDate(
157
+ f"invalid time bound {field}={value!r}: expected YYYYMMDD or unix seconds"
158
+ )
159
+
160
+
161
+ def _message_query_params(
162
+ talker: str,
163
+ keyword: str | None,
164
+ start: str | None,
165
+ end: str | None,
166
+ limit: int | None,
167
+ offset: int | None,
168
+ media: bool,
169
+ ) -> dict[str, str]:
170
+ """Query for either messages face.
171
+
172
+ The native face (``/api/v1/messages``) and the ChatLab face
173
+ (``/chatlab/messages``) take the **same** query surface - only the
174
+ envelope differs. Building it in two places is how the two faces end up
175
+ answering the same request with different pages.
176
+ """
177
+ if not talker:
178
+ raise ShapeError("talker must not be empty")
179
+ query: dict[str, str] = {"talker": talker}
180
+ if keyword is not None:
181
+ query["keyword"] = keyword
182
+ for field, value in (("start", start), ("end", end)):
183
+ if value is not None:
184
+ _validate_time_bound(field, value)
185
+ query[field] = value
186
+ if limit is not None:
187
+ query["limit"] = str(limit)
188
+ if offset is not None:
189
+ query["offset"] = str(offset)
190
+ if media:
191
+ query["media"] = "1"
192
+ return query
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class _SseFrame:
197
+ event: str | None
198
+ data: str | None
199
+ id_: str | None
200
+
201
+
202
+ def _parse_sse_block(block: bytes) -> _SseFrame | None:
203
+ event = id_ = None
204
+ data_lines: list[bytes] = []
205
+ for raw in block.split(b"\n"):
206
+ raw = raw.rstrip(b"\r")
207
+ if raw.startswith(b":"):
208
+ continue # heartbeat comment
209
+ if raw.startswith(b"id:"):
210
+ id_ = raw[3:].strip().decode("utf-8", "replace")
211
+ elif raw.startswith(b"event:"):
212
+ event = raw[6:].strip().decode("utf-8", "replace")
213
+ elif raw.startswith(b"data:"):
214
+ data_lines.append(raw[5:])
215
+ if not data_lines:
216
+ return None
217
+ # SSE spec: several data lines in one frame join with LF into the
218
+ # dispatched payload. The old per-line overwrite kept only the last
219
+ # line - a today-unreachable shape (the server emits exactly one data
220
+ # line per frame) that would silently drop a multi-line payload. The
221
+ # single-line path strips exactly like before, so committed frames
222
+ # decode byte-identically.
223
+ data = b"\n".join(data_lines).strip().decode("utf-8", "replace")
224
+ return _SseFrame(event, data, id_)
225
+
226
+
227
+ class Client:
228
+ """Async client for one weflow-server instance.
229
+
230
+ ``token`` is the API token the server printed on first start (or
231
+ ``--show-token``). Auth goes in the ``Authorization`` header only -
232
+ never in the URL.
233
+ """
234
+
235
+ def __init__(
236
+ self,
237
+ base_url: str,
238
+ token: str,
239
+ timeout: float = READ_TIMEOUT,
240
+ stall_timeout: float = STALL_TIMEOUT,
241
+ ) -> None:
242
+ """Point a client at a server.
243
+
244
+ ``timeout`` bounds one read of an ordinary JSON response; ``stall_timeout``
245
+ bounds one read of a request whose size or server-side work is unbounded
246
+ (roster counts, media bytes, ``sync_now``, the SSE stream). Both are charged
247
+ per read operation rather than as a total deadline, so a slow answer that
248
+ keeps arriving is never cut off while one that goes silent still fails.
249
+ Pass a large ``stall_timeout`` to opt a stream out of stall detection.
250
+ """
251
+ self._timeout = httpx.Timeout(timeout, connect=CONNECT_TIMEOUT)
252
+ self._long_timeout = httpx.Timeout(stall_timeout, connect=CONNECT_TIMEOUT)
253
+ self._http = httpx.AsyncClient(timeout=self._timeout)
254
+ self._base = base_url.rstrip("/")
255
+ self._token = token
256
+
257
+ async def aclose(self) -> None:
258
+ await self._http.aclose()
259
+
260
+ async def __aenter__(self) -> Client:
261
+ return self
262
+
263
+ async def __aexit__(self, *exc: Any) -> None:
264
+ await self.aclose()
265
+
266
+ # ---- plumbing -------------------------------------------------------
267
+
268
+ def _url(self, path: str) -> str:
269
+ return self._base + path
270
+
271
+ async def _http_get(self, url: str, **kwargs: Any) -> httpx.Response:
272
+ """GET that maps `httpx` transport failures into :class:`TransportError`."""
273
+ try:
274
+ return await self._http.get(url, **kwargs)
275
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
276
+ raise TransportError(f"{type(exc).__name__} on {url}: {exc}") from exc
277
+
278
+ async def _http_post(self, url: str, **kwargs: Any) -> httpx.Response:
279
+ """POST with the same transport-failure mapping as :meth:`_http_get`.
280
+
281
+ Every public request entry must go through one of these two: an entry
282
+ that calls ``self._http`` directly lets a raw `httpx.ConnectError`
283
+ escape the ClientError tree, and callers that classify
284
+ ``except ClientError`` then miss transport failures for that route
285
+ alone (regression: test_transport_failures_are_client_errors walks one
286
+ representative per transport method)."""
287
+ try:
288
+ return await self._http.post(url, **kwargs)
289
+ except (httpx.HTTPError, httpx.InvalidURL) as exc:
290
+ raise TransportError(f"{type(exc).__name__} on {url}: {exc}") from exc
291
+
292
+ async def _get_json(
293
+ self, path: str, query: dict[str, str], *, long: bool = False
294
+ ):
295
+ url = self._url(path)
296
+ resp = await self._http_get(
297
+ url,
298
+ headers={"Authorization": f"Bearer {self._token}"},
299
+ params=query,
300
+ timeout=self._long_timeout if long else self._timeout,
301
+ )
302
+ return await self._decode(resp, url)
303
+
304
+ @staticmethod
305
+ async def _decode(resp: httpx.Response, url: str):
306
+ # Non-2xx, not just 4xx/5xx: the Rust client treats anything outside
307
+ # 200..300 as an error, and a surfaced 3xx would otherwise fall into
308
+ # `resp.json()` and come out as a shape error instead of a status error.
309
+ if not 200 <= resp.status_code < 300:
310
+ raise StatusError(resp.status_code, url)
311
+ try:
312
+ return resp.json()
313
+ except _json.JSONDecodeError as exc: # pragma: no cover - defensive
314
+ raise ShapeError(str(exc)) from exc
315
+
316
+
317
+ @staticmethod
318
+ def _validate(model: Any, payload: Any) -> Any:
319
+ """`model_validate` with shape corruption kept inside ShapeError.
320
+
321
+ A 200 with a body of the wrong *type* (a list where the route
322
+ returns an object, a string where a number lives) raises pydantic's
323
+ own ValidationError, which is not a ClientError. Callers that
324
+ classify ``except ClientError`` would see it escape for that route
325
+ alone (regression: test_health_shape_corruption_is_a_shape_error
326
+ feeds a 200 whose `account` field is a number)."""
327
+ try:
328
+ return model.model_validate(payload)
329
+ except ValidationError as exc:
330
+ raise ShapeError(str(exc)) from exc
331
+
332
+ # ---- readiness ------------------------------------------------------
333
+
334
+ async def wait_ready(self, account: str, timeout: float = 120.0) -> None:
335
+ """Poll the account listing until `account` reports ready.
336
+
337
+ Wait-only: performs **no** registration action and sends no body -
338
+ the caller owns registration and the classification of business
339
+ rejections (200 responses whose JSON `state` names a conflict or a
340
+ refusal). Errors are reserved for the deadline or the account
341
+ landing in ``error``; intermediate states are waiting, not errors.
342
+ """
343
+ deadline = asyncio.get_running_loop().time() + timeout
344
+ last_state = "not-registered"
345
+ dropped = 0
346
+ while True:
347
+ # A dropped round is waiting, not failure: this poll exists to ride out
348
+ # a transient connect blip or a momentary 5xx while the index is still
349
+ # coming up. A 4xx is a misconfiguration, and a listing that no longer
350
+ # matches the declared shape is a broken contract - both propagate
351
+ # instead of being waited out.
352
+ try:
353
+ listing = self._validate(
354
+ gen.AccountsList, await self._get_json("/api/v1/accounts", {})
355
+ )
356
+ except TransportError as exc:
357
+ listing = None
358
+ dropped += 1
359
+ last_state = f"unreachable ({exc})"
360
+ except StatusError as exc:
361
+ if 500 <= exc.status < 600:
362
+ listing = None
363
+ dropped += 1
364
+ last_state = f"server error {exc.status} on the listing"
365
+ else:
366
+ raise
367
+ mine = (
368
+ next((a for a in listing.accounts if a.wxid == account), None)
369
+ if listing is not None
370
+ else None
371
+ )
372
+ if mine is not None:
373
+ if mine.state is gen.AccountStatus.READY:
374
+ return
375
+ if mine.state is gen.AccountStatus.ERROR:
376
+ raise NotReady(timeout, f"error: {mine.error or ''}")
377
+ last_state = str(mine.state.value)
378
+ elif listing is not None:
379
+ last_state = "not-registered"
380
+ if asyncio.get_running_loop().time() >= deadline:
381
+ # Naming the dropped rounds is the difference between "indexing, keep
382
+ # waiting" and "this server never answered once" - both end at the
383
+ # same deadline, and only one of them is a caller-side problem.
384
+ raise NotReady(
385
+ timeout,
386
+ last_state
387
+ if not dropped
388
+ else f"{last_state}; {dropped} poll(s) never got through",
389
+ )
390
+ await asyncio.sleep(0.25)
391
+
392
+ async def ensure_ready(self, account: str, body: dict, timeout: float = 120.0) -> None:
393
+ """Register (idempotently) and poll until the account is ready.
394
+
395
+ The HTTP POST failing (>=400) is the only transport-level error the
396
+ registration step raises. A **200 whose JSON body carries a
397
+ `state` naming a refusal** (e.g. `account_conflict`) raises
398
+ :class:`StatusError` instead of entering the wait: treating those
399
+ rejections as accepted makes the readiness poll time out and hide
400
+ the real cause. Callers that need to distinguish refusal states
401
+ themselves should POST and use :meth:`wait_ready` directly.
402
+ """
403
+ outcome = await self.register(body)
404
+ if outcome.state in _REFUSAL_STATES:
405
+ url = self._url("/api/v1/accounts")
406
+ raise StatusError(200, url, f"state={outcome.state}")
407
+ await self.wait_ready(account, timeout)
408
+
409
+ # ---- health / accounts / register -----------------------------------
410
+
411
+ async def health(self) -> gen.Health:
412
+ """``GET /health`` - liveness and account phase. **Unauthenticated.**
413
+
414
+ Deliberately carries no account identity: confirming *which* account
415
+ is bound requires :meth:`accounts`. The SDK sends no credentials
416
+ here, and a test pins that.
417
+ """
418
+ url = self._url("/health")
419
+ resp = await self._http_get(url)
420
+ return self._validate(gen.Health, await self._decode(resp, url))
421
+
422
+ async def accounts(self) -> list[gen.AccountStateView]:
423
+ """``GET /api/v1/accounts`` - one entry per bound account.
424
+
425
+ The failure reason (``error``) and the message count live only here;
426
+ ``/health`` collapses everything to a scalar phase.
427
+ """
428
+ listing = self._validate(gen.AccountsList,
429
+ await self._get_json("/api/v1/accounts", {})
430
+ )
431
+ return listing.accounts
432
+
433
+ async def register(self, body: dict) -> RegisterOutcome:
434
+ """``POST /api/v1/accounts`` - register, returning the raw outcome
435
+ **without waiting**.
436
+
437
+ The waiting half is :meth:`wait_ready`; :meth:`ensure_ready` is the
438
+ two composed with the refusal vocabulary applied. Callers that must
439
+ classify the answer themselves (a refusal is a business state, not an
440
+ HTTP error) use this pair instead.
441
+ """
442
+ url = self._url("/api/v1/accounts")
443
+ resp = await self._http_post(
444
+ url,
445
+ headers={"Authorization": f"Bearer {self._token}"},
446
+ json=body,
447
+ )
448
+ payload = await self._decode(resp, url)
449
+ if not isinstance(payload, dict) or not isinstance(payload.get("state"), str):
450
+ raise ShapeError(f"{url}: no string `state` in the body: {payload!r}")
451
+ status = payload.get("status")
452
+ return RegisterOutcome(
453
+ state=payload["state"],
454
+ status=status if isinstance(status, str) else None,
455
+ body=payload,
456
+ )
457
+
458
+ async def sync_now(self) -> gen.SyncResult:
459
+ """``POST /api/v1/sync`` - run one manual incremental sync and return
460
+ its counters.
461
+
462
+ The watcher runs the same sync on file changes, so this exists for a
463
+ caller that must have a fresh read *now* rather than eventually (the
464
+ `sync` CLI subcommand and the `sync_now` MCP tool both do). It is a
465
+ write against the server's state - it advances watermarks and may export
466
+ media - so it is deliberately absent from every polling path: only a
467
+ caller that asked for it triggers it.
468
+ """
469
+ url = self._url("/api/v1/sync")
470
+ # A sync pass indexes and may export media: bounded only by the size of
471
+ # the library, never by a fixed budget.
472
+ resp = await self._http_post(
473
+ url,
474
+ headers={"Authorization": f"Bearer {self._token}"},
475
+ timeout=self._long_timeout,
476
+ )
477
+ return self._validate(gen.SyncResult, await self._decode(resp, url))
478
+
479
+ # ---- pull_page / drain_session --------------------------------------
480
+
481
+ async def pull_page(
482
+ self,
483
+ talker: str,
484
+ since: int | None,
485
+ offset: int = 0,
486
+ limit: int | None = None,
487
+ ) -> gen.PullEnvelope:
488
+ """Fetch **one page** of the ChatLab Pull cursor loop.
489
+
490
+ :meth:`drain_session` is this in a loop; the single-page entry exists
491
+ for callers that must bound a single request. ``since`` is
492
+ **exclusive** and ``offset`` advances within one timestamp group.
493
+ Both cursors come back in ``sync`` and must be echoed verbatim: the
494
+ server pages by (timestamp group, offset), and a client-derived
495
+ cursor is how pages get silently skipped or replayed.
496
+
497
+ ``limit`` is a per-page cap (the server caps it at 5000). ``None``
498
+ means the server default, which is also 5000.
499
+ """
500
+ query: dict[str, str] = {}
501
+ if since is not None:
502
+ query["since"] = str(since)
503
+ if offset:
504
+ query["offset"] = str(offset)
505
+ if limit is not None:
506
+ query["limit"] = str(limit)
507
+ return self._validate(gen.PullEnvelope,
508
+ await self._get_json(
509
+ f"/api/v1/sessions/{_encode_path_segment(talker)}/messages", query
510
+ )
511
+ )
512
+
513
+ async def drain_session(
514
+ self,
515
+ talker: str,
516
+ since: int | None,
517
+ on_page: Callable[[list[gen.PullMessage]], None],
518
+ ) -> int:
519
+ """Drain one session through the Pull cursor loop.
520
+
521
+ Cursors are echoed verbatim (``next_since`` / ``next_offset`` come
522
+ back exactly as the server sent them): the server pages by
523
+ (timestamp group, offset), and a client-derived cursor is how pages
524
+ get silently skipped or replayed.
525
+ """
526
+ next_since = since
527
+ next_offset = 0
528
+ total = 0
529
+ while True:
530
+ page = await self.pull_page(talker, next_since, next_offset)
531
+ total += len(page.messages)
532
+ on_page(page.messages)
533
+ if not page.sync.has_more:
534
+ return total
535
+ next_since = page.sync.next_since
536
+ next_offset = page.sync.next_offset
537
+
538
+ # ---- list_all_sessions ----------------------------------------------
539
+
540
+ async def list_all_sessions(
541
+ self,
542
+ *,
543
+ page_size: int | None = None,
544
+ keyword: str | None = None,
545
+ ) -> list[gen.SessionNative]:
546
+ """Fetch the complete session list via offset paging.
547
+
548
+ The list is a live view; duplicates across pages are collapsed with a
549
+ warning (losing data silently is the failure mode this loop must not
550
+ have).
551
+
552
+ ``page_size`` is the server-side page size (the server caps it at
553
+ 10000). Leave it ``None`` for the server default; a polling consumer
554
+ that re-reads the list every cycle should ask for the maximum instead,
555
+ because the request count is ``sessions / page_size`` and the server's
556
+ default page is two orders of magnitude smaller than the cap.
557
+ ``keyword`` filters server-side, so pagination walks the filtered list.
558
+ """
559
+ out: list[gen.SessionNative] = []
560
+ seen: set[str] = set()
561
+ offset = 0
562
+ while True:
563
+ query: dict[str, str] = {"offset": str(offset)}
564
+ if page_size is not None:
565
+ query["limit"] = str(page_size)
566
+ if keyword is not None:
567
+ query["keyword"] = keyword
568
+ page = self._validate(gen.SessionsNative,
569
+ await self._get_json("/api/v1/sessions", query)
570
+ )
571
+ count = len(page.sessions)
572
+ for session in page.sessions:
573
+ if session.username not in seen:
574
+ seen.add(session.username)
575
+ out.append(session)
576
+ else:
577
+ log.warning(
578
+ "session list shifted during pagination: duplicate %s collapsed",
579
+ session.username,
580
+ )
581
+ if count == 0:
582
+ return out
583
+ offset += count
584
+
585
+ # ---- media_bytes ----------------------------------------------------
586
+
587
+ async def media_bytes(self, message: gen.ChatlabMessage, talker: str) -> bytes:
588
+ """Fetch media bytes for a uniquely-named handle.
589
+
590
+ ``talker`` is the **session id** the export side door needs; it is NOT
591
+ ``message.account_name`` (that is the sender's display name, which only
592
+ coincides with the session id for 1:1 chats where the display name was
593
+ never customized - real data breaks the coincidence, so the caller owns
594
+ the value).
595
+
596
+ One automatic retry after a 404: the caller may have serialized the
597
+ handle before the export finished, and the ``media=1`` re-export is
598
+ what mints the file. A second 404 means the handle was never
599
+ exportable and the error propagates.
600
+ """
601
+ if message.media is None:
602
+ raise ShapeError("(no media on message): nothing to fetch")
603
+ if not talker.strip():
604
+ # Whitespace included, matching the Rust client: this is the media family,
605
+ # where an empty handle addresses a different path than the caller means.
606
+ raise ShapeError("talker must not be empty")
607
+ name = message.media.file_name
608
+ if not name.strip():
609
+ # Fail-fast, whitespace included (the Rust client trims too): an empty
610
+ # handle addresses a different path than the caller means, and the
611
+ # server cannot tell "no file name" from "some file I never heard of".
612
+ raise ShapeError("media file_name must not be empty")
613
+ url = self._url(f"/api/v1/media/{_encode_path_segment(name)}")
614
+ auth = {"Authorization": f"Bearer {self._token}"}
615
+ # Media size is whatever the sender uploaded; the export trigger behind a
616
+ # 404 copies whole files. Neither has a meaningful read budget.
617
+ resp = await self._http_get(url, headers=auth, timeout=self._long_timeout)
618
+ if resp.status_code == 404:
619
+ await self._get_json(
620
+ "/chatlab/messages",
621
+ {"talker": talker, "media": "1"},
622
+ long=True,
623
+ )
624
+ resp = await self._http_get(url, headers=auth, timeout=self._long_timeout)
625
+ # Not-2xx (not just 4xx/5xx): a 3xx falling through to `return` hands
626
+ # the caller an empty redirect body as media bytes with no error signal
627
+ # - same rule as _decode / media_bytes_by_id.
628
+ # (regression: test_media_bytes_rejects_redirect_like_statuses)
629
+ if not 200 <= resp.status_code < 300:
630
+ raise StatusError(resp.status_code, url)
631
+ return resp.content
632
+
633
+ # ---- list_messages / contacts / media_bytes_by_id ---------------------
634
+
635
+ async def list_messages(
636
+ self,
637
+ talker: str,
638
+ *,
639
+ keyword: str | None = None,
640
+ start: str | None = None,
641
+ end: str | None = None,
642
+ limit: int | None = None,
643
+ offset: int | None = None,
644
+ media: bool = False,
645
+ ) -> gen.MessagesNative:
646
+ """``GET /api/v1/messages`` - the **native** messages face.
647
+
648
+ Descending by time, offset-paged: advance ``offset`` by the page size
649
+ until ``has_more`` is false. This is the face that carries what the
650
+ ChatLab shape drops - ``raw_content``, ``is_send``, ``local_type`` - and
651
+ the only one that can export media (``media=True``).
652
+
653
+ Prefer :meth:`drain_session` when both faces would do: the Pull cursor
654
+ is stable across a live database, while offset paging over a growing
655
+ table can shift.
656
+ """
657
+ query = _message_query_params(
658
+ talker, keyword, start, end, limit, offset, media
659
+ )
660
+ return self._validate(gen.MessagesNative,
661
+ await self._get_json("/api/v1/messages", query)
662
+ )
663
+
664
+ async def chatlab_messages(
665
+ self,
666
+ talker: str,
667
+ *,
668
+ keyword: str | None = None,
669
+ start: str | None = None,
670
+ end: str | None = None,
671
+ limit: int | None = None,
672
+ offset: int | None = None,
673
+ media: bool = False,
674
+ ) -> gen.ChatlabMessages:
675
+ """``GET /chatlab/messages`` - the **ChatLab-shaped** messages face.
676
+
677
+ Same query surface as :meth:`list_messages`, different envelope:
678
+ ascending by time, ChatLab type codes, ``media`` on the message,
679
+ ``count``/``page`` for paging and **no** ``success`` key. Project from
680
+ this face when the caller wants ChatLab field names; the native face
681
+ is the only one carrying ``raw_content``/``is_send``.
682
+
683
+ ``offset`` is this face's paging cursor: the wire also accepts
684
+ ``cursor``, but the two are the same integer, so advancing by the page
685
+ size and passing ``page.next_cursor`` are equivalent - and it keeps
686
+ the two faces from growing different paging parameters for one list.
687
+ """
688
+ query = _message_query_params(
689
+ talker, keyword, start, end, limit, offset, media
690
+ )
691
+ return self._validate(gen.ChatlabMessages,
692
+ await self._get_json("/chatlab/messages", query)
693
+ )
694
+
695
+ async def contacts(
696
+ self,
697
+ *,
698
+ limit: int | None = None,
699
+ offset: int | None = None,
700
+ keyword: str | None = None,
701
+ ) -> gen.Contacts:
702
+ """``GET /api/v1/contacts`` - one page of the contact list.
703
+
704
+ Contact detail is not part of the ChatLab shape at all; this is the
705
+ only source for display names, remarks and aliases.
706
+ """
707
+ query: dict[str, str] = {}
708
+ if limit is not None:
709
+ query["limit"] = str(limit)
710
+ if offset is not None:
711
+ query["offset"] = str(offset)
712
+ if keyword is not None:
713
+ query["keyword"] = keyword
714
+ return self._validate(gen.Contacts,
715
+ await self._get_json("/api/v1/contacts", query)
716
+ )
717
+
718
+ async def group_members(
719
+ self,
720
+ chatroom: str,
721
+ *,
722
+ include_message_counts: bool = False,
723
+ ) -> gen.GroupMembers:
724
+ """``GET /api/v1/group-members`` - group members (**roster union speakers**).
725
+
726
+ Silent members (in the roster, never spoke) appear too, with
727
+ ``message_count`` 0 - listing only speakers would make "who is in this
728
+ group" depend on who talked last. The server counts real numbers only
729
+ when ``include_message_counts`` is true (otherwise the whole column
730
+ reads 0: counting scans the entire conversation, which is not free);
731
+ when off the parameter is **omitted**, not sent as ``0``. Roster and
732
+ messages live in the memory index: this request neither reads disks
733
+ nor triggers a sync.
734
+ """
735
+ if not chatroom:
736
+ # Same fail-fast as `talker` everywhere else: an empty key asks the
737
+ # server a different question, and a 200 with an empty roster would
738
+ # read as "this group has no members".
739
+ raise ShapeError("chatroom must not be empty")
740
+ query: dict[str, str] = {"chatroomId": chatroom}
741
+ if include_message_counts:
742
+ query["includeMessageCounts"] = "1"
743
+ # Counting messages scans every session behind each roster entry: that is
744
+ # exactly the request the published read budget must not apply to.
745
+ return self._validate(gen.GroupMembers,
746
+ await self._get_json(
747
+ "/api/v1/group-members", query, long=include_message_counts
748
+ )
749
+ )
750
+
751
+ async def media_bytes_by_id(self, media_id: str) -> bytes:
752
+ """``GET /api/v1/media/{id}`` - bytes for a handle the server advertised.
753
+
754
+ ``media_id`` is a **single path segment**: the native face's
755
+ ``media_id``, or the last segment of ``media.url``. Prefer
756
+ :meth:`media_bytes` when a ChatLab message is at hand - that one also
757
+ triggers an export and retries once on a 404.
758
+ """
759
+ if not media_id.strip():
760
+ # Whitespace counts as empty, matching the Rust client: an empty handle
761
+ # addresses a *different* path, and the
762
+ # server answering "no such file" would dress up a caller mistake as an
763
+ # unexportable handle. (The check sits after the docstring: a statement
764
+ # before it turns the docstring into a discarded expression, so help()
765
+ # and API docs generators would lose this method entirely.)
766
+ raise ShapeError("media_id must not be empty")
767
+ url = self._url(f"/api/v1/media/{_encode_path_segment(media_id)}")
768
+ resp = await self._http_get(
769
+ url,
770
+ headers={"Authorization": f"Bearer {self._token}"},
771
+ timeout=self._long_timeout,
772
+ )
773
+ if not 200 <= resp.status_code < 300:
774
+ raise StatusError(resp.status_code, url)
775
+ return resp.content
776
+
777
+ # ---- watch ------------------------------------------------------------
778
+
779
+ async def watch(self) -> AsyncIterator[Any]:
780
+ """Yield live server events over one long-lived SSE connection.
781
+
782
+ One connection produces many events: the read loop yields each
783
+ decoded frame and keeps reading until the stream itself ends or
784
+ errors - only then does it reconnect, carrying `Last-Event-ID` so
785
+ the server's replay window fills the gap. A per-frame disconnect
786
+ would turn every frame (including idle `sync` heartbeats) into a
787
+ reconnect cycle and make the server re-send its baseline each time.
788
+
789
+ Framing is byte-level LF - never ``aiter_lines``, whose splitlines
790
+ semantics split JSON bodies at U+0085/U+2028/U+2029 - with a 1 MiB
791
+ cap that bounds both the unconsumed accumulation and any single
792
+ complete frame: an over-cap frame (well-formed or not) is not
793
+ delivered and the stream ends for a backing-off reconnect.
794
+
795
+ Deliberately stricter than briefdesk's ``sources_base`` (that
796
+ shared layer flushes a complete frame first and only caps the
797
+ unconsumed tail - three sources depend on it, it is not touched
798
+ here). The divergence is documented on both sides; reconcile it
799
+ only with a deliberate decision.
800
+
801
+ Decoding failures on a single frame (bad JSON, shape mismatch,
802
+ non-object payload) are logged and skipped, never fatal: one bad
803
+ frame must not kill the stream. `StatusError` (HTTP >= 400) and
804
+ network errors keep their documented semantics - the former
805
+ propagates to the caller, the latter reconnects with backoff.
806
+ Cancelling the iteration closes the stream.
807
+ """
808
+ last_event_id: str | None = None
809
+ backoff = 0.5
810
+ while True:
811
+ # Only a *clean* stream end (EOF, no overflow, no transport
812
+ # error) resets backoff - see the flag below.
813
+ clean_exit = False
814
+ try:
815
+ headers = {
816
+ "Authorization": f"Bearer {self._token}",
817
+ "Accept": "text/event-stream",
818
+ }
819
+ if last_event_id is not None:
820
+ headers["Last-Event-ID"] = last_event_id
821
+ overflow = False
822
+ async with self._http.stream(
823
+ "GET",
824
+ self._url("/api/v1/push/messages"),
825
+ headers=headers,
826
+ timeout=self._long_timeout,
827
+ ) as resp:
828
+ # 3xx is also a failure (same not-2xx rule as _decode /
829
+ # media_bytes): a 302 decoded as a readable stream ends in
830
+ # an empty-body EOF, resetting the backoff and looping
831
+ # forever at 0.5s with no error signal to the caller.
832
+ if not 200 <= resp.status_code < 300:
833
+ raise StatusError(resp.status_code, self._url("/api/v1/push/messages"))
834
+ # The old shape reset backoff right here, on every
835
+ # 200: a server that connects fine and then emits
836
+ # over-cap frames reconnected forever at a flat 0.5s
837
+ # (measured: 6 connects in 3s) - which is not a backoff
838
+ # at all. Only the clean-exit path below may reset it.
839
+ buffer = bytearray()
840
+ pending: list[bytes] = []
841
+ pending_bytes = 0
842
+ async for chunk in resp.aiter_bytes():
843
+ buffer.extend(chunk)
844
+ while True:
845
+ nl = buffer.find(b"\n")
846
+ if nl < 0:
847
+ break
848
+ line = bytes(buffer[:nl]).rstrip(b"\r")
849
+ del buffer[: nl + 1]
850
+ if line:
851
+ pending.append(line)
852
+ pending_bytes += len(line) + 1
853
+ continue
854
+ # blank line = end of frame
855
+ if pending_bytes > _SSE_BUFFER_CAP:
856
+ log.warning(
857
+ "SSE frame over %d bytes (malformed stream?), "
858
+ "ending this stream for reconnect",
859
+ _SSE_BUFFER_CAP,
860
+ )
861
+ overflow = True
862
+ break
863
+ frame = _parse_sse_block(b"\n".join(pending))
864
+ pending = []
865
+ pending_bytes = 0
866
+ if frame is None:
867
+ continue
868
+ if frame.id_ is not None and frame.id_.isdigit():
869
+ last_event_id = frame.id_
870
+ try:
871
+ decoded = self._decode_event(frame)
872
+ except (ShapeError, ValidationError, AttributeError) as exc:
873
+ log.warning("undecodable SSE frame skipped: %s", exc)
874
+ continue
875
+ if decoded is not None:
876
+ yield decoded
877
+ if pending_bytes + len(buffer) > _SSE_BUFFER_CAP:
878
+ log.warning(
879
+ "SSE buffer over %d bytes without a frame boundary "
880
+ "(malformed stream?), ending this stream for reconnect",
881
+ _SSE_BUFFER_CAP,
882
+ )
883
+ overflow = True
884
+ break
885
+ if not overflow:
886
+ residual = bytes(buffer).rstrip(b"\r")
887
+ buffer.clear()
888
+ if residual:
889
+ pending.append(residual)
890
+ # (No separate EOF frame-cap check: the accumulation
891
+ # check above already fires once pending_bytes passes
892
+ # the cap, so an over-cap unterminated frame never
893
+ # reaches this flush - a branch here would be dead.
894
+ # Pinned by
895
+ # test_watch_drops_oversized_final_frame_at_eof_without_blank.)
896
+ if pending:
897
+ # EOF: fold the unterminated final line (still in
898
+ # the buffer) into the pending frame - a server
899
+ # may close without the trailing blank line, and
900
+ # the last event would silently vanish with it.
901
+ frame = _parse_sse_block(b"\n".join(pending))
902
+ if frame is not None:
903
+ if frame.id_ is not None and frame.id_.isdigit():
904
+ last_event_id = frame.id_
905
+ try:
906
+ decoded = self._decode_event(frame)
907
+ except (ShapeError, ValidationError, AttributeError) as exc:
908
+ log.warning("undecodable final SSE frame: %s", exc)
909
+ else:
910
+ if decoded is not None:
911
+ yield decoded
912
+ if not overflow:
913
+ # Every guard passed: the next reconnect starts
914
+ # from the floor instead of carrying a stale
915
+ # doubled delay over.
916
+ clean_exit = True
917
+ # Overflow: skip the EOF flush entirely (an un-trusted
918
+ # stream must not deliver its tail) and keep escalating.
919
+ except httpx.InvalidURL as exc:
920
+ # Not a stream hiccup: this client cannot parse its own URL, so every
921
+ # reconnect fails identically. Swallowing it would turn a
922
+ # configuration mistake into a stream that never yields and never
923
+ # errors - the two other entry points raise, so raise here too.
924
+ raise TransportError(f"unusable base_url for SSE: {exc}") from exc
925
+ except httpx.HTTPError as exc:
926
+ log.warning("SSE stream error, reconnecting: %s", exc)
927
+ if clean_exit:
928
+ backoff = 0.5
929
+ await asyncio.sleep(backoff)
930
+ backoff = min(backoff * 2, 30.0)
931
+
932
+ @staticmethod
933
+ def _decode_event(frame: _SseFrame) -> Any | None:
934
+ assert frame.data is not None
935
+ try:
936
+ payload = _json.loads(frame.data)
937
+ except _json.JSONDecodeError as exc:
938
+ raise ShapeError(str(exc)) from exc
939
+ kind = payload.get("event", frame.event or "")
940
+ if kind == "message.new":
941
+ return Client._validate(gen.EventNew, payload)
942
+ if kind == "message.revoke":
943
+ return Client._validate(gen.EventRevoke, payload)
944
+ if kind == "sync":
945
+ return Client._validate(gen.EventSync, payload)
946
+ # Notification face / unknown kinds: meta-only pass-through so a new
947
+ # server event kind degrades instead of killing the stream.
948
+ log.debug("ignoring unknown SSE event kind: %s", kind)
949
+ return None