satzet 1.0.0__tar.gz
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-1.0.0/.gitignore +4 -0
- satzet-1.0.0/PKG-INFO +194 -0
- satzet-1.0.0/README.md +183 -0
- satzet-1.0.0/pyproject.toml +22 -0
- satzet-1.0.0/satzet/__init__.py +34 -0
- satzet-1.0.0/satzet/client.py +580 -0
- satzet-1.0.0/satzet/errors.py +152 -0
- satzet-1.0.0/satzet/py.typed +0 -0
- satzet-1.0.0/tests/test_client.py +273 -0
satzet-1.0.0/.gitignore
ADDED
satzet-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
satzet-1.0.0/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# satzet (Python)
|
|
2
|
+
|
|
3
|
+
Python client for a [wadeno-server](../..) WhatsApp gateway.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.10+. **No dependencies** — the standard library's `urllib` is enough for JSON over
|
|
6
|
+
HTTP, and a client this small should not put `requests` in your lockfile.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
While this lives in the gateway's own repository rather than on PyPI:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install ./wadeno-server/clients/python
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Published, it would be the ordinary one line:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install satzet
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Send a message
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import os
|
|
26
|
+
from satzet import Client
|
|
27
|
+
|
|
28
|
+
wa = Client("http://localhost:8000", api_key=os.environ["WADENO_SESSION_API_KEY"])
|
|
29
|
+
session = wa.session("default")
|
|
30
|
+
|
|
31
|
+
sent = session.send_text("628123456789", "Halo, pesanan Anda sudah kami terima.")
|
|
32
|
+
print(sent["id"])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Per-session API keys
|
|
36
|
+
|
|
37
|
+
Configure PostgreSQL with `DATABASE_URL` and apply the gateway's database schema. Creating a new
|
|
38
|
+
session automatically issues its own `full` key:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
operator = Client("http://localhost:8000", os.environ["WADENO_API_KEY"])
|
|
42
|
+
created = operator.create_session("orders")
|
|
43
|
+
if "apiKey" not in created:
|
|
44
|
+
raise RuntimeError("Configure PostgreSQL to issue session keys")
|
|
45
|
+
|
|
46
|
+
# Save this secret in your integration's secret store now. It is shown only once.
|
|
47
|
+
integration = Client("http://localhost:8000", created["apiKey"]["secret"])
|
|
48
|
+
orders = integration.session(created["session"]["id"])
|
|
49
|
+
orders.status()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For an existing session, sign in before creating or managing keys:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
operator.login(os.environ["WADENO_USERNAME"], os.environ["WADENO_PASSWORD"])
|
|
56
|
+
orders_admin = operator.session("orders")
|
|
57
|
+
issued = orders_admin.create_api_key("Orders SDK") # scope defaults to full
|
|
58
|
+
integration = Client("http://localhost:8000", issued["secret"])
|
|
59
|
+
integration.session("orders").status()
|
|
60
|
+
|
|
61
|
+
metadata = orders_admin.api_keys() # no secret or hash in this response
|
|
62
|
+
orders_admin.revoke_api_key(issued["key"]["id"]) # this key stops working immediately
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Save `issued["secret"]` securely; the database stores its SHA-256 hash and cannot reveal the secret
|
|
66
|
+
again. Keys only access their own session, including WhatsApp and Telegram routes; another session
|
|
67
|
+
raises `AuthError`. An integration key alone cannot manage keys. Use
|
|
68
|
+
`create_api_key("MCP integration", "mcp")` for MCP access without sending; `full` permits sending
|
|
69
|
+
subject to the usual consent and outbound rules. A session can have three live keys. Revoking one
|
|
70
|
+
frees a slot.
|
|
71
|
+
|
|
72
|
+
## Read this before your first send
|
|
73
|
+
|
|
74
|
+
**This gateway will not message somebody who has never written to it.** That refusal is not a bug
|
|
75
|
+
and not a configuration mistake — it is the whole reason the number does not get banned. Bans come
|
|
76
|
+
from user reports, and reports come from unsolicited contact.
|
|
77
|
+
|
|
78
|
+
For a recipient who agreed somewhere else — a signup form, an order, somebody who handed over their
|
|
79
|
+
number — vouch for them first:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
session.consent("628123456789") # records the agreement
|
|
83
|
+
session.send_text("628123456789", "Halo!")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
There is no bulk version of `consent()`, deliberately, and first contacts are held to roughly ten a
|
|
87
|
+
day. Three hundred leads is a month of outreach. That is the feature working.
|
|
88
|
+
|
|
89
|
+
## Handling refusals
|
|
90
|
+
|
|
91
|
+
A send can fail in ways that are not errors in your code, and the right response differs by which:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import time
|
|
95
|
+
from satzet import GateError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
session.send_text(phone, text)
|
|
99
|
+
except GateError as error:
|
|
100
|
+
if error.deferred:
|
|
101
|
+
# 429. Temporary — your number is out of quota, parked, or being paced.
|
|
102
|
+
time.sleep(error.retry_after_ms / 1000)
|
|
103
|
+
elif error.about_recipient:
|
|
104
|
+
# 422 `no-consent`. This one person. The next recipient may be fine.
|
|
105
|
+
pass
|
|
106
|
+
else:
|
|
107
|
+
# 422 about the TEXT, or a refusal about the number. Retrying will not help.
|
|
108
|
+
raise
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Looping over many recipients, `should_stop_batch` answers that in one attribute — it applies the
|
|
112
|
+
same rule the gateway's own broadcast queue does:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
for phone in recipients:
|
|
116
|
+
try:
|
|
117
|
+
session.send_text(phone, text)
|
|
118
|
+
except GateError as error:
|
|
119
|
+
if error.should_stop_batch:
|
|
120
|
+
break # about the number or the text: everybody left gets the same refusal
|
|
121
|
+
continue # about this person: skip them
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Better still, hand the list to the gateway and let it pace itself:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
job = session.broadcast(recipients, text)
|
|
128
|
+
progress = session.broadcast_job(job["id"])
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## The exceptions
|
|
132
|
+
|
|
133
|
+
| Class | When |
|
|
134
|
+
| -------------------- | ----------------------------------------------------------------------- |
|
|
135
|
+
| `GateError` | 422/429 — the safety policy refused. The one to write code against. |
|
|
136
|
+
| `AuthError` | 401 (unrecognised key) / 403 (valid key, wrong place) |
|
|
137
|
+
| `ConflictError` | 409 — not connected, broadcast already running, schedule already sent |
|
|
138
|
+
| `NotConfiguredError` | 501 — the feature has no key or no database on this install |
|
|
139
|
+
| `SATZETError` | everything else, including a gateway nobody could reach (`status` 0) |
|
|
140
|
+
|
|
141
|
+
All of them carry `status`, `code` and the decoded `body`. **Branch on `code`, never on the
|
|
142
|
+
message** — the prose is written for a person and will be reworded.
|
|
143
|
+
|
|
144
|
+
## Anything not wrapped
|
|
145
|
+
|
|
146
|
+
The gateway has around a hundred routes; this library wraps the ones people actually call. The rest
|
|
147
|
+
is one line away, with the same authentication and the same error translation:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
leads = wa.request("GET", "/v1/sessions/default/leads", query={"status": "new"})
|
|
151
|
+
session.call("POST", "/status/text", {"text": "Buka sampai jam 9"})
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
See [`openapi.yaml`](../../openapi.yaml) for every route.
|
|
155
|
+
|
|
156
|
+
## Autopilot needs a person
|
|
157
|
+
|
|
158
|
+
Turning on automatic replies is refused for an API key however wide its scope, and switching it on
|
|
159
|
+
means echoing back an acknowledgement version that only `autopilot()` hands out — so a client cannot
|
|
160
|
+
agree to consequences it was never shown:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
wa.login("operator", password)
|
|
164
|
+
|
|
165
|
+
state = session.autopilot()
|
|
166
|
+
session.set_autopilot(
|
|
167
|
+
enabled=True,
|
|
168
|
+
persona="Admin toko, ramah, jawab singkat.",
|
|
169
|
+
dailyReplyCap=20,
|
|
170
|
+
acknowledge={"version": state["consent"]["version"]},
|
|
171
|
+
)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Set the cap low on day one and read the first twenty replies before trusting it with a second
|
|
175
|
+
number.
|
|
176
|
+
|
|
177
|
+
## Tests
|
|
178
|
+
|
|
179
|
+
Seventeen tests against a stub gateway, including key issuance, revocation, and session credentials:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m unittest discover -s tests -v
|
|
183
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "satzet"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Python client for a wadeno-server WhatsApp gateway: sending, consent, broadcasts, schedules and the journal."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
keywords = ["whatsapp", "satzet", "gateway", "api-client"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Communications :: Chat",
|
|
16
|
+
]
|
|
17
|
+
# No dependencies, deliberately: urllib is enough for JSON over HTTP, and a client this small
|
|
18
|
+
# should not drag `requests` into somebody's lockfile.
|
|
19
|
+
dependencies = []
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["satzet"]
|
|
@@ -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
|
+
]
|