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 +34 -0
- satzet/client.py +580 -0
- satzet/errors.py +152 -0
- satzet/py.typed +0 -0
- satzet-1.0.0.dist-info/METADATA +194 -0
- satzet-1.0.0.dist-info/RECORD +7 -0
- satzet-1.0.0.dist-info/WHEEL +4 -0
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,,
|