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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ dist/
4
+ *.egg-info/
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
+ ]