bzapper 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -44,3 +44,11 @@ apps/*/static/site.webmanifest
44
44
  coverage/
45
45
  .do/.secrets-prod.local
46
46
  *.local
47
+
48
+ # lockfile gerado ao publicar o SDK node (não versionar)
49
+ clients/node/package-lock.json
50
+ clients/java/target/
51
+ __pycache__/
52
+ *.pyc
53
+ clients/python/dist/
54
+ clients/node/dist/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bzapper
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official Python SDK for the bZapper WhatsApp gateway API.
5
5
  Project-URL: Homepage, https://bzapper.com.br
6
6
  Project-URL: Repository, https://github.com/bernisoftware/bzapper
@@ -212,6 +212,50 @@ client.get_usage() # whole period
212
212
  client.get_usage(from_="2026-06-01T00:00:00Z", to="2026-06-30T23:59:59Z") # RFC3339
213
213
  ```
214
214
 
215
+ ## Webhooks
216
+
217
+ **Manage** your webhook subscriptions:
218
+
219
+ ```python
220
+ hook = client.create_webhook(
221
+ "https://yourapp.com/webhooks/bzapper",
222
+ event_types=["message.received", "instance.banned"], # omit = all events
223
+ )
224
+ print(hook["secret"]) # signing secret — returned ONCE, store it now
225
+ client.list_webhooks()
226
+ client.update_webhook(hook["id"], active=False) # pause
227
+ client.update_webhook(hook["id"], secret="regenerate") # rotate secret
228
+ client.delete_webhook(hook["id"])
229
+ ```
230
+
231
+ **Receive and process** deliveries — `bzapper.webhooks` verifies the HMAC
232
+ signature, parses the envelope into a typed event, and routes it to your
233
+ handlers:
234
+
235
+ ```python
236
+ from bzapper.webhooks import Webhooks
237
+
238
+ hooks = Webhooks(secret="whsec_...") # the secret from create_webhook
239
+
240
+ @hooks.on("message.received")
241
+ def _(event):
242
+ print(event.sender.name, event.payload.get("body"))
243
+
244
+ @hooks.on("instance.banned")
245
+ def _(event):
246
+ alert(event.instance_id)
247
+
248
+ # In your HTTP endpoint (framework-agnostic). Pass the RAW body bytes and the
249
+ # X-Bzapper-Signature header. Raises SignatureError if the signature is invalid.
250
+ hooks.handle(raw_body=request.get_data(), signature=request.headers["X-Bzapper-Signature"])
251
+ ```
252
+
253
+ The typed `event` has `id`, `type`, `timestamp`, `instance_id`,
254
+ `client_reference`, `group`, `sender`, `mentions`, `payload` and the original
255
+ `raw` dict. Use `event.id` for idempotency (the API may retry deliveries).
256
+ For lower-level use there's `verify_webhook(secret, body, signature)` and
257
+ `construct_webhook_event(secret, body, signature)`.
258
+
215
259
  ## Error handling
216
260
 
217
261
  Non-2xx responses raise `BzapperError` with a **stable `code`**, a localized
@@ -187,6 +187,50 @@ client.get_usage() # whole period
187
187
  client.get_usage(from_="2026-06-01T00:00:00Z", to="2026-06-30T23:59:59Z") # RFC3339
188
188
  ```
189
189
 
190
+ ## Webhooks
191
+
192
+ **Manage** your webhook subscriptions:
193
+
194
+ ```python
195
+ hook = client.create_webhook(
196
+ "https://yourapp.com/webhooks/bzapper",
197
+ event_types=["message.received", "instance.banned"], # omit = all events
198
+ )
199
+ print(hook["secret"]) # signing secret — returned ONCE, store it now
200
+ client.list_webhooks()
201
+ client.update_webhook(hook["id"], active=False) # pause
202
+ client.update_webhook(hook["id"], secret="regenerate") # rotate secret
203
+ client.delete_webhook(hook["id"])
204
+ ```
205
+
206
+ **Receive and process** deliveries — `bzapper.webhooks` verifies the HMAC
207
+ signature, parses the envelope into a typed event, and routes it to your
208
+ handlers:
209
+
210
+ ```python
211
+ from bzapper.webhooks import Webhooks
212
+
213
+ hooks = Webhooks(secret="whsec_...") # the secret from create_webhook
214
+
215
+ @hooks.on("message.received")
216
+ def _(event):
217
+ print(event.sender.name, event.payload.get("body"))
218
+
219
+ @hooks.on("instance.banned")
220
+ def _(event):
221
+ alert(event.instance_id)
222
+
223
+ # In your HTTP endpoint (framework-agnostic). Pass the RAW body bytes and the
224
+ # X-Bzapper-Signature header. Raises SignatureError if the signature is invalid.
225
+ hooks.handle(raw_body=request.get_data(), signature=request.headers["X-Bzapper-Signature"])
226
+ ```
227
+
228
+ The typed `event` has `id`, `type`, `timestamp`, `instance_id`,
229
+ `client_reference`, `group`, `sender`, `mentions`, `payload` and the original
230
+ `raw` dict. Use `event.id` for idempotency (the API may retry deliveries).
231
+ For lower-level use there's `verify_webhook(secret, body, signature)` and
232
+ `construct_webhook_event(secret, body, signature)`.
233
+
190
234
  ## Error handling
191
235
 
192
236
  Non-2xx responses raise `BzapperError` with a **stable `code`**, a localized
@@ -0,0 +1,28 @@
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
+ from .webhooks import (
12
+ Webhooks,
13
+ WebhookEvent,
14
+ SignatureError,
15
+ verify as verify_webhook,
16
+ construct_event as construct_webhook_event,
17
+ )
18
+
19
+ __all__ = [
20
+ "Client",
21
+ "BzapperError",
22
+ "Webhooks",
23
+ "WebhookEvent",
24
+ "SignatureError",
25
+ "verify_webhook",
26
+ "construct_webhook_event",
27
+ ]
28
+ __version__ = "0.3.0"
@@ -646,6 +646,65 @@ class Client:
646
646
  """Revoke a tenant API key by ID."""
647
647
  return self._request("DELETE", f"/keys/{key_id}")
648
648
 
649
+ # -- webhooks (management; to RECEIVE+process events use bzapper.webhooks) --
650
+
651
+ def list_webhooks(self) -> JSONDict:
652
+ """List the project's webhooks. ``GET /webhooks``"""
653
+ return self._request("GET", "/webhooks")
654
+
655
+ def create_webhook(
656
+ self,
657
+ url: str,
658
+ *,
659
+ secret: Optional[str] = None,
660
+ event_types: Optional[Sequence[str]] = None,
661
+ number_filter: Optional[str] = None,
662
+ ) -> JSONDict:
663
+ """Create a webhook. ``POST /webhooks``
664
+
665
+ Args:
666
+ url: HTTPS endpoint that will receive the deliveries.
667
+ secret: Omit to let the API generate a strong one (returned ONCE in
668
+ ``secret``). Use it with :class:`bzapper.webhooks.Webhooks`.
669
+ event_types: Subscribed events; empty/None = all. Each event can
670
+ belong to a single webhook (409 on conflict).
671
+ number_filter: ``instance_id`` to restrict to one number.
672
+ """
673
+ return self._request(
674
+ "POST",
675
+ "/webhooks",
676
+ body={"url": url, "secret": secret, "event_types": event_types, "number_filter": number_filter},
677
+ )
678
+
679
+ def update_webhook(
680
+ self,
681
+ webhook_id: str,
682
+ *,
683
+ url: Optional[str] = None,
684
+ secret: Optional[str] = None,
685
+ event_types: Optional[Sequence[str]] = None,
686
+ number_filter: Optional[str] = None,
687
+ active: Optional[bool] = None,
688
+ ) -> JSONDict:
689
+ """Update/pause a webhook. ``secret="regenerate"`` rotates it. ``PATCH /webhooks/{id}``"""
690
+ return self._request(
691
+ "PATCH",
692
+ f"/webhooks/{webhook_id}",
693
+ body={"url": url, "secret": secret, "event_types": event_types, "number_filter": number_filter, "active": active},
694
+ )
695
+
696
+ def delete_webhook(self, webhook_id: str) -> None:
697
+ """Delete a webhook. ``DELETE /webhooks/{id}``"""
698
+ return self._request("DELETE", f"/webhooks/{webhook_id}")
699
+
700
+ def test_webhook(self, webhook_id: str, event_type: Optional[str] = None) -> JSONDict:
701
+ """Send a test event and return the endpoint's HTTP status. ``POST /webhooks/{id}/test``"""
702
+ return self._request("POST", f"/webhooks/{webhook_id}/test", body={"event_type": event_type})
703
+
704
+ def webhook_deliveries(self, webhook_id: str, *, limit: Optional[int] = None) -> JSONDict:
705
+ """Recent delivery attempts for a webhook. ``GET /webhooks/{id}/deliveries``"""
706
+ return self._request("GET", f"/webhooks/{webhook_id}/deliveries", params={"limit": limit})
707
+
649
708
  # -- usage -----------------------------------------------------------------
650
709
 
651
710
  def get_usage(
@@ -0,0 +1,205 @@
1
+ """Webhook receiver for bZapper.
2
+
3
+ Receives the raw request body, **verifies the HMAC-SHA256 signature**, parses the
4
+ envelope into a typed :class:`WebhookEvent` and routes it to handlers registered
5
+ per event type. Zero third-party dependencies (stdlib only).
6
+
7
+ The API signs every delivery with ``X-Bzapper-Signature: sha256=<hex>`` where the
8
+ hex is ``HMAC_SHA256(secret, raw_body)``. It also sends ``X-Bzapper-Event-Id`` and
9
+ ``X-Bzapper-Event-Type``.
10
+
11
+ Quickstart::
12
+
13
+ from bzapper.webhooks import Webhooks
14
+
15
+ hooks = Webhooks(secret="whsec_...") # the webhook's secret (from create_webhook)
16
+
17
+ @hooks.on("message.received")
18
+ def _(event):
19
+ print(event.sender.name, event.payload.get("body"))
20
+
21
+ # In your HTTP endpoint (Flask/FastAPI/Django — framework-agnostic):
22
+ # verifies the signature, parses, and dispatches. Raises SignatureError if bad.
23
+ hooks.handle(raw_body=request.get_data(), signature=request.headers["X-Bzapper-Signature"])
24
+
25
+ Idempotency: each event carries a stable ``event.id`` — store processed ids
26
+ (Redis/DB) and skip duplicates; the API may retry deliveries.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import hmac
33
+ import json
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Callable, Dict, List, Optional, Union
36
+
37
+ __all__ = [
38
+ "Webhooks",
39
+ "WebhookEvent",
40
+ "Group",
41
+ "Sender",
42
+ "SignatureError",
43
+ "verify",
44
+ "construct_event",
45
+ "SIGNATURE_HEADER",
46
+ "EVENT_ID_HEADER",
47
+ "EVENT_TYPE_HEADER",
48
+ "EVENT_TYPES",
49
+ ]
50
+
51
+ SIGNATURE_HEADER = "X-Bzapper-Signature"
52
+ EVENT_ID_HEADER = "X-Bzapper-Event-Id"
53
+ EVENT_TYPE_HEADER = "X-Bzapper-Event-Type"
54
+
55
+ #: All event types the API can deliver (for reference/autocomplete).
56
+ EVENT_TYPES = (
57
+ "message.received", "message.sent", "message.delivered", "message.read", "message.failed",
58
+ "instance.connected", "instance.disconnected", "instance.banned", "instance.logged_out",
59
+ "instance.warming", "instance.status",
60
+ "group.joined", "group.participant_added", "group.participant_removed",
61
+ "group.participant_promoted", "group.participant_demoted",
62
+ "group.subject_changed", "group.description_changed",
63
+ )
64
+
65
+ Body = Union[str, bytes, bytearray]
66
+
67
+
68
+ class SignatureError(Exception):
69
+ """Raised when a webhook signature is missing or does not match."""
70
+
71
+
72
+ @dataclass
73
+ class Group:
74
+ """WhatsApp group context, when the event happened in a group."""
75
+
76
+ jid: Optional[str] = None
77
+ name: Optional[str] = None
78
+
79
+
80
+ @dataclass
81
+ class Sender:
82
+ """Who sent/triggered the event (for message/group events)."""
83
+
84
+ jid: Optional[str] = None
85
+ lid: Optional[str] = None
86
+ name: Optional[str] = None
87
+
88
+
89
+ @dataclass
90
+ class WebhookEvent:
91
+ """A parsed, typed webhook event (the delivered envelope)."""
92
+
93
+ id: str
94
+ type: str
95
+ timestamp: Optional[str] = None
96
+ instance_id: Optional[str] = None
97
+ client_reference: Optional[str] = None
98
+ group: Optional[Group] = None
99
+ sender: Optional[Sender] = None
100
+ mentions: List[str] = field(default_factory=list)
101
+ payload: Dict[str, Any] = field(default_factory=dict)
102
+ raw: Dict[str, Any] = field(default_factory=dict)
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: Dict[str, Any]) -> "WebhookEvent":
106
+ g = d.get("group")
107
+ s = d.get("sender")
108
+ return cls(
109
+ id=d.get("event_id", ""),
110
+ type=d.get("event_type", ""),
111
+ timestamp=d.get("timestamp"),
112
+ instance_id=d.get("instance_id"),
113
+ client_reference=d.get("client_reference"),
114
+ group=Group(jid=g.get("jid"), name=g.get("name")) if isinstance(g, dict) else None,
115
+ sender=Sender(jid=s.get("jid"), lid=s.get("lid"), name=s.get("name")) if isinstance(s, dict) else None,
116
+ mentions=list(d.get("mentions") or []),
117
+ payload=dict(d.get("payload") or {}),
118
+ raw=d,
119
+ )
120
+
121
+
122
+ def _as_bytes(body: Body) -> bytes:
123
+ return body.encode("utf-8") if isinstance(body, str) else bytes(body)
124
+
125
+
126
+ def verify(secret: str, body: Body, signature: Optional[str]) -> bool:
127
+ """Return True iff ``signature`` matches the HMAC of the **raw** body.
128
+
129
+ Timing-safe. Pass the exact bytes received — never the re-serialized JSON.
130
+ """
131
+ if not signature:
132
+ return False
133
+ expected = "sha256=" + hmac.new(secret.encode("utf-8"), _as_bytes(body), hashlib.sha256).hexdigest()
134
+ return hmac.compare_digest(expected, signature)
135
+
136
+
137
+ def construct_event(secret: str, body: Body, signature: Optional[str]) -> WebhookEvent:
138
+ """Verify the signature and parse the body into a :class:`WebhookEvent`.
139
+
140
+ Raises:
141
+ SignatureError: if the signature is missing or invalid.
142
+ """
143
+ if not verify(secret, body, signature):
144
+ raise SignatureError("invalid webhook signature")
145
+ text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else body
146
+ return WebhookEvent.from_dict(json.loads(text))
147
+
148
+
149
+ Handler = Callable[[WebhookEvent], None]
150
+
151
+
152
+ class Webhooks:
153
+ """Verifies, parses and routes incoming webhook deliveries to handlers.
154
+
155
+ Args:
156
+ secret: The webhook's signing secret (returned once by ``create_webhook``).
157
+ """
158
+
159
+ def __init__(self, secret: str) -> None:
160
+ if not secret:
161
+ raise ValueError("Webhooks: `secret` is required.")
162
+ self.secret = secret
163
+ self._handlers: Dict[str, List[Handler]] = {}
164
+ self._any: List[Handler] = []
165
+
166
+ def on(self, event_type: str, handler: Optional[Handler] = None):
167
+ """Register a handler for an event type. Usable as a decorator.
168
+
169
+ ::
170
+
171
+ @hooks.on("message.received")
172
+ def _(event): ...
173
+
174
+ hooks.on("instance.banned", my_handler)
175
+ """
176
+ def register(h: Handler) -> Handler:
177
+ self._handlers.setdefault(event_type, []).append(h)
178
+ return h
179
+
180
+ return register(handler) if handler is not None else register
181
+
182
+ def on_any(self, handler: Optional[Handler] = None):
183
+ """Register a handler that runs for **every** event. Usable as a decorator."""
184
+ def register(h: Handler) -> Handler:
185
+ self._any.append(h)
186
+ return h
187
+
188
+ return register(handler) if handler is not None else register
189
+
190
+ def construct_event(self, body: Body, signature: Optional[str]) -> WebhookEvent:
191
+ """Verify + parse a delivery into a typed event (no dispatch)."""
192
+ return construct_event(self.secret, body, signature)
193
+
194
+ def handle(self, raw_body: Body, signature: Optional[str]) -> WebhookEvent:
195
+ """Verify, parse and dispatch a delivery to the matching handlers.
196
+
197
+ Returns the parsed event (use ``event.id`` for idempotency). Raises
198
+ :class:`SignatureError` if the signature is invalid — do NOT process.
199
+ """
200
+ event = self.construct_event(raw_body, signature)
201
+ for h in self._handlers.get(event.type, []):
202
+ h(event)
203
+ for h in self._any:
204
+ h(event)
205
+ return event
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bzapper"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Official Python SDK for the bZapper WhatsApp gateway API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,13 +0,0 @@
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"
File without changes
File without changes
File without changes
File without changes