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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_sdk.py
4
+ wabalink/__init__.py
5
+ wabalink/client.py
6
+ wabalink/webhooks.py
7
+ wabalink.egg-info/PKG-INFO
8
+ wabalink.egg-info/SOURCES.txt
9
+ wabalink.egg-info/dependency_links.txt
10
+ wabalink.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ wabalink