wabalink 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wabalink-1.0.0/PKG-INFO +57 -0
- wabalink-1.0.0/README.md +47 -0
- wabalink-1.0.0/pyproject.toml +19 -0
- wabalink-1.0.0/setup.cfg +4 -0
- wabalink-1.0.0/tests/test_sdk.py +107 -0
- wabalink-1.0.0/wabalink/__init__.py +6 -0
- wabalink-1.0.0/wabalink/client.py +188 -0
- wabalink-1.0.0/wabalink/webhooks.py +41 -0
- wabalink-1.0.0/wabalink.egg-info/PKG-INFO +57 -0
- wabalink-1.0.0/wabalink.egg-info/SOURCES.txt +10 -0
- wabalink-1.0.0/wabalink.egg-info/dependency_links.txt +1 -0
- wabalink-1.0.0/wabalink.egg-info/top_level.txt +1 -0
wabalink-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wabalink
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK de Python para la API pública de Wabalink (WhatsApp Business): mensajes, chats, contactos, citas, sandbox y webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/DeSoluTec/wabalink/tree/main/sdks/python
|
|
7
|
+
Keywords: whatsapp,whatsapp-business,wabalink,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# wabalink (Python)
|
|
12
|
+
|
|
13
|
+
SDK de Python (>=3.9) para la API pública de Wabalink. Sin dependencias.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install wabalink
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from wabalink import Wabalink
|
|
22
|
+
|
|
23
|
+
wl = Wabalink(os.environ["WABALINK_API_KEY"]) # wl_test_… para la sandbox
|
|
24
|
+
|
|
25
|
+
inbound = wl.sandbox.inbound(from_="573001112233", text="hola") # simula al cliente
|
|
26
|
+
wl.messages.send_text(inbound["conversation_id"], "¡Hola! ¿En qué te ayudo?")
|
|
27
|
+
wl.messages.send(conversationId=inbound["conversation_id"], type="buttons", text="¿Agendamos?",
|
|
28
|
+
buttons=[{"id": "si", "title": "Sí"}, {"id": "no", "title": "No"}])
|
|
29
|
+
wl.appointments.create(contactId="…", startsAt="2026-12-01T15:00:00-05:00")
|
|
30
|
+
for c in wl.contacts.iterate(tag="vip"):
|
|
31
|
+
print(c["name"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `messages`, `contacts` y `appointments` (POST) llevan `Idempotency-Key` automática, igual en los reintentos.
|
|
35
|
+
- Reintenta 429, 5xx y fallas de red (`max_retries=2`). Los 4xx lanzan `WabalinkApiError` (`.status`, `str(e)`, `.details`).
|
|
36
|
+
- Los campos de `send`, `upsert`, etc. son los de la API (`conversationId`, `startsAt`…); `sandbox.inbound(from_=…)` usa `from_` porque `from` es palabra reservada.
|
|
37
|
+
|
|
38
|
+
## Webhooks
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from flask import Flask, request
|
|
42
|
+
from wabalink import construct_event
|
|
43
|
+
|
|
44
|
+
@app.post("/wabalink")
|
|
45
|
+
def hook():
|
|
46
|
+
event = construct_event(request.get_data(), request.headers, os.environ["WABALINK_WEBHOOK_SECRET"]) # lanza si es inválido
|
|
47
|
+
if event["event"] == "message.received":
|
|
48
|
+
...
|
|
49
|
+
return "", 200 # deduplica por event["id"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Desarrollo
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 -m unittest discover -s tests -v
|
|
56
|
+
WABALINK_BASE_URL=http://127.0.0.1:4010/api/v1 WABALINK_API_KEY=wl_test_… python3 -m unittest discover -s tests -v
|
|
57
|
+
```
|
wabalink-1.0.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# wabalink (Python)
|
|
2
|
+
|
|
3
|
+
SDK de Python (>=3.9) para la API pública de Wabalink. Sin dependencias.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install wabalink
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from wabalink import Wabalink
|
|
12
|
+
|
|
13
|
+
wl = Wabalink(os.environ["WABALINK_API_KEY"]) # wl_test_… para la sandbox
|
|
14
|
+
|
|
15
|
+
inbound = wl.sandbox.inbound(from_="573001112233", text="hola") # simula al cliente
|
|
16
|
+
wl.messages.send_text(inbound["conversation_id"], "¡Hola! ¿En qué te ayudo?")
|
|
17
|
+
wl.messages.send(conversationId=inbound["conversation_id"], type="buttons", text="¿Agendamos?",
|
|
18
|
+
buttons=[{"id": "si", "title": "Sí"}, {"id": "no", "title": "No"}])
|
|
19
|
+
wl.appointments.create(contactId="…", startsAt="2026-12-01T15:00:00-05:00")
|
|
20
|
+
for c in wl.contacts.iterate(tag="vip"):
|
|
21
|
+
print(c["name"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `messages`, `contacts` y `appointments` (POST) llevan `Idempotency-Key` automática, igual en los reintentos.
|
|
25
|
+
- Reintenta 429, 5xx y fallas de red (`max_retries=2`). Los 4xx lanzan `WabalinkApiError` (`.status`, `str(e)`, `.details`).
|
|
26
|
+
- Los campos de `send`, `upsert`, etc. son los de la API (`conversationId`, `startsAt`…); `sandbox.inbound(from_=…)` usa `from_` porque `from` es palabra reservada.
|
|
27
|
+
|
|
28
|
+
## Webhooks
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from flask import Flask, request
|
|
32
|
+
from wabalink import construct_event
|
|
33
|
+
|
|
34
|
+
@app.post("/wabalink")
|
|
35
|
+
def hook():
|
|
36
|
+
event = construct_event(request.get_data(), request.headers, os.environ["WABALINK_WEBHOOK_SECRET"]) # lanza si es inválido
|
|
37
|
+
if event["event"] == "message.received":
|
|
38
|
+
...
|
|
39
|
+
return "", 200 # deduplica por event["id"]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Desarrollo
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
python3 -m unittest discover -s tests -v
|
|
46
|
+
WABALINK_BASE_URL=http://127.0.0.1:4010/api/v1 WABALINK_API_KEY=wl_test_… python3 -m unittest discover -s tests -v
|
|
47
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "wabalink"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "SDK de Python para la API pública de Wabalink (WhatsApp Business): mensajes, chats, contactos, citas, sandbox y webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["whatsapp", "whatsapp-business", "wabalink", "sdk"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/DeSoluTec/wabalink/tree/main/sdks/python"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["wabalink"]
|
wabalink-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
|
|
8
|
+
from wabalink import Wabalink, WabalinkApiError, WabalinkSignatureError, construct_event, sign
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def serve(handler):
|
|
12
|
+
reqs = []
|
|
13
|
+
|
|
14
|
+
class H(BaseHTTPRequestHandler):
|
|
15
|
+
def _do(self):
|
|
16
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
17
|
+
body = self.rfile.read(n).decode()
|
|
18
|
+
reqs.append({"method": self.command, "path": self.path, "headers": dict(self.headers), "body": body})
|
|
19
|
+
status, payload = handler(self, len(reqs))
|
|
20
|
+
out = json.dumps(payload).encode()
|
|
21
|
+
self.send_response(status)
|
|
22
|
+
self.send_header("Content-Type", "application/json")
|
|
23
|
+
self.send_header("Content-Length", str(len(out)))
|
|
24
|
+
self.end_headers()
|
|
25
|
+
self.wfile.write(out)
|
|
26
|
+
|
|
27
|
+
do_GET = do_POST = do_PATCH = _do
|
|
28
|
+
|
|
29
|
+
def log_message(self, *a):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
srv = HTTPServer(("127.0.0.1", 0), H)
|
|
33
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
34
|
+
return srv, f"http://127.0.0.1:{srv.server_port}/api/v1", reqs
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnitTests(unittest.TestCase):
|
|
38
|
+
def test_bearer_y_consulta(self):
|
|
39
|
+
srv, base, reqs = serve(lambda h, n: (200, []))
|
|
40
|
+
Wabalink("wl_test_x", base).conversations.list(status="open", limit=5)
|
|
41
|
+
self.assertEqual(reqs[0]["headers"]["Authorization"], "Bearer wl_test_x")
|
|
42
|
+
self.assertEqual(reqs[0]["path"], "/api/v1/conversations?status=open&limit=5")
|
|
43
|
+
srv.shutdown()
|
|
44
|
+
|
|
45
|
+
def test_idempotency_key_se_repite_al_reintentar(self):
|
|
46
|
+
srv, base, reqs = serve(lambda h, n: (503, {}) if n == 1 else (201, {"conversation_id": "c", "message": {}}))
|
|
47
|
+
r = Wabalink("wl_test_x", base).messages.send_text("c", "hola")
|
|
48
|
+
self.assertEqual(r["conversation_id"], "c")
|
|
49
|
+
self.assertEqual(len(reqs), 2)
|
|
50
|
+
self.assertTrue(reqs[0]["headers"]["Idempotency-Key"])
|
|
51
|
+
self.assertEqual(reqs[0]["headers"]["Idempotency-Key"], reqs[1]["headers"]["Idempotency-Key"])
|
|
52
|
+
srv.shutdown()
|
|
53
|
+
|
|
54
|
+
def test_4xx_lanza_error_sin_reintentar(self):
|
|
55
|
+
srv, base, reqs = serve(lambda h, n: (400, {"error": "Ventana de 24 h cerrada"}))
|
|
56
|
+
with self.assertRaises(WabalinkApiError) as cm:
|
|
57
|
+
Wabalink("wl_test_x", base).messages.send_text("c", "x")
|
|
58
|
+
self.assertEqual(cm.exception.status, 400)
|
|
59
|
+
self.assertEqual(str(cm.exception), "Ventana de 24 h cerrada")
|
|
60
|
+
self.assertEqual(len(reqs), 1)
|
|
61
|
+
srv.shutdown()
|
|
62
|
+
|
|
63
|
+
def test_iterate_recorre_paginas(self):
|
|
64
|
+
def h(handler, n):
|
|
65
|
+
off = int(handler.path.split("offset=")[1].split("&")[0])
|
|
66
|
+
return 200, {"items": [{"id": f"a{off}"}, {"id": f"b{off}"}], "total": 4}
|
|
67
|
+
srv, base, _ = serve(h)
|
|
68
|
+
ids = [c["id"] for c in Wabalink("wl_test_x", base).contacts.iterate(limit=2)]
|
|
69
|
+
self.assertEqual(ids, ["a0", "b0", "a2", "b2"])
|
|
70
|
+
srv.shutdown()
|
|
71
|
+
|
|
72
|
+
def test_webhooks(self):
|
|
73
|
+
body = json.dumps({"id": "e1", "event": "message.received", "livemode": False, "data": {}})
|
|
74
|
+
ts = str(int(time.time()))
|
|
75
|
+
headers = {"X-Wabalink-Timestamp": ts, "X-Wabalink-Signature": sign("sec", ts, body)}
|
|
76
|
+
self.assertEqual(construct_event(body, headers, "sec")["id"], "e1")
|
|
77
|
+
with self.assertRaises(WabalinkSignatureError):
|
|
78
|
+
construct_event(body + " ", headers, "sec")
|
|
79
|
+
with self.assertRaises(WabalinkSignatureError):
|
|
80
|
+
construct_event(body, headers, "otro")
|
|
81
|
+
old = str(int(ts) - 3600)
|
|
82
|
+
with self.assertRaises(WabalinkSignatureError):
|
|
83
|
+
construct_event(body, {"X-Wabalink-Timestamp": old, "X-Wabalink-Signature": sign("sec", old, body)}, "sec")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
LIVE = os.environ.get("WABALINK_API_KEY", "").startswith("wl_test_") and os.environ.get("WABALINK_BASE_URL")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@unittest.skipUnless(LIVE, "sin WABALINK_API_KEY de sandbox")
|
|
90
|
+
class SandboxTests(unittest.TestCase):
|
|
91
|
+
def test_flujo_completo(self):
|
|
92
|
+
wl = Wabalink(os.environ["WABALINK_API_KEY"])
|
|
93
|
+
self.assertFalse(wl.me()["livemode"])
|
|
94
|
+
wl.sandbox.reset()
|
|
95
|
+
inbound = wl.sandbox.inbound(from_="573001112233", name="Ana", text="hola")
|
|
96
|
+
sent = wl.messages.send_text(inbound["conversation_id"], "Hola Ana")
|
|
97
|
+
self.assertTrue(sent["message"]["wamid"])
|
|
98
|
+
self.assertGreaterEqual(len(wl.conversations.messages(inbound["conversation_id"])), 2)
|
|
99
|
+
self.assertTrue(wl.contacts.upsert(phone="+573001112233", tags=["sdk"])["id"])
|
|
100
|
+
self.assertEqual(wl.conversations.close(inbound["conversation_id"], outcome="resolved")["status"], "closed")
|
|
101
|
+
self.assertTrue(any(t["name"] == "hello_world" for t in wl.templates()))
|
|
102
|
+
with self.assertRaises(WabalinkApiError):
|
|
103
|
+
wl.messages.send(conversationId=inbound["conversation_id"], type="text")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from .client import Wabalink, WabalinkError, WabalinkApiError, WabalinkConnectionError
|
|
2
|
+
from .webhooks import construct_event, sign, WabalinkSignatureError
|
|
3
|
+
|
|
4
|
+
__all__ = ["Wabalink", "WabalinkError", "WabalinkApiError", "WabalinkConnectionError",
|
|
5
|
+
"construct_event", "sign", "WabalinkSignatureError"]
|
|
6
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
10
|
+
|
|
11
|
+
_IDEMPOTENT_PATHS = {"/messages", "/contacts", "/appointments"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WabalinkError(Exception):
|
|
15
|
+
def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.status = status
|
|
18
|
+
self.body = body
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WabalinkApiError(WabalinkError):
|
|
22
|
+
"""Respuesta 4xx/5xx de la API. str(e) es el `error` del servidor."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def details(self) -> Any:
|
|
26
|
+
return self.body.get("details") if isinstance(self.body, dict) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WabalinkConnectionError(WabalinkError):
|
|
30
|
+
"""Falla de red o tiempo agotado tras agotar los reintentos."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clean(d: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
34
|
+
return {k: v for k, v in (d or {}).items() if v is not None}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Group:
|
|
38
|
+
def __init__(self, c: "Wabalink"):
|
|
39
|
+
self._c = c
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Conversations(_Group):
|
|
43
|
+
def list(self, status: Optional[str] = None, phone_id: Optional[str] = None, limit: Optional[int] = None) -> List[dict]:
|
|
44
|
+
return self._c.request("GET", "/conversations", query={"status": status, "phoneId": phone_id, "limit": limit})
|
|
45
|
+
|
|
46
|
+
def get(self, id: str) -> dict:
|
|
47
|
+
return self._c.request("GET", f"/conversations/{id}")
|
|
48
|
+
|
|
49
|
+
def messages(self, id: str, before: Optional[str] = None, before_id: Optional[str] = None, limit: Optional[int] = None) -> List[dict]:
|
|
50
|
+
return self._c.request("GET", f"/conversations/{id}/messages", query={"before": before, "beforeId": before_id, "limit": limit})
|
|
51
|
+
|
|
52
|
+
def handoff(self, id: str, to: str, note: Optional[str] = None) -> dict:
|
|
53
|
+
return self._c.request("POST", f"/conversations/{id}/handoff", body=_clean({"to": to, "note": note}))
|
|
54
|
+
|
|
55
|
+
def close(self, id: str, outcome: Optional[str] = None, topic: Optional[str] = None,
|
|
56
|
+
sale_amount: Optional[float] = None, note: Optional[str] = None) -> dict:
|
|
57
|
+
return self._c.request("POST", f"/conversations/{id}/close",
|
|
58
|
+
body=_clean({"outcome": outcome, "topic": topic, "saleAmount": sale_amount, "note": note}))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Messages(_Group):
|
|
62
|
+
def send(self, idempotency_key: Optional[str] = None, **body: Any) -> dict:
|
|
63
|
+
"""send(conversationId=…, type="text", text="…"). Los campos son los de POST /messages."""
|
|
64
|
+
return self._c.request("POST", "/messages", body=_clean(body), idempotency_key=idempotency_key)
|
|
65
|
+
|
|
66
|
+
def send_text(self, conversation_id: str, text: str, idempotency_key: Optional[str] = None) -> dict:
|
|
67
|
+
return self.send(idempotency_key=idempotency_key, conversationId=conversation_id, type="text", text=text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Contacts(_Group):
|
|
71
|
+
def list(self, **params: Any) -> dict:
|
|
72
|
+
return self._c.request("GET", "/contacts", query=params)
|
|
73
|
+
|
|
74
|
+
def get(self, id: str) -> dict:
|
|
75
|
+
return self._c.request("GET", f"/contacts/{id}")
|
|
76
|
+
|
|
77
|
+
def upsert(self, idempotency_key: Optional[str] = None, **body: Any) -> dict:
|
|
78
|
+
return self._c.request("POST", "/contacts", body=_clean(body), idempotency_key=idempotency_key)
|
|
79
|
+
|
|
80
|
+
def update(self, id: str, **body: Any) -> dict:
|
|
81
|
+
return self._c.request("PATCH", f"/contacts/{id}", body=_clean(body))
|
|
82
|
+
|
|
83
|
+
def iterate(self, limit: int = 100, **params: Any) -> Iterator[dict]:
|
|
84
|
+
"""Recorre todas las páginas."""
|
|
85
|
+
offset = 0
|
|
86
|
+
while True:
|
|
87
|
+
page = self.list(limit=limit, offset=offset, **params)
|
|
88
|
+
yield from page["items"]
|
|
89
|
+
offset += limit
|
|
90
|
+
if offset >= page["total"] or not page["items"]:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _Appointments(_Group):
|
|
95
|
+
def list(self, **params: Any) -> List[dict]:
|
|
96
|
+
return self._c.request("GET", "/appointments", query=params)
|
|
97
|
+
|
|
98
|
+
def create(self, idempotency_key: Optional[str] = None, **body: Any) -> dict:
|
|
99
|
+
return self._c.request("POST", "/appointments", body=_clean(body), idempotency_key=idempotency_key)
|
|
100
|
+
|
|
101
|
+
def update(self, id: str, **body: Any) -> dict:
|
|
102
|
+
return self._c.request("PATCH", f"/appointments/{id}", body=_clean(body))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _Sandbox(_Group):
|
|
106
|
+
def inbound(self, **body: Any) -> dict:
|
|
107
|
+
"""inbound(from_="573001112233", text="hola"). `from_` se envía como `from`."""
|
|
108
|
+
if "from_" in body:
|
|
109
|
+
body["from"] = body.pop("from_")
|
|
110
|
+
return self._c.request("POST", "/sandbox/messages", body=_clean(body))
|
|
111
|
+
|
|
112
|
+
def reset(self) -> dict:
|
|
113
|
+
return self._c.request("POST", "/sandbox/reset", body={})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Wabalink:
|
|
117
|
+
"""Cliente de la API pública. api_key: wl_live_… (real) o wl_test_… (sandbox)."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, api_key: str, base_url: Optional[str] = None, max_retries: int = 2, timeout: float = 30.0):
|
|
120
|
+
if not api_key:
|
|
121
|
+
raise ValueError("Wabalink: falta api_key (wl_live_… o wl_test_…)")
|
|
122
|
+
self.api_key = api_key
|
|
123
|
+
self.base_url = (base_url or os.environ.get("WABALINK_BASE_URL") or "https://www.wabalink.com/api/v1").rstrip("/")
|
|
124
|
+
self.max_retries = max_retries
|
|
125
|
+
self.timeout = timeout
|
|
126
|
+
self.conversations = _Conversations(self)
|
|
127
|
+
self.messages = _Messages(self)
|
|
128
|
+
self.contacts = _Contacts(self)
|
|
129
|
+
self.appointments = _Appointments(self)
|
|
130
|
+
self.sandbox = _Sandbox(self)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_test_key(self) -> bool:
|
|
134
|
+
return self.api_key.startswith("wl_test_")
|
|
135
|
+
|
|
136
|
+
def me(self) -> dict:
|
|
137
|
+
return self.request("GET", "/me")
|
|
138
|
+
|
|
139
|
+
def tags(self) -> List[dict]:
|
|
140
|
+
return self.request("GET", "/tags")
|
|
141
|
+
|
|
142
|
+
def templates(self) -> List[dict]:
|
|
143
|
+
return self.request("GET", "/templates")
|
|
144
|
+
|
|
145
|
+
def request(self, method: str, path: str, query: Optional[Dict[str, Any]] = None, body: Any = None,
|
|
146
|
+
idempotency_key: Optional[str] = None) -> Any:
|
|
147
|
+
url = self.base_url + path
|
|
148
|
+
q = {k: ("true" if v is True else "false" if v is False else v) for k, v in _clean(query).items()}
|
|
149
|
+
if q:
|
|
150
|
+
url += "?" + urllib.parse.urlencode(q)
|
|
151
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": "wabalink-python/1.0.0"}
|
|
152
|
+
data = None
|
|
153
|
+
if body is not None:
|
|
154
|
+
data = json.dumps(body).encode()
|
|
155
|
+
headers["Content-Type"] = "application/json"
|
|
156
|
+
# Misma llave en todos los reintentos: reintentar nunca duplica un mensaje.
|
|
157
|
+
if method == "POST" and path in _IDEMPOTENT_PATHS:
|
|
158
|
+
headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
|
|
159
|
+
elif idempotency_key:
|
|
160
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
161
|
+
|
|
162
|
+
last: Exception = WabalinkError("sin intentos")
|
|
163
|
+
for attempt in range(self.max_retries + 1):
|
|
164
|
+
if attempt:
|
|
165
|
+
time.sleep(min(8.0, 0.4 * 2 ** (attempt - 1)) + random.random() * 0.1)
|
|
166
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
167
|
+
try:
|
|
168
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
169
|
+
text = res.read().decode()
|
|
170
|
+
return json.loads(text) if text else None
|
|
171
|
+
except urllib.error.HTTPError as e:
|
|
172
|
+
text = e.read().decode()
|
|
173
|
+
try:
|
|
174
|
+
payload = json.loads(text) if text else None
|
|
175
|
+
except ValueError:
|
|
176
|
+
payload = text
|
|
177
|
+
msg = payload.get("error") if isinstance(payload, dict) and payload.get("error") else f"HTTP {e.code}"
|
|
178
|
+
last = WabalinkApiError(msg, e.code, payload)
|
|
179
|
+
# 409: la primera petición con esa Idempotency-Key sigue en curso → se reintenta.
|
|
180
|
+
if e.code in (429, 409) or e.code >= 500:
|
|
181
|
+
ra = e.headers.get("Retry-After")
|
|
182
|
+
if attempt < self.max_retries and ra and ra.isdigit():
|
|
183
|
+
time.sleep(min(int(ra), 30))
|
|
184
|
+
continue
|
|
185
|
+
raise last
|
|
186
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
187
|
+
last = WabalinkConnectionError(f"No se pudo conectar con Wabalink: {e}")
|
|
188
|
+
raise last
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .client import WabalinkError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WabalinkSignatureError(WabalinkError):
|
|
11
|
+
"""Firma de webhook inválida o fuera de tolerancia."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sign(secret: str, timestamp: Union[str, int], raw_body: Union[str, bytes]) -> str:
|
|
15
|
+
"""Firma esperada: sha256=HMAC(secreto, "<timestamp>.<cuerpo>")."""
|
|
16
|
+
body = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body
|
|
17
|
+
mac = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
|
|
18
|
+
return "sha256=" + mac
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def construct_event(raw_body: Union[str, bytes], headers: Mapping[str, str], secret: str,
|
|
22
|
+
tolerance_seconds: int = 300, now: Optional[float] = None) -> Any:
|
|
23
|
+
"""Verifica un webhook y devuelve el evento (dict).
|
|
24
|
+
|
|
25
|
+
`raw_body` es el cuerpo EXACTO recibido. Deduplica por event["id"]: los reintentos conservan la id.
|
|
26
|
+
"""
|
|
27
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
28
|
+
ts = lower.get("x-wabalink-timestamp")
|
|
29
|
+
sig = lower.get("x-wabalink-signature")
|
|
30
|
+
if not ts or not sig:
|
|
31
|
+
raise WabalinkSignatureError("Faltan las cabeceras X-Wabalink-Timestamp o X-Wabalink-Signature")
|
|
32
|
+
if tolerance_seconds > 0:
|
|
33
|
+
try:
|
|
34
|
+
drift = abs((now if now is not None else time.time()) - float(ts))
|
|
35
|
+
except ValueError:
|
|
36
|
+
drift = float("inf")
|
|
37
|
+
if drift > tolerance_seconds:
|
|
38
|
+
raise WabalinkSignatureError("Marca de tiempo fuera de tolerancia")
|
|
39
|
+
if not hmac.compare_digest(sign(secret, ts, raw_body), sig):
|
|
40
|
+
raise WabalinkSignatureError("Firma inválida")
|
|
41
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wabalink
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SDK de Python para la API pública de Wabalink (WhatsApp Business): mensajes, chats, contactos, citas, sandbox y webhooks.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/DeSoluTec/wabalink/tree/main/sdks/python
|
|
7
|
+
Keywords: whatsapp,whatsapp-business,wabalink,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# wabalink (Python)
|
|
12
|
+
|
|
13
|
+
SDK de Python (>=3.9) para la API pública de Wabalink. Sin dependencias.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install wabalink
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import os
|
|
21
|
+
from wabalink import Wabalink
|
|
22
|
+
|
|
23
|
+
wl = Wabalink(os.environ["WABALINK_API_KEY"]) # wl_test_… para la sandbox
|
|
24
|
+
|
|
25
|
+
inbound = wl.sandbox.inbound(from_="573001112233", text="hola") # simula al cliente
|
|
26
|
+
wl.messages.send_text(inbound["conversation_id"], "¡Hola! ¿En qué te ayudo?")
|
|
27
|
+
wl.messages.send(conversationId=inbound["conversation_id"], type="buttons", text="¿Agendamos?",
|
|
28
|
+
buttons=[{"id": "si", "title": "Sí"}, {"id": "no", "title": "No"}])
|
|
29
|
+
wl.appointments.create(contactId="…", startsAt="2026-12-01T15:00:00-05:00")
|
|
30
|
+
for c in wl.contacts.iterate(tag="vip"):
|
|
31
|
+
print(c["name"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `messages`, `contacts` y `appointments` (POST) llevan `Idempotency-Key` automática, igual en los reintentos.
|
|
35
|
+
- Reintenta 429, 5xx y fallas de red (`max_retries=2`). Los 4xx lanzan `WabalinkApiError` (`.status`, `str(e)`, `.details`).
|
|
36
|
+
- Los campos de `send`, `upsert`, etc. son los de la API (`conversationId`, `startsAt`…); `sandbox.inbound(from_=…)` usa `from_` porque `from` es palabra reservada.
|
|
37
|
+
|
|
38
|
+
## Webhooks
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from flask import Flask, request
|
|
42
|
+
from wabalink import construct_event
|
|
43
|
+
|
|
44
|
+
@app.post("/wabalink")
|
|
45
|
+
def hook():
|
|
46
|
+
event = construct_event(request.get_data(), request.headers, os.environ["WABALINK_WEBHOOK_SECRET"]) # lanza si es inválido
|
|
47
|
+
if event["event"] == "message.received":
|
|
48
|
+
...
|
|
49
|
+
return "", 200 # deduplica por event["id"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Desarrollo
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python3 -m unittest discover -s tests -v
|
|
56
|
+
WABALINK_BASE_URL=http://127.0.0.1:4010/api/v1 WABALINK_API_KEY=wl_test_… python3 -m unittest discover -s tests -v
|
|
57
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wabalink
|