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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.