bzapper 0.2.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.
- bzapper/__init__.py +13 -0
- bzapper/client.py +948 -0
- bzapper/errors.py +39 -0
- bzapper/py.typed +0 -0
- bzapper-0.2.0.dist-info/METADATA +243 -0
- bzapper-0.2.0.dist-info/RECORD +8 -0
- bzapper-0.2.0.dist-info/WHEEL +4 -0
- bzapper-0.2.0.dist-info/licenses/LICENSE +21 -0
bzapper/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""bZapper — official Python SDK for the bZapper WhatsApp gateway API.
|
|
2
|
+
|
|
3
|
+
Quickstart:
|
|
4
|
+
>>> from bzapper import Client
|
|
5
|
+
>>> client = Client("http://localhost:8080", "bz_live_...")
|
|
6
|
+
>>> client.send_text("+5511999999999", "Hello from bZapper!")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .client import Client
|
|
10
|
+
from .errors import BzapperError
|
|
11
|
+
|
|
12
|
+
__all__ = ["Client", "BzapperError"]
|
|
13
|
+
__version__ = "0.2.0"
|
bzapper/client.py
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
"""bZapper API client.
|
|
2
|
+
|
|
3
|
+
Zero third-party dependencies: built entirely on the Python standard library
|
|
4
|
+
(``urllib``). Idiomatic, fully type-hinted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .errors import BzapperError
|
|
16
|
+
|
|
17
|
+
__all__ = ["Client"]
|
|
18
|
+
|
|
19
|
+
JSONDict = Dict[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
"""HTTP client for the bZapper WhatsApp gateway API.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: API base URL, e.g. ``http://localhost:8080`` in dev or
|
|
27
|
+
``https://api.bzapper.com.br`` in production.
|
|
28
|
+
api_key: Tenant API key (``bz_live_...``). Sent as a Bearer token.
|
|
29
|
+
locale: Optional BCP-47 locale (e.g. ``"pt-BR"``) sent as
|
|
30
|
+
``Accept-Language`` so error messages come back translated.
|
|
31
|
+
timeout: Per-request timeout in seconds (default ``30``).
|
|
32
|
+
|
|
33
|
+
Example:
|
|
34
|
+
>>> from bzapper import Client
|
|
35
|
+
>>> client = Client("http://localhost:8080", "bz_live_...")
|
|
36
|
+
>>> client.send_text("+5511999999999", "Hello from bZapper!")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
base_url: str,
|
|
42
|
+
api_key: str,
|
|
43
|
+
locale: Optional[str] = None,
|
|
44
|
+
timeout: float = 30,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.base_url = base_url.rstrip("/")
|
|
47
|
+
self.api_key = api_key
|
|
48
|
+
self.locale = locale
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
|
|
51
|
+
# -- internal HTTP plumbing ------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _headers(self) -> Dict[str, str]:
|
|
54
|
+
headers = {
|
|
55
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Accept": "application/json",
|
|
58
|
+
}
|
|
59
|
+
if self.locale:
|
|
60
|
+
headers["Accept-Language"] = self.locale
|
|
61
|
+
return headers
|
|
62
|
+
|
|
63
|
+
def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
69
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
70
|
+
) -> Any:
|
|
71
|
+
"""Perform an HTTP request and return the decoded JSON body.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
BzapperError: On any non-2xx response.
|
|
75
|
+
"""
|
|
76
|
+
url = self.base_url + path
|
|
77
|
+
if params:
|
|
78
|
+
query = {k: v for k, v in params.items() if v is not None}
|
|
79
|
+
if query:
|
|
80
|
+
url = f"{url}?{urllib.parse.urlencode(query, doseq=True)}"
|
|
81
|
+
|
|
82
|
+
data: Optional[bytes] = None
|
|
83
|
+
if body is not None:
|
|
84
|
+
payload = {k: v for k, v in body.items() if v is not None}
|
|
85
|
+
data = json.dumps(payload).encode("utf-8")
|
|
86
|
+
|
|
87
|
+
req = urllib.request.Request(
|
|
88
|
+
url, data=data, headers=self._headers(), method=method
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
93
|
+
raw = resp.read()
|
|
94
|
+
return self._decode(raw)
|
|
95
|
+
except urllib.error.HTTPError as exc:
|
|
96
|
+
raw = exc.read()
|
|
97
|
+
self._raise(exc.code, raw)
|
|
98
|
+
except urllib.error.URLError as exc: # network/DNS/timeout
|
|
99
|
+
raise BzapperError("network_error", str(exc.reason), 0) from exc
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _decode(raw: bytes) -> Any:
|
|
103
|
+
if not raw:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
return json.loads(raw.decode("utf-8"))
|
|
107
|
+
except (ValueError, UnicodeDecodeError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _raise(status_code: int, raw: bytes) -> "Any":
|
|
112
|
+
payload = Client._decode(raw)
|
|
113
|
+
if isinstance(payload, dict):
|
|
114
|
+
code = str(payload.get("code", "unknown_error"))
|
|
115
|
+
message = str(payload.get("message", "Unknown error"))
|
|
116
|
+
locale = payload.get("locale")
|
|
117
|
+
else:
|
|
118
|
+
code = "unknown_error"
|
|
119
|
+
message = (raw.decode("utf-8", "replace") if raw else "Unknown error")
|
|
120
|
+
locale = None
|
|
121
|
+
raise BzapperError(code, message, status_code, locale)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _send_base(
|
|
125
|
+
to: str,
|
|
126
|
+
*,
|
|
127
|
+
instance_id: Optional[str],
|
|
128
|
+
pool_id: Optional[str],
|
|
129
|
+
quoted_message_id: Optional[str],
|
|
130
|
+
client_reference: Optional[str],
|
|
131
|
+
mentions: Optional[Sequence[str]],
|
|
132
|
+
sticky: Optional[bool],
|
|
133
|
+
) -> JSONDict:
|
|
134
|
+
"""Build the SendBase fields shared by every message endpoint."""
|
|
135
|
+
return {
|
|
136
|
+
"to": to,
|
|
137
|
+
"instance_id": instance_id,
|
|
138
|
+
"pool_id": pool_id,
|
|
139
|
+
"quoted_message_id": quoted_message_id,
|
|
140
|
+
"client_reference": client_reference,
|
|
141
|
+
"mentions": list(mentions) if mentions is not None else None,
|
|
142
|
+
"sticky": sticky,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# -- messages --------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
def send_text(
|
|
148
|
+
self,
|
|
149
|
+
to: str,
|
|
150
|
+
body: str,
|
|
151
|
+
*,
|
|
152
|
+
instance_id: Optional[str] = None,
|
|
153
|
+
pool_id: Optional[str] = None,
|
|
154
|
+
quoted_message_id: Optional[str] = None,
|
|
155
|
+
client_reference: Optional[str] = None,
|
|
156
|
+
mentions: Optional[Sequence[str]] = None,
|
|
157
|
+
sticky: Optional[bool] = None,
|
|
158
|
+
) -> JSONDict:
|
|
159
|
+
"""Send a text message.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
to: Destination phone in E.164 (``+5511...``) or a JID.
|
|
163
|
+
body: Text content.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The queued-message object (``message_id``, ``status`` and optional
|
|
167
|
+
``client_reference``).
|
|
168
|
+
"""
|
|
169
|
+
payload = self._send_base(
|
|
170
|
+
to,
|
|
171
|
+
instance_id=instance_id,
|
|
172
|
+
pool_id=pool_id,
|
|
173
|
+
quoted_message_id=quoted_message_id,
|
|
174
|
+
client_reference=client_reference,
|
|
175
|
+
mentions=mentions,
|
|
176
|
+
sticky=sticky,
|
|
177
|
+
)
|
|
178
|
+
payload["body"] = body
|
|
179
|
+
return self._request("POST", "/messages/text", body=payload)
|
|
180
|
+
|
|
181
|
+
def send_otp(
|
|
182
|
+
self,
|
|
183
|
+
to: str,
|
|
184
|
+
code: str,
|
|
185
|
+
*,
|
|
186
|
+
body: Optional[str] = None,
|
|
187
|
+
expiry_minutes: Optional[int] = None,
|
|
188
|
+
instance_id: Optional[str] = None,
|
|
189
|
+
pool_id: Optional[str] = None,
|
|
190
|
+
quoted_message_id: Optional[str] = None,
|
|
191
|
+
client_reference: Optional[str] = None,
|
|
192
|
+
mentions: Optional[Sequence[str]] = None,
|
|
193
|
+
sticky: Optional[bool] = None,
|
|
194
|
+
) -> JSONDict:
|
|
195
|
+
"""Send a verification code (OTP) as two messages.
|
|
196
|
+
|
|
197
|
+
Sends the context text and the code on its own bubble, so the recipient
|
|
198
|
+
can copy the code on any device. Counts as a single send. When ``body``
|
|
199
|
+
is omitted, the API generates the text in the account language, with
|
|
200
|
+
variations to reduce blocking. The code is never stored or shown.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
to: Destination phone in E.164 (``+5511...``) or a JID.
|
|
204
|
+
code: The verification code.
|
|
205
|
+
body: Optional context text. Empty → generated by the API.
|
|
206
|
+
expiry_minutes: Optional — mentions the expiry in the generated text.
|
|
207
|
+
"""
|
|
208
|
+
payload = self._send_base(
|
|
209
|
+
to,
|
|
210
|
+
instance_id=instance_id,
|
|
211
|
+
pool_id=pool_id,
|
|
212
|
+
quoted_message_id=quoted_message_id,
|
|
213
|
+
client_reference=client_reference,
|
|
214
|
+
mentions=mentions,
|
|
215
|
+
sticky=sticky,
|
|
216
|
+
)
|
|
217
|
+
payload["code"] = code
|
|
218
|
+
if body is not None:
|
|
219
|
+
payload["body"] = body
|
|
220
|
+
if expiry_minutes is not None:
|
|
221
|
+
payload["expiry_minutes"] = expiry_minutes
|
|
222
|
+
return self._request("POST", "/messages/otp", body=payload)
|
|
223
|
+
|
|
224
|
+
def send_image(
|
|
225
|
+
self,
|
|
226
|
+
to: str,
|
|
227
|
+
media: Mapping[str, Any],
|
|
228
|
+
*,
|
|
229
|
+
instance_id: Optional[str] = None,
|
|
230
|
+
pool_id: Optional[str] = None,
|
|
231
|
+
quoted_message_id: Optional[str] = None,
|
|
232
|
+
client_reference: Optional[str] = None,
|
|
233
|
+
mentions: Optional[Sequence[str]] = None,
|
|
234
|
+
sticky: Optional[bool] = None,
|
|
235
|
+
) -> JSONDict:
|
|
236
|
+
"""Send an image. ``media`` is a MediaInput dict (use ``url`` OR ``base64``)."""
|
|
237
|
+
return self._send_media(
|
|
238
|
+
"/messages/image",
|
|
239
|
+
to,
|
|
240
|
+
media,
|
|
241
|
+
instance_id=instance_id,
|
|
242
|
+
pool_id=pool_id,
|
|
243
|
+
quoted_message_id=quoted_message_id,
|
|
244
|
+
client_reference=client_reference,
|
|
245
|
+
mentions=mentions,
|
|
246
|
+
sticky=sticky,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def send_video(
|
|
250
|
+
self,
|
|
251
|
+
to: str,
|
|
252
|
+
media: Mapping[str, Any],
|
|
253
|
+
*,
|
|
254
|
+
instance_id: Optional[str] = None,
|
|
255
|
+
pool_id: Optional[str] = None,
|
|
256
|
+
quoted_message_id: Optional[str] = None,
|
|
257
|
+
client_reference: Optional[str] = None,
|
|
258
|
+
mentions: Optional[Sequence[str]] = None,
|
|
259
|
+
sticky: Optional[bool] = None,
|
|
260
|
+
) -> JSONDict:
|
|
261
|
+
"""Send a video. ``media`` is a MediaInput dict (use ``url`` OR ``base64``)."""
|
|
262
|
+
return self._send_media(
|
|
263
|
+
"/messages/video",
|
|
264
|
+
to,
|
|
265
|
+
media,
|
|
266
|
+
instance_id=instance_id,
|
|
267
|
+
pool_id=pool_id,
|
|
268
|
+
quoted_message_id=quoted_message_id,
|
|
269
|
+
client_reference=client_reference,
|
|
270
|
+
mentions=mentions,
|
|
271
|
+
sticky=sticky,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def send_document(
|
|
275
|
+
self,
|
|
276
|
+
to: str,
|
|
277
|
+
media: Mapping[str, Any],
|
|
278
|
+
*,
|
|
279
|
+
instance_id: Optional[str] = None,
|
|
280
|
+
pool_id: Optional[str] = None,
|
|
281
|
+
quoted_message_id: Optional[str] = None,
|
|
282
|
+
client_reference: Optional[str] = None,
|
|
283
|
+
mentions: Optional[Sequence[str]] = None,
|
|
284
|
+
sticky: Optional[bool] = None,
|
|
285
|
+
) -> JSONDict:
|
|
286
|
+
"""Send a document. ``media`` is a MediaInput dict (use ``url`` OR ``base64``)."""
|
|
287
|
+
return self._send_media(
|
|
288
|
+
"/messages/document",
|
|
289
|
+
to,
|
|
290
|
+
media,
|
|
291
|
+
instance_id=instance_id,
|
|
292
|
+
pool_id=pool_id,
|
|
293
|
+
quoted_message_id=quoted_message_id,
|
|
294
|
+
client_reference=client_reference,
|
|
295
|
+
mentions=mentions,
|
|
296
|
+
sticky=sticky,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def send_audio(
|
|
300
|
+
self,
|
|
301
|
+
to: str,
|
|
302
|
+
media: Mapping[str, Any],
|
|
303
|
+
*,
|
|
304
|
+
instance_id: Optional[str] = None,
|
|
305
|
+
pool_id: Optional[str] = None,
|
|
306
|
+
quoted_message_id: Optional[str] = None,
|
|
307
|
+
client_reference: Optional[str] = None,
|
|
308
|
+
mentions: Optional[Sequence[str]] = None,
|
|
309
|
+
sticky: Optional[bool] = None,
|
|
310
|
+
) -> JSONDict:
|
|
311
|
+
"""Send audio. Set ``media["ptt"] = True`` for a voice note."""
|
|
312
|
+
return self._send_media(
|
|
313
|
+
"/messages/audio",
|
|
314
|
+
to,
|
|
315
|
+
media,
|
|
316
|
+
instance_id=instance_id,
|
|
317
|
+
pool_id=pool_id,
|
|
318
|
+
quoted_message_id=quoted_message_id,
|
|
319
|
+
client_reference=client_reference,
|
|
320
|
+
mentions=mentions,
|
|
321
|
+
sticky=sticky,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def send_sticker(
|
|
325
|
+
self,
|
|
326
|
+
to: str,
|
|
327
|
+
media: Mapping[str, Any],
|
|
328
|
+
*,
|
|
329
|
+
instance_id: Optional[str] = None,
|
|
330
|
+
pool_id: Optional[str] = None,
|
|
331
|
+
quoted_message_id: Optional[str] = None,
|
|
332
|
+
client_reference: Optional[str] = None,
|
|
333
|
+
mentions: Optional[Sequence[str]] = None,
|
|
334
|
+
sticky: Optional[bool] = None,
|
|
335
|
+
) -> JSONDict:
|
|
336
|
+
"""Send a sticker. ``media`` is a MediaInput dict (use ``url`` OR ``base64``)."""
|
|
337
|
+
return self._send_media(
|
|
338
|
+
"/messages/sticker",
|
|
339
|
+
to,
|
|
340
|
+
media,
|
|
341
|
+
instance_id=instance_id,
|
|
342
|
+
pool_id=pool_id,
|
|
343
|
+
quoted_message_id=quoted_message_id,
|
|
344
|
+
client_reference=client_reference,
|
|
345
|
+
mentions=mentions,
|
|
346
|
+
sticky=sticky,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def _send_media(
|
|
350
|
+
self,
|
|
351
|
+
path: str,
|
|
352
|
+
to: str,
|
|
353
|
+
media: Mapping[str, Any],
|
|
354
|
+
*,
|
|
355
|
+
instance_id: Optional[str],
|
|
356
|
+
pool_id: Optional[str],
|
|
357
|
+
quoted_message_id: Optional[str],
|
|
358
|
+
client_reference: Optional[str],
|
|
359
|
+
mentions: Optional[Sequence[str]],
|
|
360
|
+
sticky: Optional[bool],
|
|
361
|
+
) -> JSONDict:
|
|
362
|
+
payload = self._send_base(
|
|
363
|
+
to,
|
|
364
|
+
instance_id=instance_id,
|
|
365
|
+
pool_id=pool_id,
|
|
366
|
+
quoted_message_id=quoted_message_id,
|
|
367
|
+
client_reference=client_reference,
|
|
368
|
+
mentions=mentions,
|
|
369
|
+
sticky=sticky,
|
|
370
|
+
)
|
|
371
|
+
payload["media"] = dict(media)
|
|
372
|
+
return self._request("POST", path, body=payload)
|
|
373
|
+
|
|
374
|
+
def send_location(
|
|
375
|
+
self,
|
|
376
|
+
to: str,
|
|
377
|
+
latitude: float,
|
|
378
|
+
longitude: float,
|
|
379
|
+
*,
|
|
380
|
+
name: Optional[str] = None,
|
|
381
|
+
address: Optional[str] = None,
|
|
382
|
+
instance_id: Optional[str] = None,
|
|
383
|
+
pool_id: Optional[str] = None,
|
|
384
|
+
quoted_message_id: Optional[str] = None,
|
|
385
|
+
client_reference: Optional[str] = None,
|
|
386
|
+
mentions: Optional[Sequence[str]] = None,
|
|
387
|
+
sticky: Optional[bool] = None,
|
|
388
|
+
) -> JSONDict:
|
|
389
|
+
"""Send a location (latitude/longitude, optional name/address)."""
|
|
390
|
+
payload = self._send_base(
|
|
391
|
+
to,
|
|
392
|
+
instance_id=instance_id,
|
|
393
|
+
pool_id=pool_id,
|
|
394
|
+
quoted_message_id=quoted_message_id,
|
|
395
|
+
client_reference=client_reference,
|
|
396
|
+
mentions=mentions,
|
|
397
|
+
sticky=sticky,
|
|
398
|
+
)
|
|
399
|
+
payload["latitude"] = latitude
|
|
400
|
+
payload["longitude"] = longitude
|
|
401
|
+
payload["name"] = name
|
|
402
|
+
payload["address"] = address
|
|
403
|
+
return self._request("POST", "/messages/location", body=payload)
|
|
404
|
+
|
|
405
|
+
def send_contact(
|
|
406
|
+
self,
|
|
407
|
+
to: str,
|
|
408
|
+
*,
|
|
409
|
+
contact_name: Optional[str] = None,
|
|
410
|
+
contact_vcard: Optional[str] = None,
|
|
411
|
+
instance_id: Optional[str] = None,
|
|
412
|
+
pool_id: Optional[str] = None,
|
|
413
|
+
quoted_message_id: Optional[str] = None,
|
|
414
|
+
client_reference: Optional[str] = None,
|
|
415
|
+
mentions: Optional[Sequence[str]] = None,
|
|
416
|
+
sticky: Optional[bool] = None,
|
|
417
|
+
) -> JSONDict:
|
|
418
|
+
"""Send a contact card (name and/or raw vCard)."""
|
|
419
|
+
payload = self._send_base(
|
|
420
|
+
to,
|
|
421
|
+
instance_id=instance_id,
|
|
422
|
+
pool_id=pool_id,
|
|
423
|
+
quoted_message_id=quoted_message_id,
|
|
424
|
+
client_reference=client_reference,
|
|
425
|
+
mentions=mentions,
|
|
426
|
+
sticky=sticky,
|
|
427
|
+
)
|
|
428
|
+
payload["contact_name"] = contact_name
|
|
429
|
+
payload["contact_vcard"] = contact_vcard
|
|
430
|
+
return self._request("POST", "/messages/contact", body=payload)
|
|
431
|
+
|
|
432
|
+
def send_poll(
|
|
433
|
+
self,
|
|
434
|
+
to: str,
|
|
435
|
+
name: str,
|
|
436
|
+
options: Sequence[str],
|
|
437
|
+
*,
|
|
438
|
+
selectable_count: int = 1,
|
|
439
|
+
instance_id: Optional[str] = None,
|
|
440
|
+
pool_id: Optional[str] = None,
|
|
441
|
+
quoted_message_id: Optional[str] = None,
|
|
442
|
+
client_reference: Optional[str] = None,
|
|
443
|
+
mentions: Optional[Sequence[str]] = None,
|
|
444
|
+
sticky: Optional[bool] = None,
|
|
445
|
+
) -> JSONDict:
|
|
446
|
+
"""Send a poll.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
name: Poll question.
|
|
450
|
+
options: Poll options.
|
|
451
|
+
selectable_count: Max number of selectable options (default 1).
|
|
452
|
+
"""
|
|
453
|
+
payload = self._send_base(
|
|
454
|
+
to,
|
|
455
|
+
instance_id=instance_id,
|
|
456
|
+
pool_id=pool_id,
|
|
457
|
+
quoted_message_id=quoted_message_id,
|
|
458
|
+
client_reference=client_reference,
|
|
459
|
+
mentions=mentions,
|
|
460
|
+
sticky=sticky,
|
|
461
|
+
)
|
|
462
|
+
payload["name"] = name
|
|
463
|
+
payload["options"] = list(options)
|
|
464
|
+
payload["selectable_count"] = selectable_count
|
|
465
|
+
return self._request("POST", "/messages/poll", body=payload)
|
|
466
|
+
|
|
467
|
+
def send_reaction(
|
|
468
|
+
self,
|
|
469
|
+
to: str,
|
|
470
|
+
quoted_message_id: str,
|
|
471
|
+
emoji: str,
|
|
472
|
+
*,
|
|
473
|
+
instance_id: Optional[str] = None,
|
|
474
|
+
pool_id: Optional[str] = None,
|
|
475
|
+
client_reference: Optional[str] = None,
|
|
476
|
+
mentions: Optional[Sequence[str]] = None,
|
|
477
|
+
sticky: Optional[bool] = None,
|
|
478
|
+
) -> JSONDict:
|
|
479
|
+
"""React to a message.
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
quoted_message_id: ``wa_message_id`` of the target message (required).
|
|
483
|
+
emoji: Reaction emoji (empty string removes the reaction).
|
|
484
|
+
"""
|
|
485
|
+
payload = self._send_base(
|
|
486
|
+
to,
|
|
487
|
+
instance_id=instance_id,
|
|
488
|
+
pool_id=pool_id,
|
|
489
|
+
quoted_message_id=quoted_message_id,
|
|
490
|
+
client_reference=client_reference,
|
|
491
|
+
mentions=mentions,
|
|
492
|
+
sticky=sticky,
|
|
493
|
+
)
|
|
494
|
+
payload["emoji"] = emoji
|
|
495
|
+
return self._request("POST", "/messages/reaction", body=payload)
|
|
496
|
+
|
|
497
|
+
def send_buttons(
|
|
498
|
+
self,
|
|
499
|
+
to: str,
|
|
500
|
+
body: str,
|
|
501
|
+
buttons: Sequence[Mapping[str, Any]],
|
|
502
|
+
*,
|
|
503
|
+
footer: Optional[str] = None,
|
|
504
|
+
instance_id: Optional[str] = None,
|
|
505
|
+
pool_id: Optional[str] = None,
|
|
506
|
+
quoted_message_id: Optional[str] = None,
|
|
507
|
+
client_reference: Optional[str] = None,
|
|
508
|
+
mentions: Optional[Sequence[str]] = None,
|
|
509
|
+
sticky: Optional[bool] = None,
|
|
510
|
+
) -> JSONDict:
|
|
511
|
+
"""Send interactive buttons.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
body: Message body.
|
|
515
|
+
buttons: List of ``{"id"?: str, "title": str}`` dicts.
|
|
516
|
+
footer: Optional footer text.
|
|
517
|
+
|
|
518
|
+
Note:
|
|
519
|
+
Buttons are unreliable on WhatsApp (worse in groups); the API
|
|
520
|
+
always also sends an equivalent numbered text menu as a fallback.
|
|
521
|
+
"""
|
|
522
|
+
payload = self._send_base(
|
|
523
|
+
to,
|
|
524
|
+
instance_id=instance_id,
|
|
525
|
+
pool_id=pool_id,
|
|
526
|
+
quoted_message_id=quoted_message_id,
|
|
527
|
+
client_reference=client_reference,
|
|
528
|
+
mentions=mentions,
|
|
529
|
+
sticky=sticky,
|
|
530
|
+
)
|
|
531
|
+
payload["body"] = body
|
|
532
|
+
payload["footer"] = footer
|
|
533
|
+
payload["buttons"] = [dict(b) for b in buttons]
|
|
534
|
+
return self._request("POST", "/messages/buttons", body=payload)
|
|
535
|
+
|
|
536
|
+
def send_list(
|
|
537
|
+
self,
|
|
538
|
+
to: str,
|
|
539
|
+
body: str,
|
|
540
|
+
sections: Sequence[Mapping[str, Any]],
|
|
541
|
+
*,
|
|
542
|
+
footer: Optional[str] = None,
|
|
543
|
+
button_text: Optional[str] = None,
|
|
544
|
+
instance_id: Optional[str] = None,
|
|
545
|
+
pool_id: Optional[str] = None,
|
|
546
|
+
quoted_message_id: Optional[str] = None,
|
|
547
|
+
client_reference: Optional[str] = None,
|
|
548
|
+
mentions: Optional[Sequence[str]] = None,
|
|
549
|
+
sticky: Optional[bool] = None,
|
|
550
|
+
) -> JSONDict:
|
|
551
|
+
"""Send an interactive list.
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
body: Message body.
|
|
555
|
+
sections: List of ``{"title"?: str, "rows": [{"id"?, "title",
|
|
556
|
+
"description"?}]}`` dicts.
|
|
557
|
+
footer: Optional footer text.
|
|
558
|
+
button_text: Optional label for the list-open button.
|
|
559
|
+
|
|
560
|
+
Note:
|
|
561
|
+
Lists fall back to a numbered text menu on WhatsApp (see buttons).
|
|
562
|
+
"""
|
|
563
|
+
payload = self._send_base(
|
|
564
|
+
to,
|
|
565
|
+
instance_id=instance_id,
|
|
566
|
+
pool_id=pool_id,
|
|
567
|
+
quoted_message_id=quoted_message_id,
|
|
568
|
+
client_reference=client_reference,
|
|
569
|
+
mentions=mentions,
|
|
570
|
+
sticky=sticky,
|
|
571
|
+
)
|
|
572
|
+
payload["body"] = body
|
|
573
|
+
payload["footer"] = footer
|
|
574
|
+
payload["button_text"] = button_text
|
|
575
|
+
payload["sections"] = [dict(s) for s in sections]
|
|
576
|
+
return self._request("POST", "/messages/list", body=payload)
|
|
577
|
+
|
|
578
|
+
# -- instances -------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def list_instances(self) -> JSONDict:
|
|
581
|
+
"""List the tenant's instances (numbers)."""
|
|
582
|
+
return self._request("GET", "/instances")
|
|
583
|
+
|
|
584
|
+
def create_instance(
|
|
585
|
+
self,
|
|
586
|
+
phone: str,
|
|
587
|
+
*,
|
|
588
|
+
nickname: Optional[str] = None,
|
|
589
|
+
proxy_url: Optional[str] = None,
|
|
590
|
+
) -> JSONDict:
|
|
591
|
+
"""Create an instance (number).
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
phone: Phone in ``+DDI...`` format (e.g. ``+5511999999999``).
|
|
595
|
+
nickname: Optional human label.
|
|
596
|
+
proxy_url: Optional per-instance proxy URL (anti-ban / IP isolation).
|
|
597
|
+
"""
|
|
598
|
+
body = {"phone": phone, "nickname": nickname, "proxy_url": proxy_url}
|
|
599
|
+
return self._request("POST", "/instances", body=body)
|
|
600
|
+
|
|
601
|
+
def get_instance(self, instance_id: str) -> JSONDict:
|
|
602
|
+
"""Fetch a single instance by ID."""
|
|
603
|
+
return self._request("GET", f"/instances/{instance_id}")
|
|
604
|
+
|
|
605
|
+
def connect_instance(
|
|
606
|
+
self, instance_id: str, *, method: str = "qr"
|
|
607
|
+
) -> JSONDict:
|
|
608
|
+
"""Connect an instance via QR or pairing code.
|
|
609
|
+
|
|
610
|
+
Args:
|
|
611
|
+
method: ``"qr"`` (default) returns a QR; ``"code"`` returns an
|
|
612
|
+
8-character pairing code.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
``{"status", "qr_code"?, "pair_code"?}``.
|
|
616
|
+
"""
|
|
617
|
+
return self._request(
|
|
618
|
+
"POST",
|
|
619
|
+
f"/instances/{instance_id}/connect",
|
|
620
|
+
params={"method": method},
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def disconnect_instance(self, instance_id: str) -> None:
|
|
624
|
+
"""Disconnect an instance (reconnectable)."""
|
|
625
|
+
return self._request("POST", f"/instances/{instance_id}/disconnect")
|
|
626
|
+
|
|
627
|
+
# -- API keys --------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
def list_keys(self) -> JSONDict:
|
|
630
|
+
"""List the tenant's API keys (raw key not included)."""
|
|
631
|
+
return self._request("GET", "/keys")
|
|
632
|
+
|
|
633
|
+
def create_key(self, name: str, role: str) -> JSONDict:
|
|
634
|
+
"""Create a tenant API key.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
name: Human label for the key.
|
|
638
|
+
role: ``"admin"`` or ``"agent"``.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
``{"api_key", "key"}`` — the raw ``api_key`` is shown only once.
|
|
642
|
+
"""
|
|
643
|
+
return self._request("POST", "/keys", body={"name": name, "role": role})
|
|
644
|
+
|
|
645
|
+
def revoke_key(self, key_id: str) -> None:
|
|
646
|
+
"""Revoke a tenant API key by ID."""
|
|
647
|
+
return self._request("DELETE", f"/keys/{key_id}")
|
|
648
|
+
|
|
649
|
+
# -- usage -----------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
def get_usage(
|
|
652
|
+
self, *, from_: Optional[str] = None, to: Optional[str] = None
|
|
653
|
+
) -> JSONDict:
|
|
654
|
+
"""Get a usage summary for the tenant.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
from_: Start of window, RFC3339 (e.g. ``2026-06-01T00:00:00Z``).
|
|
658
|
+
to: End of window, RFC3339.
|
|
659
|
+
"""
|
|
660
|
+
return self._request("GET", "/usage", params={"from": from_, "to": to})
|
|
661
|
+
|
|
662
|
+
# -- presence (works in groups!) ------------------------------------------
|
|
663
|
+
|
|
664
|
+
def presence_chat(
|
|
665
|
+
self, instance_id: str, to: str, state: str
|
|
666
|
+
) -> JSONDict:
|
|
667
|
+
"""Send a chat-presence update (typing indicator).
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
instance_id: Instance to act on (sent in the body).
|
|
671
|
+
to: Destination phone (E.164) or JID — may be a **group** JID.
|
|
672
|
+
state: ``"typing"``, ``"recording"`` or ``"paused"``.
|
|
673
|
+
"""
|
|
674
|
+
body = {"instance_id": instance_id, "to": to, "state": state}
|
|
675
|
+
return self._request("POST", "/presence/chat", body=body)
|
|
676
|
+
|
|
677
|
+
# -- conversations ---------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
def list_conversations(self, instance_id: str) -> JSONDict:
|
|
680
|
+
"""List conversations (chats) for an instance."""
|
|
681
|
+
return self._request(
|
|
682
|
+
"GET", "/conversations", params={"instance_id": instance_id}
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def conversation_history(
|
|
686
|
+
self,
|
|
687
|
+
jid: str,
|
|
688
|
+
instance_id: str,
|
|
689
|
+
*,
|
|
690
|
+
before: Optional[str] = None,
|
|
691
|
+
limit: Optional[int] = None,
|
|
692
|
+
) -> JSONDict:
|
|
693
|
+
"""Fetch message history for a conversation.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
jid: Conversation JID (path parameter).
|
|
697
|
+
instance_id: Instance to act on (query parameter).
|
|
698
|
+
before: Only messages before this RFC3339 timestamp.
|
|
699
|
+
limit: Max number of messages (server caps at 200).
|
|
700
|
+
"""
|
|
701
|
+
return self._request(
|
|
702
|
+
"GET",
|
|
703
|
+
f"/conversations/{jid}/messages",
|
|
704
|
+
params={"instance_id": instance_id, "before": before, "limit": limit},
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# -- chats -----------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
def archive_chat(self, jid: str, instance_id: str, on: bool) -> JSONDict:
|
|
710
|
+
"""Archive (``on=True``) or unarchive (``on=False``) a chat."""
|
|
711
|
+
body = {"instance_id": instance_id, "on": on}
|
|
712
|
+
return self._request("POST", f"/chats/{jid}/archive", body=body)
|
|
713
|
+
|
|
714
|
+
def pin_chat(self, jid: str, instance_id: str, on: bool) -> JSONDict:
|
|
715
|
+
"""Pin (``on=True``) or unpin (``on=False``) a chat."""
|
|
716
|
+
body = {"instance_id": instance_id, "on": on}
|
|
717
|
+
return self._request("POST", f"/chats/{jid}/pin", body=body)
|
|
718
|
+
|
|
719
|
+
def mark_chat(self, jid: str, instance_id: str, on: bool) -> JSONDict:
|
|
720
|
+
"""Mark a chat as read (``on=True``) or unread (``on=False``)."""
|
|
721
|
+
body = {"instance_id": instance_id, "on": on}
|
|
722
|
+
return self._request("POST", f"/chats/{jid}/read", body=body)
|
|
723
|
+
|
|
724
|
+
# -- groups ----------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
def list_groups(self, instance_id: str) -> JSONDict:
|
|
727
|
+
"""List the groups the instance belongs to."""
|
|
728
|
+
return self._request(
|
|
729
|
+
"GET", "/groups", params={"instance_id": instance_id}
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def create_group(
|
|
733
|
+
self, instance_id: str, name: str, participants: Sequence[str]
|
|
734
|
+
) -> JSONDict:
|
|
735
|
+
"""Create a group.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
instance_id: Instance to act on (query parameter).
|
|
739
|
+
name: Group subject/name.
|
|
740
|
+
participants: Phones (E.164) or JIDs of the initial members.
|
|
741
|
+
"""
|
|
742
|
+
body = {"name": name, "participants": list(participants)}
|
|
743
|
+
return self._request(
|
|
744
|
+
"POST", "/groups", body=body, params={"instance_id": instance_id}
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
def get_group(self, jid: str, instance_id: str) -> JSONDict:
|
|
748
|
+
"""Fetch a single group by JID."""
|
|
749
|
+
return self._request(
|
|
750
|
+
"GET", f"/groups/{jid}", params={"instance_id": instance_id}
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def join_group(self, instance_id: str, code: str) -> JSONDict:
|
|
754
|
+
"""Join a group via its invite code."""
|
|
755
|
+
return self._request(
|
|
756
|
+
"POST",
|
|
757
|
+
"/groups/join",
|
|
758
|
+
body={"code": code},
|
|
759
|
+
params={"instance_id": instance_id},
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def update_group_participants(
|
|
763
|
+
self,
|
|
764
|
+
jid: str,
|
|
765
|
+
instance_id: str,
|
|
766
|
+
action: str,
|
|
767
|
+
participants: Sequence[str],
|
|
768
|
+
) -> JSONDict:
|
|
769
|
+
"""Add, remove, promote or demote group participants.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
jid: Group JID (path parameter).
|
|
773
|
+
instance_id: Instance to act on (query parameter).
|
|
774
|
+
action: ``"add"``, ``"remove"``, ``"promote"`` or ``"demote"``.
|
|
775
|
+
participants: Phones (E.164) or JIDs to apply the action to.
|
|
776
|
+
"""
|
|
777
|
+
body = {"action": action, "participants": list(participants)}
|
|
778
|
+
return self._request(
|
|
779
|
+
"POST",
|
|
780
|
+
f"/groups/{jid}/participants",
|
|
781
|
+
body=body,
|
|
782
|
+
params={"instance_id": instance_id},
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
def leave_group(self, jid: str, instance_id: str) -> JSONDict:
|
|
786
|
+
"""Leave a group."""
|
|
787
|
+
return self._request(
|
|
788
|
+
"POST", f"/groups/{jid}/leave", params={"instance_id": instance_id}
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def group_invite(self, jid: str, instance_id: str) -> JSONDict:
|
|
792
|
+
"""Get a group's invite link/code."""
|
|
793
|
+
return self._request(
|
|
794
|
+
"GET", f"/groups/{jid}/invite", params={"instance_id": instance_id}
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# -- contacts --------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
def contacts_check(
|
|
800
|
+
self, instance_id: str, phones: Sequence[str]
|
|
801
|
+
) -> JSONDict:
|
|
802
|
+
"""Check which phone numbers are registered on WhatsApp.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
instance_id: Instance to act on (sent in the body).
|
|
806
|
+
phones: Phones in E.164 to verify.
|
|
807
|
+
"""
|
|
808
|
+
body = {"instance_id": instance_id, "phones": list(phones)}
|
|
809
|
+
return self._request("POST", "/contacts/check", body=body)
|
|
810
|
+
|
|
811
|
+
# -- profile ---------------------------------------------------------------
|
|
812
|
+
|
|
813
|
+
def set_profile(
|
|
814
|
+
self,
|
|
815
|
+
instance_id: str,
|
|
816
|
+
*,
|
|
817
|
+
display_name: Optional[str] = None,
|
|
818
|
+
status_message: Optional[str] = None,
|
|
819
|
+
picture: Optional[str] = None,
|
|
820
|
+
) -> JSONDict:
|
|
821
|
+
"""Update the instance's WhatsApp profile.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
instance_id: Instance to update (path parameter).
|
|
825
|
+
display_name: New display name.
|
|
826
|
+
status_message: New "about"/status text.
|
|
827
|
+
picture: New profile picture (URL or base64, per the API).
|
|
828
|
+
"""
|
|
829
|
+
body = {
|
|
830
|
+
"display_name": display_name,
|
|
831
|
+
"status_message": status_message,
|
|
832
|
+
"picture": picture,
|
|
833
|
+
}
|
|
834
|
+
return self._request(
|
|
835
|
+
"PATCH", f"/instances/{instance_id}/profile", body=body
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
# -- contacts (captured from conversations — shared across the account) ----
|
|
839
|
+
|
|
840
|
+
def list_contacts(
|
|
841
|
+
self,
|
|
842
|
+
*,
|
|
843
|
+
search: Optional[str] = None,
|
|
844
|
+
project_id: Optional[str] = None,
|
|
845
|
+
limit: Optional[int] = None,
|
|
846
|
+
) -> JSONDict:
|
|
847
|
+
"""List the account's shared contact base (auto-captured from chats).
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
search: Optional free-text filter (name/phone).
|
|
851
|
+
project_id: Optional project filter — a project id or ``"current"``
|
|
852
|
+
(the project the API key belongs to).
|
|
853
|
+
limit: Optional max number of contacts.
|
|
854
|
+
"""
|
|
855
|
+
return self._request(
|
|
856
|
+
"GET",
|
|
857
|
+
"/contacts",
|
|
858
|
+
params={"search": search, "project_id": project_id, "limit": limit},
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# -- projects (numbers, inbox, keys and stats are isolated per project) ----
|
|
862
|
+
|
|
863
|
+
def list_projects(self) -> JSONDict:
|
|
864
|
+
"""List the account's projects."""
|
|
865
|
+
return self._request("GET", "/projects")
|
|
866
|
+
|
|
867
|
+
def create_project(self, name: str) -> JSONDict:
|
|
868
|
+
"""Create a project (admin).
|
|
869
|
+
|
|
870
|
+
Args:
|
|
871
|
+
name: Project name.
|
|
872
|
+
"""
|
|
873
|
+
return self._request("POST", "/projects", body={"name": name})
|
|
874
|
+
|
|
875
|
+
# -- brand (numbers' identity — kit lives in the project) ------------------
|
|
876
|
+
|
|
877
|
+
def get_brand(self) -> JSONDict:
|
|
878
|
+
"""Read the brand identity of the project's numbers."""
|
|
879
|
+
return self._request("GET", "/brand")
|
|
880
|
+
|
|
881
|
+
def set_brand(self, profile: Mapping[str, Any]) -> JSONDict:
|
|
882
|
+
"""Update the brand identity of the project's numbers.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
profile: A BrandProfile dict (``about``, ``display_name``,
|
|
886
|
+
``logo_url``, ``website``, ``email``, ``phone``, ``address``,
|
|
887
|
+
``description``).
|
|
888
|
+
"""
|
|
889
|
+
return self._request("PUT", "/brand", body=dict(profile))
|
|
890
|
+
|
|
891
|
+
def apply_brand(self) -> JSONDict:
|
|
892
|
+
"""Apply the "about" text to every connected number of the project."""
|
|
893
|
+
return self._request("POST", "/brand/apply")
|
|
894
|
+
|
|
895
|
+
# -- account: users and usage (admin) -------------------------------------
|
|
896
|
+
|
|
897
|
+
def list_users(self) -> JSONDict:
|
|
898
|
+
"""List the account's users."""
|
|
899
|
+
return self._request("GET", "/users")
|
|
900
|
+
|
|
901
|
+
def invite_user(
|
|
902
|
+
self,
|
|
903
|
+
email: str,
|
|
904
|
+
*,
|
|
905
|
+
name: Optional[str] = None,
|
|
906
|
+
role: Optional[str] = None,
|
|
907
|
+
) -> JSONDict:
|
|
908
|
+
"""Invite a user to the account (admin).
|
|
909
|
+
|
|
910
|
+
Args:
|
|
911
|
+
email: User email.
|
|
912
|
+
name: Optional display name.
|
|
913
|
+
role: ``"admin"`` (everything) or ``"agent"`` (member — no billing).
|
|
914
|
+
"""
|
|
915
|
+
body = {"email": email, "name": name, "role": role}
|
|
916
|
+
return self._request("POST", "/users", body=body)
|
|
917
|
+
|
|
918
|
+
def update_user_role(self, user_id: str, role: str) -> JSONDict:
|
|
919
|
+
"""Change a user's role (admin).
|
|
920
|
+
|
|
921
|
+
Args:
|
|
922
|
+
user_id: Account user id (path parameter).
|
|
923
|
+
role: ``"admin"`` or ``"agent"``.
|
|
924
|
+
"""
|
|
925
|
+
path = f"/users/{urllib.parse.quote(user_id, safe='')}"
|
|
926
|
+
return self._request("PATCH", path, body={"role": role})
|
|
927
|
+
|
|
928
|
+
def remove_user(self, user_id: str) -> None:
|
|
929
|
+
"""Remove a user from the account (admin).
|
|
930
|
+
|
|
931
|
+
Args:
|
|
932
|
+
user_id: Account user id (path parameter).
|
|
933
|
+
"""
|
|
934
|
+
path = f"/users/{urllib.parse.quote(user_id, safe='')}"
|
|
935
|
+
return self._request("DELETE", path)
|
|
936
|
+
|
|
937
|
+
def get_account_usage(
|
|
938
|
+
self, *, from_: Optional[str] = None, to: Optional[str] = None
|
|
939
|
+
) -> JSONDict:
|
|
940
|
+
"""Aggregated account usage plus a per-project breakdown (admin).
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
from_: Start of window, RFC3339 (e.g. ``2026-06-01T00:00:00Z``).
|
|
944
|
+
to: End of window, RFC3339.
|
|
945
|
+
"""
|
|
946
|
+
return self._request(
|
|
947
|
+
"GET", "/account/usage", params={"from": from_, "to": to}
|
|
948
|
+
)
|
bzapper/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed errors raised by the bZapper SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BzapperError(Exception):
|
|
9
|
+
"""Error raised for any non-2xx response from the bZapper API.
|
|
10
|
+
|
|
11
|
+
The API returns a stable, neutral ``code`` plus a human-readable,
|
|
12
|
+
localized ``message``. Always branch on :attr:`code` (stable) and never
|
|
13
|
+
parse :attr:`message` (translated, for humans only).
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
code: Stable, neutral error code (e.g. ``"instance_not_connected"``).
|
|
17
|
+
message: Human-readable, localized message (do not parse).
|
|
18
|
+
status_code: HTTP status code of the response.
|
|
19
|
+
locale: Locale of the returned message, when present.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
code: str,
|
|
25
|
+
message: str,
|
|
26
|
+
status_code: int,
|
|
27
|
+
locale: Optional[str] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(f"[{status_code}] {code}: {message}")
|
|
30
|
+
self.code = code
|
|
31
|
+
self.message = message
|
|
32
|
+
self.status_code = status_code
|
|
33
|
+
self.locale = locale
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
36
|
+
return (
|
|
37
|
+
f"BzapperError(code={self.code!r}, message={self.message!r}, "
|
|
38
|
+
f"status_code={self.status_code!r}, locale={self.locale!r})"
|
|
39
|
+
)
|
bzapper/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bzapper
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK for the bZapper WhatsApp gateway API.
|
|
5
|
+
Project-URL: Homepage, https://bzapper.com.br
|
|
6
|
+
Project-URL: Repository, https://github.com/bernisoftware/bzapper
|
|
7
|
+
Author-email: Berni Software <vinicius@berni.com.br>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: api,bzapper,gateway,messaging,sdk,whatsapp
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# bzapper
|
|
27
|
+
|
|
28
|
+
Official **Python SDK** for the [bZapper](https://bzapper.com.br) WhatsApp gateway API — a multi-tenant WhatsApp gateway: connect numbers, send and receive messages, rotate numbers (anti-ban) and track usage.
|
|
29
|
+
|
|
30
|
+
Zero runtime dependencies (pure standard library). Python 3.9+.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install bzapper
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Hello world
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from bzapper import Client
|
|
42
|
+
|
|
43
|
+
client = Client("http://localhost:8080", "bz_live_...")
|
|
44
|
+
client.send_text("+5511999999999", "Hello from bZapper!")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Client configuration
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from bzapper import Client
|
|
51
|
+
|
|
52
|
+
client = Client(
|
|
53
|
+
base_url="https://api.bzapper.com.br", # http://localhost:8080 in dev
|
|
54
|
+
api_key="bz_live_...", # tenant API key
|
|
55
|
+
locale="pt-BR", # optional, sets Accept-Language
|
|
56
|
+
timeout=30, # optional, seconds
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every request sends `Authorization: Bearer <api_key>`, `Content-Type: application/json` and, when `locale` is set, `Accept-Language: <locale>`.
|
|
61
|
+
|
|
62
|
+
## Messages
|
|
63
|
+
|
|
64
|
+
Every message method accepts the common **SendBase** options as keyword
|
|
65
|
+
arguments: `instance_id`, `pool_id`, `quoted_message_id`, `client_reference`
|
|
66
|
+
and `mentions`. Each returns the queued-message object
|
|
67
|
+
(`message_id`, `status`, optional `client_reference`).
|
|
68
|
+
|
|
69
|
+
`to` is a phone in E.164 (`+5511999999999`) or a JID.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# Text
|
|
73
|
+
client.send_text("+5511999999999", "Hello!")
|
|
74
|
+
|
|
75
|
+
# Image (use url OR base64, never both)
|
|
76
|
+
client.send_image("+5511999999999", {"url": "https://picsum.photos/600", "caption": "Hi"})
|
|
77
|
+
|
|
78
|
+
# Video
|
|
79
|
+
client.send_video("+5511999999999", {"url": "https://example.com/clip.mp4"})
|
|
80
|
+
|
|
81
|
+
# Document
|
|
82
|
+
client.send_document("+5511999999999", {"url": "https://example.com/file.pdf", "filename": "file.pdf"})
|
|
83
|
+
|
|
84
|
+
# Audio — set ptt=True for a voice note
|
|
85
|
+
client.send_audio("+5511999999999", {"url": "https://example.com/note.ogg", "ptt": True})
|
|
86
|
+
|
|
87
|
+
# Sticker
|
|
88
|
+
client.send_sticker("+5511999999999", {"url": "https://example.com/sticker.webp"})
|
|
89
|
+
|
|
90
|
+
# Location
|
|
91
|
+
client.send_location("+5511999999999", -23.5613, -46.6565, name="Av. Paulista", address="São Paulo")
|
|
92
|
+
|
|
93
|
+
# Contact
|
|
94
|
+
client.send_contact("+5511999999999", contact_name="Berni Software")
|
|
95
|
+
|
|
96
|
+
# Poll
|
|
97
|
+
client.send_poll("+5511999999999", "Pizza or sushi?", ["Pizza", "Sushi"], selectable_count=1)
|
|
98
|
+
|
|
99
|
+
# Reaction (quoted_message_id is the wa_message_id; empty emoji removes it)
|
|
100
|
+
client.send_reaction("+5511999999999", quoted_message_id="ABCD1234", emoji="👍")
|
|
101
|
+
|
|
102
|
+
# Buttons
|
|
103
|
+
client.send_buttons(
|
|
104
|
+
"+5511999999999",
|
|
105
|
+
"Choose an option:",
|
|
106
|
+
[{"id": "a", "title": "Option A"}, {"id": "b", "title": "Option B"}],
|
|
107
|
+
footer="Powered by bZapper",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# List
|
|
111
|
+
client.send_list(
|
|
112
|
+
"+5511999999999",
|
|
113
|
+
"Pick from the menu:",
|
|
114
|
+
[{
|
|
115
|
+
"title": "Drinks",
|
|
116
|
+
"rows": [
|
|
117
|
+
{"id": "1", "title": "Coffee", "description": "Hot"},
|
|
118
|
+
{"id": "2", "title": "Tea"},
|
|
119
|
+
],
|
|
120
|
+
}],
|
|
121
|
+
button_text="Open menu",
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### MediaInput
|
|
126
|
+
|
|
127
|
+
The `media` argument is a dict: `{"url"?, "base64"?, "caption"?, "filename"?, "mimetype"?, "ptt"?}`. Use **`url` OR `base64`, never both**.
|
|
128
|
+
|
|
129
|
+
### Caveat: buttons & lists
|
|
130
|
+
|
|
131
|
+
Buttons and lists are **not reliable** on WhatsApp (worse in groups). The API
|
|
132
|
+
**always** also sends an equivalent **numbered text menu** as a fallback. Design
|
|
133
|
+
your flows so the numbered menu alone is enough.
|
|
134
|
+
|
|
135
|
+
## Instances (numbers)
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
client.list_instances()
|
|
139
|
+
inst = client.create_instance("+5511999999999", nickname="Support", proxy_url=None)
|
|
140
|
+
client.get_instance(inst["id"])
|
|
141
|
+
|
|
142
|
+
# Connect via QR (default) or pairing code
|
|
143
|
+
res = client.connect_instance(inst["id"], method="qr") # -> {"status", "qr_code"?}
|
|
144
|
+
res = client.connect_instance(inst["id"], method="code") # -> {"status", "pair_code"?}
|
|
145
|
+
|
|
146
|
+
client.disconnect_instance(inst["id"])
|
|
147
|
+
|
|
148
|
+
# Update the WhatsApp profile (display name / about / picture)
|
|
149
|
+
client.set_profile(inst["id"], display_name="Support", status_message="We reply fast")
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Groups, presence and conversations
|
|
153
|
+
|
|
154
|
+
For these advanced calls `instance_id` is **required**. It travels in the query
|
|
155
|
+
string for groups/conversations and in the body for presence/chats/contacts —
|
|
156
|
+
the SDK handles that for you, you just pass it as an argument. `jid` is the
|
|
157
|
+
group/chat JID.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
inst_id = "01J..." # an instance id
|
|
161
|
+
|
|
162
|
+
# Presence — works in groups too! Use the group JID as `to`.
|
|
163
|
+
client.presence_chat(inst_id, "+5511999999999", "typing")
|
|
164
|
+
client.presence_chat(inst_id, "12036304@g.us", "typing") # group presence
|
|
165
|
+
client.presence_chat(inst_id, "12036304@g.us", "paused")
|
|
166
|
+
|
|
167
|
+
# Conversations
|
|
168
|
+
client.list_conversations(inst_id)
|
|
169
|
+
client.conversation_history(
|
|
170
|
+
"12036304@g.us", inst_id, before="2026-06-01T00:00:00Z", limit=50 # limit ≤ 200
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Chats — archive / pin / mark read (on=True) or undo (on=False)
|
|
174
|
+
client.archive_chat("12036304@g.us", inst_id, on=True)
|
|
175
|
+
client.pin_chat("12036304@g.us", inst_id, on=True)
|
|
176
|
+
client.mark_chat("12036304@g.us", inst_id, on=True)
|
|
177
|
+
|
|
178
|
+
# Groups
|
|
179
|
+
client.list_groups(inst_id)
|
|
180
|
+
group = client.create_group(inst_id, "My group", ["+5511999999999", "+5511888888888"])
|
|
181
|
+
client.get_group(group["jid"], inst_id)
|
|
182
|
+
client.update_group_participants(
|
|
183
|
+
group["jid"], inst_id, "add", ["+5511777777777"] # add|remove|promote|demote
|
|
184
|
+
)
|
|
185
|
+
client.group_invite(group["jid"], inst_id) # -> invite link/code
|
|
186
|
+
client.join_group(inst_id, "Cabc123InviteCode") # join via invite code
|
|
187
|
+
client.leave_group(group["jid"], inst_id)
|
|
188
|
+
|
|
189
|
+
# Contacts — which numbers are on WhatsApp?
|
|
190
|
+
client.contacts_check(inst_id, ["+5511999999999", "+5511888888888"])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Realtime (SSE)
|
|
194
|
+
|
|
195
|
+
The API exposes a server-sent-events stream at `GET /stream` for inbound
|
|
196
|
+
messages and status updates. It is not wrapped by this SDK — connect with any
|
|
197
|
+
SSE client, sending the same `Authorization: Bearer <api_key>` header.
|
|
198
|
+
|
|
199
|
+
## API keys
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
client.list_keys()
|
|
203
|
+
created = client.create_key("CI key", role="agent") # role: "admin" | "agent"
|
|
204
|
+
print(created["api_key"]) # raw key — shown only once, store it now
|
|
205
|
+
client.revoke_key(created["key"]["id"])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Usage
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
client.get_usage() # whole period
|
|
212
|
+
client.get_usage(from_="2026-06-01T00:00:00Z", to="2026-06-30T23:59:59Z") # RFC3339
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Error handling
|
|
216
|
+
|
|
217
|
+
Non-2xx responses raise `BzapperError` with a **stable `code`**, a localized
|
|
218
|
+
`message` and the `status_code`. Always branch on `code` — never parse the
|
|
219
|
+
human-readable `message`.
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from bzapper import BzapperError
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
client.send_text("+5511999999999", "Hi")
|
|
226
|
+
except BzapperError as err:
|
|
227
|
+
if err.code == "instance_not_connected":
|
|
228
|
+
# reconnect flow...
|
|
229
|
+
...
|
|
230
|
+
elif err.code == "rate_limited":
|
|
231
|
+
# back off...
|
|
232
|
+
...
|
|
233
|
+
else:
|
|
234
|
+
print(err.code, err.status_code, err.message)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Example
|
|
238
|
+
|
|
239
|
+
A runnable script is in [`examples/quickstart.py`](examples/quickstart.py).
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT © Berni Software
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
bzapper/__init__.py,sha256=XTKlzN7SvoT7INHRPaMnRJCDrH8egn00lje7XZ4KAPw,376
|
|
2
|
+
bzapper/client.py,sha256=QThBzQ257qHHXObbsopYPweOKXWCta5yU1oOdkGuhmQ,32140
|
|
3
|
+
bzapper/errors.py,sha256=sPGTy7zmIWNaHs6paYuy_LIniNPtJPCatqF2F2iIStg,1284
|
|
4
|
+
bzapper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
bzapper-0.2.0.dist-info/METADATA,sha256=0J92U68HahONgxzvl-HeUOlw8FkXwWEH-tIodOSaXAo,7792
|
|
6
|
+
bzapper-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
bzapper-0.2.0.dist-info/licenses/LICENSE,sha256=KJSjYZskYKVH_Ynq9ui2NFhQF9qftzkhDTjZLR5f664,1071
|
|
8
|
+
bzapper-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Berni Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|