satzet 1.0.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.
satzet/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Python client for a wadeno-server WhatsApp gateway.
2
+
3
+ from satzet import Client
4
+
5
+ wa = Client("http://localhost:8000", api_key=os.environ["WADENO_API_KEY"])
6
+ wa.session("default").send_text("628123456789", "halo")
7
+
8
+ READ THIS BEFORE YOUR FIRST SEND. This gateway will not message somebody who has never written to
9
+ it — that refusal is the whole reason the number does not get banned. `Session.consent(to)` is how
10
+ you vouch for a recipient who signed up elsewhere, and first contacts are held to roughly ten a
11
+ day. See `GateError` for what to do about each kind of refusal.
12
+ """
13
+
14
+ from .client import Client, Session
15
+ from .errors import (
16
+ AuthError,
17
+ ConflictError,
18
+ GateError,
19
+ NotConfiguredError,
20
+ SATZETError,
21
+ )
22
+
23
+ __version__ = "1.0.0"
24
+
25
+ __all__ = [
26
+ "Client",
27
+ "Session",
28
+ "SATZETError",
29
+ "GateError",
30
+ "AuthError",
31
+ "ConflictError",
32
+ "NotConfiguredError",
33
+ "__version__",
34
+ ]
satzet/client.py ADDED
@@ -0,0 +1,580 @@
1
+ """The gateway, as one object.
2
+
3
+ WHAT THIS IS. A thin wrapper over the HTTP API: base URL, credentials, and a translator that turns
4
+ the server's status codes into exceptions worth catching. Deliberately NOT a generated client with
5
+ a class per response — the API is small enough that dicts are easier to read than a hierarchy to
6
+ learn, and :meth:`Client.request` reaches anything not wrapped here.
7
+
8
+ WHAT IT CANNOT DO FOR YOU. The consent rule. This gateway will not message somebody who has never
9
+ written to it, and no client library changes that — see :meth:`Session.consent`.
10
+
11
+ Zero dependencies: the standard library's ``urllib`` is enough for JSON over HTTP, and a client
12
+ this small should not drag ``requests`` into somebody's lockfile.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from pathlib import Path
23
+ from typing import Any, Literal, Mapping, MutableMapping
24
+
25
+ from .errors import (
26
+ AuthError,
27
+ ConflictError,
28
+ GateError,
29
+ NotConfiguredError,
30
+ SATZETError,
31
+ )
32
+
33
+ __all__ = ["Client", "Session"]
34
+
35
+ JsonDict = dict[str, Any]
36
+
37
+
38
+ class Client:
39
+ """A wadeno-server gateway.
40
+
41
+ >>> from satzet import Client
42
+ >>> wa = Client("http://localhost:8000", api_key=os.environ["WADENO_API_KEY"])
43
+ >>> wa.session("default").send_text("628123456789", "halo")
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ base_url: str,
49
+ api_key: str,
50
+ *,
51
+ token: str | None = None,
52
+ timeout: float = 60.0,
53
+ ) -> None:
54
+ """
55
+ :param base_url: where the gateway is, e.g. ``http://localhost:8000``.
56
+ :param api_key: a session's database-backed key, ``WADENO_API_KEY``, or ``WADENO_API_KEYS``.
57
+ :param token: a Bearer token from :meth:`login`. Needed for autopilot and key management,
58
+ which refuse an API key however wide its scope.
59
+ :param timeout: seconds. Generous by default — a send waits out the gate's deliberate
60
+ spacing before it returns.
61
+ """
62
+ self.base_url = base_url.rstrip("/")
63
+ self.api_key = api_key
64
+ self.token = token
65
+ self.timeout = timeout
66
+
67
+ # ---------------------------------------------------------------- sessions
68
+
69
+ def session(self, session_id: str) -> "Session":
70
+ """One account, and everything that hangs off it."""
71
+ return Session(self, session_id)
72
+
73
+ def sessions(self, *, archived: bool = False) -> JsonDict:
74
+ """Every registered account, with its live state."""
75
+ return self.request("GET", "/v1/sessions", query={"archived": "1"} if archived else None)
76
+
77
+ def create_session(self, session_id: str, **options: Any) -> JsonDict:
78
+ """Register a session. **Does not connect anything** — call ``start()`` next."""
79
+ return self.request("POST", "/v1/sessions", {"id": session_id, **options})
80
+
81
+ # ---------------------------------------------------------------- credentials
82
+
83
+ def health(self) -> JsonDict:
84
+ """Liveness. The only route that needs no credential."""
85
+ return self.request("GET", "/health")
86
+
87
+ def login(self, username: str, password: str) -> JsonDict:
88
+ """Sign in, and keep the token for the rest of this client's life.
89
+
90
+ Note the asymmetry with the API key: the key answers "may this caller in", the token
91
+ answers "who is it", and a few routes need the second question answered.
92
+ """
93
+ result = self.request("POST", "/v1/auth/login", {"username": username, "password": password})
94
+ if result.get("token"):
95
+ self.token = str(result["token"])
96
+ return result
97
+
98
+ # ---------------------------------------------------------------- webhook
99
+
100
+ def webhook(self) -> JsonDict:
101
+ """Where inbound events go, and whether they are arriving."""
102
+ return self.request("GET", "/v1/webhook")
103
+
104
+ def set_webhook(self, **config: Any) -> JsonDict:
105
+ """Point the webhook somewhere.
106
+
107
+ PATCH-like despite the verb: a field you leave out keeps its value, and an explicit
108
+ ``None`` clears it. That is what lets a form which — correctly — never displays the secret
109
+ be saved without unsigning the webhook.
110
+ """
111
+ return self.request("PUT", "/v1/webhook", config)
112
+
113
+ def test_webhook(self) -> JsonDict:
114
+ """Fire one event and report what the receiver said.
115
+
116
+ 200 either way — the request succeeded in finding out. It exists because a webhook that is
117
+ quietly being refused looks exactly like one that is working.
118
+ """
119
+ return self.request("POST", "/v1/webhook/test")
120
+
121
+ # ---------------------------------------------------------------- transport
122
+
123
+ def request(
124
+ self,
125
+ method: str,
126
+ path: str,
127
+ body: Mapping[str, Any] | None = None,
128
+ query: Mapping[str, Any] | None = None,
129
+ ) -> JsonDict:
130
+ """Anything this class does not wrap.
131
+
132
+ The escape hatch, and a first-class part of the design rather than an admission: the
133
+ gateway has around a hundred routes and this library wraps the ones people actually call.
134
+ Everything else — channels, leads, Sheets exports, the assistant — is one line away, with
135
+ the same authentication and the same error translation.
136
+
137
+ >>> wa.request("GET", "/v1/sessions/default/leads", query={"status": "new"})
138
+ """
139
+ url = self.base_url + path
140
+ if query:
141
+ clean = {k: v for k, v in query.items() if v is not None}
142
+ if clean:
143
+ url += "?" + urllib.parse.urlencode(clean)
144
+
145
+ headers: MutableMapping[str, str] = {
146
+ "Accept": "application/json",
147
+ "x-api-key": self.api_key,
148
+ }
149
+ if self.token:
150
+ # Both credentials, deliberately. The gateway reads identity BEFORE it decides
151
+ # authentication, so a key WITH a token is that person rather than an anonymous
152
+ # machine — which is what keeps a signed-in operator's assistant history their own.
153
+ headers["Authorization"] = f"Bearer {self.token}"
154
+
155
+ data: bytes | None = None
156
+ if body is not None:
157
+ data = json.dumps(body, ensure_ascii=False).encode("utf-8")
158
+ headers["Content-Type"] = "application/json"
159
+
160
+ request = urllib.request.Request(url, data=data, headers=dict(headers), method=method.upper())
161
+ try:
162
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
163
+ return _decode(response.status, response.read())
164
+ except urllib.error.HTTPError as exc:
165
+ # An HTTPError IS the response for every 4xx and 5xx, so the body — which carries the
166
+ # gate's `code` — is read from it rather than lost to a generic message.
167
+ raise _translate(exc.code, exc.read()) from None
168
+ except urllib.error.URLError as exc:
169
+ # Never reached the server, so there is no status and no code. Distinguished from a
170
+ # refusal because the fix is a network or a URL rather than a request.
171
+ raise SATZETError(f"could not reach the gateway at {self.base_url}: {exc.reason}") from None
172
+
173
+
174
+ class Session:
175
+ """One WhatsApp account, and everything that hangs off it.
176
+
177
+ THE ORDER THAT MATTERS, for anybody arriving from a gateway with no safety policy:
178
+
179
+ 1. :meth:`start` and scan the QR from :meth:`qr`, once, per number.
180
+ 2. :meth:`consent` for anybody who has not written to you first.
181
+ 3. :meth:`send_text`.
182
+
183
+ Skip step 2 and step 3 raises :class:`~satzet.errors.GateError` with ``no-consent``, forever.
184
+ That is the feature working.
185
+ """
186
+
187
+ def __init__(self, client: Client, session_id: str) -> None:
188
+ self.client = client
189
+ self.id = session_id
190
+
191
+ def _path(self, suffix: str = "") -> str:
192
+ return f"/v1/sessions/{self.id}{suffix}"
193
+
194
+ def call(
195
+ self,
196
+ method: str,
197
+ suffix: str = "",
198
+ body: Mapping[str, Any] | None = None,
199
+ query: Mapping[str, Any] | None = None,
200
+ ) -> JsonDict:
201
+ """Anything on this session that this class does not wrap — channels, leads, status."""
202
+ return self.client.request(method, self._path(suffix), body, query)
203
+
204
+ # ---------------------------------------------------------------- linking
205
+
206
+ def get(self) -> JsonDict:
207
+ """The record plus the gate's snapshot."""
208
+ return self.call("GET")
209
+
210
+ def status(self) -> JsonDict:
211
+ """What the connection is doing right now. Poll this while linking."""
212
+ return self.call("GET", "/status")
213
+
214
+ def start(self, pair_phone: str | None = None) -> JsonDict:
215
+ """Start linking. **Returns immediately — it does not wait for the scan.**
216
+
217
+ For an unlinked device the connection only completes once somebody picks up their phone, so
218
+ awaiting it would hang for minutes. Poll :meth:`qr` afterwards.
219
+
220
+ :param pair_phone: ask for an 8-character pairing code for this number instead of a QR —
221
+ easier to read out over the phone than a picture.
222
+ """
223
+ return self.call("POST", "/start", {"pairPhone": pair_phone} if pair_phone else {})
224
+
225
+ def qr(self) -> JsonDict | None:
226
+ """The QR or pairing code to show somebody, or ``None`` once there is nothing to scan.
227
+
228
+ ``None`` is how a polling loop learns it can stop: the underlying 404 means either "already
229
+ linked" or "no offer yet", and :meth:`status` says which. A code older than about 20 seconds
230
+ still comes back, marked ``stale`` — a screen that empties itself every twenty seconds is
231
+ worse than one that says "fetching a new code".
232
+ """
233
+ try:
234
+ return self.call("GET", "/qr")
235
+ except SATZETError as exc:
236
+ if exc.status == 404:
237
+ return None
238
+ raise
239
+
240
+ def stop(self) -> JsonDict:
241
+ """Disconnect, keep the link."""
242
+ return self.call("POST", "/stop")
243
+
244
+ def delete(self, *, purge: bool = False) -> JsonDict:
245
+ """Unlink the number, or forget the session entirely.
246
+
247
+ **Two different acts.** Without ``purge`` the device is unlinked from the WhatsApp account
248
+ and the record survives, so a new number can be scanned into the same slot. With ``purge``
249
+ the record, the journal, the contacts, the leads, the schedules and the credentials on disk
250
+ all go. Both are irreversible.
251
+ """
252
+ return self.call("DELETE", query={"purge": "1"} if purge else None)
253
+
254
+ # ---------------------------------------------------------------- session API keys
255
+
256
+ def api_keys(self) -> JsonDict:
257
+ """Live key metadata for this session. Requires ``client.login()`` and PostgreSQL."""
258
+ return self.call("GET", "/keys")
259
+
260
+ def create_api_key(self, name: str, scope: Literal["full", "mcp"] = "full") -> JsonDict:
261
+ """Create a key restricted to this session. Save ``secret`` securely: it is shown once.
262
+
263
+ ``full`` supports sending; ``mcp`` permits the MCP integration routes without sending.
264
+ Requires a signed-in person; an existing API key alone cannot create another one.
265
+ """
266
+ return self.call("POST", "/keys", {"name": name, "scope": scope})
267
+
268
+ def revoke_api_key(self, key_id: str) -> JsonDict:
269
+ """Revoke a key immediately. Requires a signed-in person."""
270
+ return self.call("DELETE", f"/keys/{urllib.parse.quote(key_id, safe='')}")
271
+
272
+ # ---------------------------------------------------------------- sending
273
+
274
+ def consent(self, to: str) -> JsonDict:
275
+ """Record that somebody agreed to be contacted, outside WhatsApp.
276
+
277
+ **THE MOST IMPORTANT METHOD IN THIS LIBRARY, and the one nobody expects to need.** This
278
+ gateway's whole defence against being banned is that it does not contact people who did not
279
+ ask: bans come from user reports, and reports come from unsolicited contact. A recipient who
280
+ has never written in is refused by :meth:`send_text` with ``no-consent``, permanently.
281
+
282
+ So call this for the one recipient you are willing to vouch for — a signup form, an order,
283
+ a person who gave you their number — and then send. Note what it is NOT: there is no bulk
284
+ version, and first contacts are held to roughly ten a day. Three hundred leads is a month of
285
+ outreach, deliberately.
286
+ """
287
+ return self.call("POST", "/consent", {"to": to})
288
+
289
+ def send_text(self, to: str, text: str, *, quote: str | None = None) -> JsonDict:
290
+ """Send a text message.
291
+
292
+ ``to`` is a phone number in international form (``628123456789``) or a full JID. A national
293
+ number (``0812…``) is refused rather than guessed at — it addresses nobody.
294
+
295
+ Raises :class:`~satzet.errors.GateError` when the safety policy refuses. Catch it and ask
296
+ which kind: ``deferred`` means wait ``retry_after_ms``, ``rejected`` means retrying will
297
+ never help.
298
+
299
+ ``quote`` is optional: a WhatsApp message id from THIS conversation, which pins the reply
300
+ to it — the grey "reply" bar the app shows. A 404 means the gateway's journal no longer has
301
+ it; a 400 means it belongs to another chat, which is refused because a quote carries a copy
302
+ of the original text.
303
+
304
+ :returns: ``id`` is WhatsApp's message id; ``delayedMs`` is how long the gate held it,
305
+ because the spacing is jittered on purpose.
306
+ """
307
+ body: JsonDict = {"to": to, "text": text}
308
+ # Omitted rather than sent as None when nobody asked, so an ordinary send is the same
309
+ # request it was before quoting existed.
310
+ if quote is not None:
311
+ body["quote"] = quote
312
+ return self.call("POST", "/messages", body)
313
+
314
+ def send_image(
315
+ self,
316
+ to: str,
317
+ *,
318
+ url: str | None = None,
319
+ image: str | bytes | None = None,
320
+ path: str | Path | None = None,
321
+ caption: str | None = None,
322
+ ) -> JsonDict:
323
+ """Send an image by URL, by base64, or from a local file.
324
+
325
+ 16 MB cap however it arrives. An uncaptioned image is invisible to the duplicate rule — the
326
+ caption is the only comparable part of one.
327
+ """
328
+ body: JsonDict = {"to": to}
329
+ if path is not None:
330
+ body["image"] = base64.b64encode(Path(path).read_bytes()).decode("ascii")
331
+ elif isinstance(image, bytes):
332
+ body["image"] = base64.b64encode(image).decode("ascii")
333
+ elif image is not None:
334
+ body["image"] = image
335
+ elif url is not None:
336
+ body["url"] = url
337
+ else:
338
+ raise SATZETError("give one of `url`, `image` or `path`")
339
+ if caption is not None:
340
+ body["caption"] = caption
341
+ return self.call("POST", "/messages/image", body)
342
+
343
+ def delete_message(self, to: str, message_id: str) -> JsonDict:
344
+ """Delete a message for everyone.
345
+
346
+ ``to`` is required alongside the id: a message id does not say which chat it lives in, and a
347
+ revoke pointed at the wrong chat silently deletes nothing.
348
+ """
349
+ return self.call("POST", f"/messages/{message_id}/delete", {"to": to})
350
+
351
+ def pin_message(self, to: str, message_id: str, duration: str | int = "day") -> JsonDict:
352
+ """Pin a message for everyone in the chat.
353
+
354
+ :param duration: ``day``, ``week``, ``month``, or a raw number of seconds — though the
355
+ recipient then sees a countdown they could not have set themselves.
356
+ """
357
+ return self.call("POST", f"/messages/{message_id}/pin", {"to": to, "duration": duration})
358
+
359
+ def unpin_message(self, to: str, message_id: str) -> JsonDict:
360
+ return self.call("POST", f"/messages/{message_id}/unpin", {"to": to})
361
+
362
+ # ---------------------------------------------------------------- many recipients, and later
363
+
364
+ def broadcast(self, to: list[str], text: str) -> JsonDict:
365
+ """Start a broadcast. **Returns a job immediately — poll it with :meth:`broadcast_job`.**
366
+
367
+ A hundred recipients through the gate is several minutes of deliberate spacing, so this does
368
+ not wait. Recipients are de-duplicated by the gateway, so a bare number and the same number
369
+ as a JID cost one send rather than two.
370
+
371
+ One broadcast per session at a time; a second raises :class:`~satzet.errors.ConflictError`
372
+ carrying the job already running, so a reloaded page can re-attach.
373
+ """
374
+ return self.call("POST", "/broadcasts", {"to": list(to), "text": text})
375
+
376
+ def broadcast_job(self, job_id: str) -> JsonDict:
377
+ return self.call("GET", f"/broadcasts/{job_id}")
378
+
379
+ def cancel_broadcast(self, job_id: str) -> JsonDict:
380
+ """Stops between recipients, never mid-send."""
381
+ return self.call("POST", f"/broadcasts/{job_id}/cancel")
382
+
383
+ def broadcasts(self) -> JsonDict:
384
+ """Jobs, newest first. In memory, so not resumed after a restart."""
385
+ return self.call("GET", "/broadcasts")
386
+
387
+ def schedule(
388
+ self,
389
+ to: str,
390
+ text: str,
391
+ *,
392
+ send_at: str | int | None = None,
393
+ delay_ms: int | None = None,
394
+ note: str | None = None,
395
+ ) -> JsonDict:
396
+ """Send a message at a moment of your choosing.
397
+
398
+ Unlike :meth:`send_text`, this IS retried when the gate defers — it is persisted, and given
399
+ up on only outside its grace window. A message that comes due more than an hour late is
400
+ recorded as ``missed`` rather than sent, because "9am Tuesday" stops meaning anything by
401
+ noon.
402
+
403
+ The session does not have to be connected to schedule. Consent, however, is checked NOW as
404
+ well as at send time — so a cold recipient is a 400 here rather than a queue of refusals
405
+ nobody is awake to read.
406
+
407
+ :param send_at: an ISO timestamp (``2026-08-30T09:00:00+07:00``) or epoch milliseconds.
408
+ :param delay_ms: an alternative to ``send_at``, for a caller with no clock to convert with.
409
+ """
410
+ body: JsonDict = {"to": to, "text": text}
411
+ if send_at is not None:
412
+ body["sendAt"] = send_at
413
+ if delay_ms is not None:
414
+ body["delayMs"] = delay_ms
415
+ if note is not None:
416
+ body["note"] = note
417
+ return self.call("POST", "/schedules", body)
418
+
419
+ def schedules(self, **filters: Any) -> JsonDict:
420
+ """What is waiting. Filters: ``status``, ``limit``."""
421
+ return self.call("GET", "/schedules", query=filters)
422
+
423
+ def cancel_schedule(self, schedule_id: str) -> JsonDict:
424
+ """Only while it is still pending — 409 once it has gone."""
425
+ return self.call("DELETE", f"/schedules/{schedule_id}")
426
+
427
+ # ---------------------------------------------------------------- what the gateway heard
428
+
429
+ def chats(self) -> JsonDict:
430
+ """Conversations, newest first, with unread counts.
431
+
432
+ This is what the GATEWAY saw, not the contents of the account: a linked device is never sent
433
+ the history, so anything older than the link is unreachable. The journal keeps the last 2000
434
+ messages. Telegram conversations are merged in already.
435
+ """
436
+ return self.call("GET", "/chats")
437
+
438
+ def thread(self, chat: str, **options: Any) -> JsonDict:
439
+ """One thread, oldest first. Page backwards with the ``at`` of the oldest message as ``before``."""
440
+ return self.call("GET", f"/chats/{urllib.parse.quote(chat, safe='')}/messages", query=options)
441
+
442
+ def mark_seen(self, chat: str) -> JsonDict:
443
+ """Clear the unread count.
444
+
445
+ **Sends no read receipt** — the other side still sees one tick. Telling somebody their
446
+ message has been read because an operator glanced at a dashboard would be a lie told on
447
+ their behalf.
448
+ """
449
+ return self.call("POST", f"/chats/{urllib.parse.quote(chat, safe='')}/seen")
450
+
451
+ def messages(self, **filters: Any) -> JsonDict:
452
+ """The send log, newest first. Filters: ``direction``, ``state``, ``kind``, ``limit``."""
453
+ return self.call("GET", "/messages", query=filters)
454
+
455
+ def stats(self, **options: Any) -> JsonDict:
456
+ """Counts and an hourly series. ``hours`` defaults to 24."""
457
+ return self.call("GET", "/stats", query=options)
458
+
459
+ # ---------------------------------------------------------------- contacts and policy
460
+
461
+ def contacts(self) -> JsonDict:
462
+ """Saved names merged with harvested numbers and consent.
463
+
464
+ Three separate things, kept apart on purpose. ``consent`` is the one people mean when they
465
+ ask "can I message them" — a saved contact is a NAME and never a permission — and
466
+ ``nameSource`` says whether a name came from you or from what the person calls themselves,
467
+ which is self-declared and unverified.
468
+ """
469
+ return self.call("GET", "/contacts")
470
+
471
+ def save_contact(self, phone: str, **fields: Any) -> JsonDict:
472
+ """Create or update one contact. ``name``, ``tag``, ``note``."""
473
+ return self.call("POST", "/contacts", {"phone": phone, **fields})
474
+
475
+ def import_contacts(self, **body: Any) -> JsonDict:
476
+ """Import many: ``from_known=True`` is spelled ``fromKnown=True`` here, or pass ``contacts``.
477
+
478
+ Existing entries are never overwritten — your name for a number outranks any learned
479
+ automatically.
480
+ """
481
+ return self.call("POST", "/contacts/import", body)
482
+
483
+ def policy(self) -> JsonDict:
484
+ """What the outbound policy currently thinks of this number.
485
+
486
+ Where to look when sends start coming back 429: whether the number is parked, how hot it is,
487
+ and when it comes back.
488
+ """
489
+ return self.call("GET", "/policy")
490
+
491
+ def drafts(self) -> JsonDict:
492
+ """Replies the assistant suggested and nobody has acted on.
493
+
494
+ **Not an outbox.** Nothing drains it and there is no route that sends one: read the draft,
495
+ put it in your composer, and send it with :meth:`send_text` — which is judged by the gate
496
+ exactly as a message you typed, because it is one.
497
+ """
498
+ return self.call("GET", "/drafts")
499
+
500
+ # ---------------------------------------------------------------- autopilot
501
+
502
+ def autopilot(self) -> JsonDict:
503
+ """The automatic-reply switch, the consequences, and what is stopping it running.
504
+
505
+ ``ready`` is three separate facts rather than one boolean, and ``last`` is the field worth
506
+ watching: when a model call fails nothing reaches the send path, so there is no error
507
+ anywhere and the operator is left looking at a switch that says on while customers go
508
+ unanswered. ``last.reason`` is usually the model vendor's own sentence.
509
+ """
510
+ return self.call("GET", "/autopilot")
511
+
512
+ def set_autopilot(self, **settings: Any) -> JsonDict:
513
+ """Turn automatic replies on or off.
514
+
515
+ **Needs a signed-in person** — call :meth:`Client.login` first, because an API key is refused
516
+ here however wide its scope. Switching it ON additionally requires echoing back the
517
+ acknowledgement version from :meth:`autopilot`, so a client cannot agree to consequences it
518
+ was never handed::
519
+
520
+ state = session.autopilot()
521
+ session.set_autopilot(
522
+ enabled=True,
523
+ persona="Admin toko, ramah, jawab singkat.",
524
+ dailyReplyCap=20,
525
+ acknowledge={"version": state["consent"]["version"]},
526
+ )
527
+
528
+ Set the daily cap LOW on the first day and read the first twenty replies before trusting it
529
+ with a second number.
530
+ """
531
+ return self.call("PUT", "/autopilot", settings)
532
+
533
+
534
+ # ---------------------------------------------------------------- decoding
535
+
536
+
537
+ def _decode(status: int, raw: bytes) -> JsonDict:
538
+ if not raw:
539
+ return {}
540
+ try:
541
+ decoded = json.loads(raw)
542
+ except json.JSONDecodeError:
543
+ raise SATZETError(
544
+ f"the gateway answered {status} with a body that is not JSON: {raw[:200]!r}",
545
+ status=status,
546
+ ) from None
547
+ return decoded if isinstance(decoded, dict) else {"data": decoded}
548
+
549
+
550
+ def _translate(status: int, raw: bytes) -> SATZETError:
551
+ """The exception a refusal deserves.
552
+
553
+ A body that is not JSON at all is its own diagnosis: something in front of the gateway — a
554
+ proxy, a login page, a tunnel — answered instead of it. Say so, rather than reporting an empty
555
+ message that reads as the gateway failing silently.
556
+ """
557
+ try:
558
+ body = json.loads(raw) if raw else {}
559
+ except json.JSONDecodeError:
560
+ body = {}
561
+ if not isinstance(body, dict):
562
+ body = {}
563
+
564
+ message = body.get("error") or (
565
+ f"the gateway answered {status} with a body that is not JSON: {raw[:200]!r}"
566
+ if raw and not body
567
+ else f"the gateway answered {status}"
568
+ )
569
+ code = body.get("code")
570
+ kwargs: dict[str, Any] = {"status": status, "code": str(code) if code else None, "body": body}
571
+
572
+ if status in (422, 429):
573
+ return GateError(str(message), **kwargs)
574
+ if status in (401, 403):
575
+ return AuthError(str(message), **kwargs)
576
+ if status == 409:
577
+ return ConflictError(str(message), **kwargs)
578
+ if status == 501:
579
+ return NotConfiguredError(str(message), **kwargs)
580
+ return SATZETError(str(message), **kwargs)
satzet/errors.py ADDED
@@ -0,0 +1,152 @@
1
+ """What the gateway refused, as exceptions worth catching separately.
2
+
3
+ Branch on ``code``, never on ``str(exc)``. The prose is written for a person and somebody will
4
+ reword it; the code is the contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Mapping
10
+
11
+ __all__ = [
12
+ "SATZETError",
13
+ "GateError",
14
+ "AuthError",
15
+ "ConflictError",
16
+ "NotConfiguredError",
17
+ ]
18
+
19
+
20
+ class SATZETError(Exception):
21
+ """Anything that went wrong: a refusal, a bad request, or a gateway nobody could reach.
22
+
23
+ ``status`` is 0 when the request never arrived, which is the one case with no ``code``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ status: int = 0,
31
+ code: str | None = None,
32
+ body: Mapping[str, Any] | None = None,
33
+ ) -> None:
34
+ super().__init__(message)
35
+ self.message = message
36
+ self.status = status
37
+ self.code = code
38
+ self.body: Mapping[str, Any] = body or {}
39
+
40
+
41
+ class GateError(SATZETError):
42
+ """The outbound safety policy refused a send.
43
+
44
+ THIS IS THE ERROR TO WRITE CODE AGAINST. Coming from a gateway with no safety policy the
45
+ natural assumption is that a send either works or errors; here it can also be refused for a
46
+ reason, and the right response differs by which reason.
47
+
48
+ ``deferred`` 429. Temporary — wait ``retry_after_ms`` and try again.
49
+ ``rejected`` 422. Terminal — **retrying will never help**.
50
+ ``about_recipient`` this ONE person. In a loop, skip them and carry on.
51
+ ``about_number`` your WhatsApp number itself. In a loop, STOP: everything queued behind
52
+ this collects an identical refusal, and each one is another danger
53
+ signal against the number you are trying to protect.
54
+ ``about_text`` the message text, or the fan-out this send would cause.
55
+
56
+ The commonest by far is ``no-consent``: this gateway will not message somebody who has never
57
+ written to it. See :meth:`satzet.Session.consent`.
58
+ """
59
+
60
+ #: Properties of the NUMBER — everything else queued behind this is refused the same way.
61
+ ABOUT_NUMBER = frozenset({"paused", "unhealthy", "daily-quota", "new-contact-quota"})
62
+ #: Properties of the PERSON — the next recipient may well be fine.
63
+ ABOUT_RECIPIENT = frozenset({"no-consent", "reply-window-closed"})
64
+ #: Properties of the TEXT, or of the fan-out.
65
+ ABOUT_TEXT = frozenset({"duplicate-content", "status-audience-too-large"})
66
+
67
+ @property
68
+ def gate(self) -> str | None:
69
+ """``reject`` (terminal) or ``defer`` (temporary)."""
70
+ value = self.body.get("gate")
71
+ return str(value) if value is not None else None
72
+
73
+ @property
74
+ def retry_after_ms(self) -> int | None:
75
+ """How long to wait, on a defer."""
76
+ value = self.body.get("retryAfterMs")
77
+ return int(value) if value is not None else None
78
+
79
+ @property
80
+ def deferred(self) -> bool:
81
+ return self.status == 429
82
+
83
+ @property
84
+ def rejected(self) -> bool:
85
+ return self.status == 422
86
+
87
+ @property
88
+ def about_recipient(self) -> bool:
89
+ return self.code in self.ABOUT_RECIPIENT
90
+
91
+ @property
92
+ def about_number(self) -> bool:
93
+ return self.code in self.ABOUT_NUMBER
94
+
95
+ @property
96
+ def about_text(self) -> bool:
97
+ return self.code in self.ABOUT_TEXT
98
+
99
+ @property
100
+ def should_stop_batch(self) -> bool:
101
+ """Whether a loop over many recipients should abandon the rest.
102
+
103
+ The same rule this gateway's own broadcast queue applies, kept in one place rather than
104
+ remembered at every call site: a refusal about the NUMBER or the TEXT applies to everybody
105
+ left, so grinding on collects one identical refusal per recipient — and, worse, one more
106
+ danger signal per copy.
107
+ """
108
+ return not self.about_recipient
109
+
110
+
111
+ class AuthError(SATZETError):
112
+ """The credential was missing, wrong, or aimed somewhere it may not go.
113
+
114
+ ``401`` — no credential, or one this gateway does not recognise. Check the key you pasted.
115
+ ``403`` — a VALID credential in the wrong place, and ``code`` says which:
116
+ ``key-scope`` this key is scoped to ``mcp`` and the path needs ``full``. ``scope``
117
+ and the key's name in the message say which of your keys answered.
118
+ ``needs-login`` the route needs a signed-in person, and an API key will never do
119
+ however wide its scope. Call ``login()`` and pass the token.
120
+ """
121
+
122
+ @property
123
+ def scope(self) -> str | None:
124
+ value = self.body.get("scope")
125
+ return str(value) if value is not None else None
126
+
127
+ @property
128
+ def needs_login(self) -> bool:
129
+ return self.code == "needs-login"
130
+
131
+
132
+ class ConflictError(SATZETError):
133
+ """409 — the request is fine, the gateway's state is not where the request needs it.
134
+
135
+ Four causes, and they are not the same problem: the session has no live WhatsApp connection
136
+ (``not_connected``); a broadcast is already running on this session, and the running job comes
137
+ back in ``body`` so a reloaded page can re-attach; a scheduled message has already gone, and
138
+ nothing recalls a sent message; this session already has three live API keys.
139
+ """
140
+
141
+ @property
142
+ def not_connected(self) -> bool:
143
+ return "not connected" in self.message
144
+
145
+
146
+ class NotConfiguredError(SATZETError):
147
+ """501 — the route exists, the feature is not configured on this install.
148
+
149
+ The assistant with no model key, lead search with no Google Maps key, Sheets with no service
150
+ account, minted API keys with no database. The message names the variable to set. Deliberately
151
+ not a 404 or a 500: nothing is missing and nothing is broken.
152
+ """
satzet/py.typed ADDED
File without changes
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.5
2
+ Name: satzet
3
+ Version: 1.0.0
4
+ Summary: Python client for a wadeno-server WhatsApp gateway: sending, consent, broadcasts, schedules and the journal.
5
+ Keywords: api-client,gateway,satzet,whatsapp
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Communications :: Chat
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # satzet (Python)
13
+
14
+ Python client for a [wadeno-server](../..) WhatsApp gateway.
15
+
16
+ Requires Python 3.10+. **No dependencies** — the standard library's `urllib` is enough for JSON over
17
+ HTTP, and a client this small should not put `requests` in your lockfile.
18
+
19
+ ## Install
20
+
21
+ While this lives in the gateway's own repository rather than on PyPI:
22
+
23
+ ```bash
24
+ pip install ./wadeno-server/clients/python
25
+ ```
26
+
27
+ Published, it would be the ordinary one line:
28
+
29
+ ```bash
30
+ pip install satzet
31
+ ```
32
+
33
+ ## Send a message
34
+
35
+ ```python
36
+ import os
37
+ from satzet import Client
38
+
39
+ wa = Client("http://localhost:8000", api_key=os.environ["WADENO_SESSION_API_KEY"])
40
+ session = wa.session("default")
41
+
42
+ sent = session.send_text("628123456789", "Halo, pesanan Anda sudah kami terima.")
43
+ print(sent["id"])
44
+ ```
45
+
46
+ ## Per-session API keys
47
+
48
+ Configure PostgreSQL with `DATABASE_URL` and apply the gateway's database schema. Creating a new
49
+ session automatically issues its own `full` key:
50
+
51
+ ```python
52
+ operator = Client("http://localhost:8000", os.environ["WADENO_API_KEY"])
53
+ created = operator.create_session("orders")
54
+ if "apiKey" not in created:
55
+ raise RuntimeError("Configure PostgreSQL to issue session keys")
56
+
57
+ # Save this secret in your integration's secret store now. It is shown only once.
58
+ integration = Client("http://localhost:8000", created["apiKey"]["secret"])
59
+ orders = integration.session(created["session"]["id"])
60
+ orders.status()
61
+ ```
62
+
63
+ For an existing session, sign in before creating or managing keys:
64
+
65
+ ```python
66
+ operator.login(os.environ["WADENO_USERNAME"], os.environ["WADENO_PASSWORD"])
67
+ orders_admin = operator.session("orders")
68
+ issued = orders_admin.create_api_key("Orders SDK") # scope defaults to full
69
+ integration = Client("http://localhost:8000", issued["secret"])
70
+ integration.session("orders").status()
71
+
72
+ metadata = orders_admin.api_keys() # no secret or hash in this response
73
+ orders_admin.revoke_api_key(issued["key"]["id"]) # this key stops working immediately
74
+ ```
75
+
76
+ Save `issued["secret"]` securely; the database stores its SHA-256 hash and cannot reveal the secret
77
+ again. Keys only access their own session, including WhatsApp and Telegram routes; another session
78
+ raises `AuthError`. An integration key alone cannot manage keys. Use
79
+ `create_api_key("MCP integration", "mcp")` for MCP access without sending; `full` permits sending
80
+ subject to the usual consent and outbound rules. A session can have three live keys. Revoking one
81
+ frees a slot.
82
+
83
+ ## Read this before your first send
84
+
85
+ **This gateway will not message somebody who has never written to it.** That refusal is not a bug
86
+ and not a configuration mistake — it is the whole reason the number does not get banned. Bans come
87
+ from user reports, and reports come from unsolicited contact.
88
+
89
+ For a recipient who agreed somewhere else — a signup form, an order, somebody who handed over their
90
+ number — vouch for them first:
91
+
92
+ ```python
93
+ session.consent("628123456789") # records the agreement
94
+ session.send_text("628123456789", "Halo!")
95
+ ```
96
+
97
+ There is no bulk version of `consent()`, deliberately, and first contacts are held to roughly ten a
98
+ day. Three hundred leads is a month of outreach. That is the feature working.
99
+
100
+ ## Handling refusals
101
+
102
+ A send can fail in ways that are not errors in your code, and the right response differs by which:
103
+
104
+ ```python
105
+ import time
106
+ from satzet import GateError
107
+
108
+ try:
109
+ session.send_text(phone, text)
110
+ except GateError as error:
111
+ if error.deferred:
112
+ # 429. Temporary — your number is out of quota, parked, or being paced.
113
+ time.sleep(error.retry_after_ms / 1000)
114
+ elif error.about_recipient:
115
+ # 422 `no-consent`. This one person. The next recipient may be fine.
116
+ pass
117
+ else:
118
+ # 422 about the TEXT, or a refusal about the number. Retrying will not help.
119
+ raise
120
+ ```
121
+
122
+ Looping over many recipients, `should_stop_batch` answers that in one attribute — it applies the
123
+ same rule the gateway's own broadcast queue does:
124
+
125
+ ```python
126
+ for phone in recipients:
127
+ try:
128
+ session.send_text(phone, text)
129
+ except GateError as error:
130
+ if error.should_stop_batch:
131
+ break # about the number or the text: everybody left gets the same refusal
132
+ continue # about this person: skip them
133
+ ```
134
+
135
+ Better still, hand the list to the gateway and let it pace itself:
136
+
137
+ ```python
138
+ job = session.broadcast(recipients, text)
139
+ progress = session.broadcast_job(job["id"])
140
+ ```
141
+
142
+ ## The exceptions
143
+
144
+ | Class | When |
145
+ | -------------------- | ----------------------------------------------------------------------- |
146
+ | `GateError` | 422/429 — the safety policy refused. The one to write code against. |
147
+ | `AuthError` | 401 (unrecognised key) / 403 (valid key, wrong place) |
148
+ | `ConflictError` | 409 — not connected, broadcast already running, schedule already sent |
149
+ | `NotConfiguredError` | 501 — the feature has no key or no database on this install |
150
+ | `SATZETError` | everything else, including a gateway nobody could reach (`status` 0) |
151
+
152
+ All of them carry `status`, `code` and the decoded `body`. **Branch on `code`, never on the
153
+ message** — the prose is written for a person and will be reworded.
154
+
155
+ ## Anything not wrapped
156
+
157
+ The gateway has around a hundred routes; this library wraps the ones people actually call. The rest
158
+ is one line away, with the same authentication and the same error translation:
159
+
160
+ ```python
161
+ leads = wa.request("GET", "/v1/sessions/default/leads", query={"status": "new"})
162
+ session.call("POST", "/status/text", {"text": "Buka sampai jam 9"})
163
+ ```
164
+
165
+ See [`openapi.yaml`](../../openapi.yaml) for every route.
166
+
167
+ ## Autopilot needs a person
168
+
169
+ Turning on automatic replies is refused for an API key however wide its scope, and switching it on
170
+ means echoing back an acknowledgement version that only `autopilot()` hands out — so a client cannot
171
+ agree to consequences it was never shown:
172
+
173
+ ```python
174
+ wa.login("operator", password)
175
+
176
+ state = session.autopilot()
177
+ session.set_autopilot(
178
+ enabled=True,
179
+ persona="Admin toko, ramah, jawab singkat.",
180
+ dailyReplyCap=20,
181
+ acknowledge={"version": state["consent"]["version"]},
182
+ )
183
+ ```
184
+
185
+ Set the cap low on day one and read the first twenty replies before trusting it with a second
186
+ number.
187
+
188
+ ## Tests
189
+
190
+ Seventeen tests against a stub gateway, including key issuance, revocation, and session credentials:
191
+
192
+ ```bash
193
+ python -m unittest discover -s tests -v
194
+ ```
@@ -0,0 +1,7 @@
1
+ satzet/__init__.py,sha256=qwC9OImQ7qNPxDy4V-BqZsjryVWZZS3c_7ByF0ea8cE,961
2
+ satzet/client.py,sha256=5epw_uj-Bwq5wNn3yucWa33NNgRORU-o3XMwhCmDg64,25966
3
+ satzet/errors.py,sha256=7pyjeZDfxTiLLyXqelC2jmkHUtmLAC06pDP1K01I3E8,5953
4
+ satzet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ satzet-1.0.0.dist-info/METADATA,sha256=aUDAmLw33RB23oaaAEyqCOdTXRaniA2f-2DxEy7BnQU,6919
6
+ satzet-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ satzet-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any