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.
- weflow_sdk/LICENSE +21 -0
- weflow_sdk/__init__.py +37 -0
- weflow_sdk/client.py +949 -0
- weflow_sdk/generated/spec.json +1782 -0
- weflow_sdk/generated/weflow_sdk/__init__.py +135 -0
- weflow_sdk/generated/weflow_sdk/api/__init__.py +4 -0
- weflow_sdk/generated/weflow_sdk/api/default_api.py +5224 -0
- weflow_sdk/generated/weflow_sdk/api_client.py +836 -0
- weflow_sdk/generated/weflow_sdk/api_response.py +21 -0
- weflow_sdk/generated/weflow_sdk/configuration.py +595 -0
- weflow_sdk/generated/weflow_sdk/exceptions.py +218 -0
- weflow_sdk/generated/weflow_sdk/models/__init__.py +59 -0
- weflow_sdk/generated/weflow_sdk/models/account_conflict.py +95 -0
- weflow_sdk/generated/weflow_sdk/models/account_deregistered.py +100 -0
- weflow_sdk/generated/weflow_sdk/models/account_not_registered.py +97 -0
- weflow_sdk/generated/weflow_sdk/models/account_phase.py +37 -0
- weflow_sdk/generated/weflow_sdk/models/account_registered.py +95 -0
- weflow_sdk/generated/weflow_sdk/models/account_state_view.py +101 -0
- weflow_sdk/generated/weflow_sdk/models/account_status.py +37 -0
- weflow_sdk/generated/weflow_sdk/models/account_wxid_mismatch.py +102 -0
- weflow_sdk/generated/weflow_sdk/models/accounts_list.py +95 -0
- weflow_sdk/generated/weflow_sdk/models/chatlab_header.py +90 -0
- weflow_sdk/generated/weflow_sdk/models/chatlab_member.py +92 -0
- weflow_sdk/generated/weflow_sdk/models/chatlab_message.py +111 -0
- weflow_sdk/generated/weflow_sdk/models/chatlab_messages.py +125 -0
- weflow_sdk/generated/weflow_sdk/models/chatlab_meta.py +94 -0
- weflow_sdk/generated/weflow_sdk/models/contact.py +98 -0
- weflow_sdk/generated/weflow_sdk/models/contacts.py +102 -0
- weflow_sdk/generated/weflow_sdk/models/delete_api_v1_accounts_wxid200_response.py +149 -0
- weflow_sdk/generated/weflow_sdk/models/event_media.py +102 -0
- weflow_sdk/generated/weflow_sdk/models/event_new.py +111 -0
- weflow_sdk/generated/weflow_sdk/models/event_revoke.py +105 -0
- weflow_sdk/generated/weflow_sdk/models/event_sync.py +98 -0
- weflow_sdk/generated/weflow_sdk/models/group_member.py +104 -0
- weflow_sdk/generated/weflow_sdk/models/group_members.py +104 -0
- weflow_sdk/generated/weflow_sdk/models/health.py +91 -0
- weflow_sdk/generated/weflow_sdk/models/media_brief.py +95 -0
- weflow_sdk/generated/weflow_sdk/models/media_envelope.py +91 -0
- weflow_sdk/generated/weflow_sdk/models/media_object.py +123 -0
- weflow_sdk/generated/weflow_sdk/models/message_native.py +134 -0
- weflow_sdk/generated/weflow_sdk/models/messages_native.py +108 -0
- weflow_sdk/generated/weflow_sdk/models/notification_frame.py +99 -0
- weflow_sdk/generated/weflow_sdk/models/page.py +93 -0
- weflow_sdk/generated/weflow_sdk/models/post_api_v1_accounts200_response.py +135 -0
- weflow_sdk/generated/weflow_sdk/models/pull_envelope.py +120 -0
- weflow_sdk/generated/weflow_sdk/models/pull_message.py +118 -0
- weflow_sdk/generated/weflow_sdk/models/pull_sync.py +93 -0
- weflow_sdk/generated/weflow_sdk/models/quote.py +94 -0
- weflow_sdk/generated/weflow_sdk/models/session_chatlab.py +104 -0
- weflow_sdk/generated/weflow_sdk/models/session_native.py +106 -0
- weflow_sdk/generated/weflow_sdk/models/sessions_chatlab.py +102 -0
- weflow_sdk/generated/weflow_sdk/models/sessions_native.py +98 -0
- weflow_sdk/generated/weflow_sdk/models/sync_frame.py +98 -0
- weflow_sdk/generated/weflow_sdk/models/sync_result.py +91 -0
- weflow_sdk/generated/weflow_sdk/models/watermark_entry.py +92 -0
- weflow_sdk/generated/weflow_sdk/models/watermark_value.py +90 -0
- weflow_sdk/generated/weflow_sdk/py.typed +0 -0
- weflow_sdk/generated/weflow_sdk/rest.py +201 -0
- weflow_sdk/py.typed +0 -0
- weflow_sdk-0.9.0.dist-info/METADATA +59 -0
- weflow_sdk-0.9.0.dist-info/RECORD +63 -0
- weflow_sdk-0.9.0.dist-info/WHEEL +4 -0
- 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
|