zapcore-sdk 0.1.1__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.
- zapcore/__init__.py +17 -0
- zapcore/_client.py +423 -0
- zapcore/_errors.py +37 -0
- zapcore/_generated.py +4208 -0
- zapcore/py.typed +0 -0
- zapcore/webhook.py +123 -0
- zapcore_sdk-0.1.1.dist-info/METADATA +145 -0
- zapcore_sdk-0.1.1.dist-info/RECORD +10 -0
- zapcore_sdk-0.1.1.dist-info/WHEEL +4 -0
- zapcore_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
zapcore/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""SDK oficial da API REST v2 do zapCore (WhatsApp).
|
|
2
|
+
|
|
3
|
+
from zapcore import Client
|
|
4
|
+
zap = Client("https://api.seudominio.com", token="TOKEN_DA_INSTANCIA")
|
|
5
|
+
zap.send_text("5527999999999", "Olá!")
|
|
6
|
+
"""
|
|
7
|
+
from ._client import ApiResponse, Client, Page, SDK_VERSION, Transport, destino, parse_retry_after, urllib_transport
|
|
8
|
+
from ._errors import WebhookVerificationError, ZapcoreError
|
|
9
|
+
from ._generated import API_VERSION, SPEC_SHA256
|
|
10
|
+
from .webhook import parse_webhook, verify_webhook
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Client", "ApiResponse", "Page", "Transport", "ZapcoreError", "WebhookVerificationError",
|
|
14
|
+
"verify_webhook", "parse_webhook", "destino", "parse_retry_after", "urllib_transport",
|
|
15
|
+
"SDK_VERSION", "API_VERSION", "SPEC_SHA256",
|
|
16
|
+
]
|
|
17
|
+
__version__ = SDK_VERSION
|
zapcore/_client.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import email.utils
|
|
5
|
+
import json
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
|
|
14
|
+
from urllib.parse import urlencode, urlparse
|
|
15
|
+
|
|
16
|
+
from ._errors import ZapcoreError
|
|
17
|
+
from ._generated import _Operations
|
|
18
|
+
|
|
19
|
+
SDK_VERSION = "0.1.1" # x-release-please-version
|
|
20
|
+
|
|
21
|
+
#: (status, headers com chave minúscula, corpo)
|
|
22
|
+
TransportResponse = Tuple[int, Dict[str, str], bytes]
|
|
23
|
+
#: transport(method, url, headers, body, timeout) -> TransportResponse. Falha de rede: levante OSError.
|
|
24
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], TransportResponse]
|
|
25
|
+
|
|
26
|
+
_RETENTAVEIS_5XX = {502, 503, 504}
|
|
27
|
+
_POR_STATUS = {400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found",
|
|
28
|
+
409: "conflict", 423: "session_locked", 429: "rate_limited"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class ApiResponse:
|
|
33
|
+
"""Resposta completa, para quem precisa de ``meta``, status ou headers."""
|
|
34
|
+
|
|
35
|
+
data: Any
|
|
36
|
+
meta: Dict[str, Any]
|
|
37
|
+
status: int
|
|
38
|
+
headers: Dict[str, str]
|
|
39
|
+
#: ``True`` quando o servidor repetiu a resposta guardada de uma Idempotency-Key.
|
|
40
|
+
idempotent_replay: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Page:
|
|
45
|
+
items: List[Any]
|
|
46
|
+
#: Repasse sem interpretar. ``None`` = acabou.
|
|
47
|
+
next_cursor: Optional[str]
|
|
48
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _SemRedirect(urllib.request.HTTPRedirectHandler):
|
|
52
|
+
"""Nunca segue redirect: o urllib reenviaria o header ``token`` para o destino do Location.
|
|
53
|
+
|
|
54
|
+
Devolver ``None`` faz o 3xx subir como HTTPError, que o transporte entrega ao cliente.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_OPENER = urllib.request.build_opener(_SemRedirect())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Teto do download de imagem por URL (o motor também limita o que aceita).
|
|
65
|
+
MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024
|
|
66
|
+
#: Tipos aceitos no download por URL (sem svg: pode carregar script).
|
|
67
|
+
_IMAGENS_OK = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
|
68
|
+
#: ``http://`` só é aceito sem opt-in nestes hosts: o tráfego não sai da máquina.
|
|
69
|
+
_HOSTS_LOCAIS = {"localhost", "127.0.0.1", "::1"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float, max_bytes: Optional[int] = None) -> TransportResponse:
|
|
73
|
+
"""Transporte padrão: só biblioteca padrão, sem seguir redirect.
|
|
74
|
+
|
|
75
|
+
``max_bytes`` (opcional) corta a leitura do corpo: estourou, levanta :class:`ZapcoreError` 413.
|
|
76
|
+
"""
|
|
77
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
78
|
+
try:
|
|
79
|
+
with _OPENER.open(req, timeout=timeout) as r: # noqa: S310 (URL vem do dono do cliente)
|
|
80
|
+
return r.status, {k.lower(): v for k, v in r.headers.items()}, _ler(r, max_bytes)
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, _ler(e, max_bytes) or b""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ler(r: Any, max_bytes: Optional[int]) -> bytes:
|
|
86
|
+
if max_bytes is None:
|
|
87
|
+
return r.read()
|
|
88
|
+
declarado = r.headers.get("content-length")
|
|
89
|
+
if declarado and declarado.isdigit() and int(declarado) > max_bytes:
|
|
90
|
+
raise _estouro(max_bytes)
|
|
91
|
+
dados = r.read(max_bytes + 1)
|
|
92
|
+
if len(dados) > max_bytes:
|
|
93
|
+
raise _estouro(max_bytes)
|
|
94
|
+
return dados
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _estouro(max_bytes: int) -> "ZapcoreError":
|
|
98
|
+
return ZapcoreError(f"zapcore: a imagem passa de {max_bytes} bytes; baixe você e passe os bytes", status=413, code="bad_request")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _exige_canal_cifrado(base_url: str, permitir_http: bool) -> None:
|
|
102
|
+
"""``http://`` para host remoto mandaria o token da instância em claro."""
|
|
103
|
+
u = urlparse(base_url)
|
|
104
|
+
if u.scheme.lower() == "https" or permitir_http or (u.hostname or "").lower() in _HOSTS_LOCAIS:
|
|
105
|
+
return
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"zapcore: base_url em http:// para um host remoto mandaria o token em claro. Use https://, "
|
|
108
|
+
"ou allow_insecure_http=True para uma rede interna sem TLS (http:// é aceito sem opt-in "
|
|
109
|
+
"só em localhost, 127.0.0.1 e [::1])."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def parse_retry_after(valor: Optional[str], agora: Optional[float] = None) -> Optional[float]:
|
|
114
|
+
"""``Retry-After`` em segundos (número ou data HTTP)."""
|
|
115
|
+
if not valor:
|
|
116
|
+
return None
|
|
117
|
+
v = valor.strip()
|
|
118
|
+
if re.fullmatch(r"\d+(\.\d+)?", v):
|
|
119
|
+
return float(v)
|
|
120
|
+
try:
|
|
121
|
+
quando = email.utils.parsedate_to_datetime(v).timestamp()
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
return None
|
|
124
|
+
return max(0.0, float(int(quando - (time.time() if agora is None else agora) + 0.999)))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def destino(phone: str) -> str:
|
|
128
|
+
"""Número com pontuação vira só dígitos; JID (``...@g.us``) passa intacto."""
|
|
129
|
+
return phone.strip() if "@" in phone else re.sub(r"\D+", "", phone)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _limpar_data_url(u: str) -> str:
|
|
133
|
+
"""O motor recusa (400) parâmetro no mime do data URL."""
|
|
134
|
+
m = re.match(r"^data:([^;,]+)[^,]*?(;base64)?,", u, re.I)
|
|
135
|
+
if not m:
|
|
136
|
+
return u
|
|
137
|
+
return f"data:{m.group(1)}{m.group(2) or ''},{u[m.end():]}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _backoff(tentativa: int) -> float:
|
|
141
|
+
base = min(8.0, 0.5 * (2 ** tentativa))
|
|
142
|
+
return base / 2 + random.random() * (base / 2)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Client(_Operations):
|
|
146
|
+
"""Cliente da API v2 do zapCore.
|
|
147
|
+
|
|
148
|
+
Todo método gerado (um por ``operationId``, ex. ``post_chat_send_text``) e todo atalho
|
|
149
|
+
(``send_text``, ``send_image``…) devolve o ``data`` do envelope. Para ``meta``, status e
|
|
150
|
+
headers use :meth:`request`.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
base_url: str,
|
|
156
|
+
token: Optional[str] = None,
|
|
157
|
+
*,
|
|
158
|
+
admin_token: Optional[str] = None,
|
|
159
|
+
max_retries: int = 2,
|
|
160
|
+
max_retry_wait: float = 60.0,
|
|
161
|
+
timeout: float = 30.0,
|
|
162
|
+
auto_idempotency: bool = True,
|
|
163
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
164
|
+
transport: Optional[Transport] = None,
|
|
165
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
166
|
+
idempotency_key_factory: Callable[[], str] = lambda: str(uuid.uuid4()),
|
|
167
|
+
allow_insecure_http: bool = False,
|
|
168
|
+
) -> None:
|
|
169
|
+
if not re.match(r"^https?://", base_url or "", re.I):
|
|
170
|
+
raise ValueError("zapcore: base_url precisa ser uma URL http(s), ex. https://api.seudominio.com")
|
|
171
|
+
_exige_canal_cifrado(base_url, allow_insecure_http)
|
|
172
|
+
self.base_url = re.sub(r"/api/v2$", "", base_url.rstrip("/"))
|
|
173
|
+
self._token = token
|
|
174
|
+
self._admin_token = admin_token
|
|
175
|
+
self._max_retries = max(0, max_retries)
|
|
176
|
+
self._max_retry_wait = max_retry_wait
|
|
177
|
+
self._timeout = timeout
|
|
178
|
+
self._auto_idem = auto_idempotency
|
|
179
|
+
self._headers = dict(headers or {})
|
|
180
|
+
self._transport: Transport = transport or urllib_transport
|
|
181
|
+
self._sleep = sleep
|
|
182
|
+
self._nova_chave = idempotency_key_factory
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------ baixo nível
|
|
185
|
+
|
|
186
|
+
def _call(self, method: str, path: str, **kw: Any) -> Any:
|
|
187
|
+
return self.request(method, path, **kw).data
|
|
188
|
+
|
|
189
|
+
def request(
|
|
190
|
+
self,
|
|
191
|
+
method: str,
|
|
192
|
+
path: str,
|
|
193
|
+
*,
|
|
194
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
195
|
+
body: Any = None,
|
|
196
|
+
send: bool = False,
|
|
197
|
+
admin: bool = False,
|
|
198
|
+
idempotency_key: Optional[str] = None,
|
|
199
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
200
|
+
timeout: Optional[float] = None,
|
|
201
|
+
max_retries: Optional[int] = None,
|
|
202
|
+
) -> ApiResponse:
|
|
203
|
+
"""Chamada de baixo nível. Levanta :class:`ZapcoreError` em HTTP >= 400."""
|
|
204
|
+
url = self.base_url + path + _montar_query(query)
|
|
205
|
+
h: Dict[str, str] = {"Accept": "application/json", "User-Agent": f"zapcore-sdk-python/{SDK_VERSION}", **self._headers}
|
|
206
|
+
if admin:
|
|
207
|
+
if not self._admin_token:
|
|
208
|
+
raise ValueError("zapcore: rota /admin exige admin_token no cliente")
|
|
209
|
+
h["Authorization"] = self._admin_token
|
|
210
|
+
else:
|
|
211
|
+
if not self._token:
|
|
212
|
+
raise ValueError("zapcore: esta rota exige token (token da instância) no cliente")
|
|
213
|
+
h["token"] = self._token
|
|
214
|
+
dados: Optional[bytes] = None
|
|
215
|
+
if body is not None:
|
|
216
|
+
dados = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
217
|
+
h["Content-Type"] = "application/json"
|
|
218
|
+
# Uma chave por chamada LÓGICA, reaproveitada em todo retry: retry nunca duplica mensagem.
|
|
219
|
+
chave = (idempotency_key or (self._nova_chave() if self._auto_idem else None)) if send else None
|
|
220
|
+
if chave:
|
|
221
|
+
h["Idempotency-Key"] = chave
|
|
222
|
+
h.update(headers or {})
|
|
223
|
+
|
|
224
|
+
seguro = method == "GET" or chave is not None
|
|
225
|
+
limite = self._max_retries if max_retries is None else max(0, max_retries)
|
|
226
|
+
prazo = self._timeout if timeout is None else timeout
|
|
227
|
+
|
|
228
|
+
tentativa = 0
|
|
229
|
+
while True:
|
|
230
|
+
try:
|
|
231
|
+
status, rh, cru = self._transport(method, url, h, dados, prazo)
|
|
232
|
+
except OSError as e:
|
|
233
|
+
if seguro and tentativa < limite:
|
|
234
|
+
self._sleep(_backoff(tentativa))
|
|
235
|
+
tentativa += 1
|
|
236
|
+
continue
|
|
237
|
+
raise ZapcoreError(f"zapcore: falha de rede em {method} {path}: {e}", status=0, code="network_error") from e
|
|
238
|
+
|
|
239
|
+
texto = cru.decode("utf-8", errors="replace") if cru else ""
|
|
240
|
+
try:
|
|
241
|
+
corpo = json.loads(texto) if texto else None
|
|
242
|
+
except ValueError:
|
|
243
|
+
corpo = None
|
|
244
|
+
|
|
245
|
+
if 200 <= status < 300:
|
|
246
|
+
env = corpo if isinstance(corpo, dict) else {}
|
|
247
|
+
return ApiResponse(
|
|
248
|
+
data=env["data"] if "data" in env else corpo,
|
|
249
|
+
meta=env.get("meta") or {},
|
|
250
|
+
status=status,
|
|
251
|
+
headers=rh,
|
|
252
|
+
idempotent_replay=rh.get("idempotent-replay") == "true",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if 300 <= status < 400:
|
|
256
|
+
raise ZapcoreError(
|
|
257
|
+
f"zapcore: {method} {path} respondeu redirect ({status}). O SDK não segue redirect "
|
|
258
|
+
"para não vazar o token; confira o base_url (http→https? barra final?)",
|
|
259
|
+
status=status, code="redirect_refused", request_id=rh.get("request-id"),
|
|
260
|
+
)
|
|
261
|
+
erro = _montar_erro(status, rh, corpo, texto, method, path)
|
|
262
|
+
# 409 idempotency_in_progress: a MESMA requisição ainda está em voo no motor; repetir a
|
|
263
|
+
# MESMA chave (``h`` é reaproveitado) após o Retry-After. Teto = ``max_retries``.
|
|
264
|
+
em_voo = status == 409 and chave is not None and erro.code == "idempotency_in_progress"
|
|
265
|
+
if tentativa < limite and (status == 429 or em_voo or (status in _RETENTAVEIS_5XX and seguro)):
|
|
266
|
+
espera = erro.retry_after if erro.retry_after is not None else _backoff(tentativa)
|
|
267
|
+
# Janela de hora/dia estourada: esperar 40 min dentro de um request é travar o processo.
|
|
268
|
+
if espera > self._max_retry_wait:
|
|
269
|
+
raise erro
|
|
270
|
+
self._sleep(espera)
|
|
271
|
+
tentativa += 1
|
|
272
|
+
continue
|
|
273
|
+
raise erro
|
|
274
|
+
|
|
275
|
+
def paginate(self, path: str, query: Optional[Mapping[str, Any]] = None, **kw: Any) -> Iterator[Any]:
|
|
276
|
+
"""Itera uma listagem por cursor; para quando ``next_cursor`` some ou a página volta vazia."""
|
|
277
|
+
cursor: Optional[str] = None
|
|
278
|
+
while True:
|
|
279
|
+
q = dict(query or {})
|
|
280
|
+
if cursor:
|
|
281
|
+
q["cursor"] = cursor
|
|
282
|
+
r = self.request("GET", path, query=q, **kw)
|
|
283
|
+
itens = r.data if isinstance(r.data, list) else []
|
|
284
|
+
yield from itens
|
|
285
|
+
cursor = r.meta.get("next_cursor") if itens else None
|
|
286
|
+
if not cursor:
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------ atalhos
|
|
290
|
+
|
|
291
|
+
def send_text(self, phone: str, text: str, **extra: Any) -> Dict[str, Any]:
|
|
292
|
+
"""Envia texto. ``extra``: campos do corpo (``LinkPreview``…) e opções (``idempotency_key``…)."""
|
|
293
|
+
corpo, opts = _separar(extra)
|
|
294
|
+
return self.post_chat_send_text({**corpo, "Phone": destino(phone), "Body": text}, **opts)
|
|
295
|
+
|
|
296
|
+
def send_image(self, phone: str, image: Union[str, bytes], caption: Optional[str] = None, *, mime_type: str = "image/jpeg", **extra: Any) -> Dict[str, Any]:
|
|
297
|
+
"""Envia imagem: data URL, base64 puro, URL http(s) (o SDK baixa) ou bytes."""
|
|
298
|
+
corpo, opts = _separar(extra)
|
|
299
|
+
corpo.update({"Phone": destino(phone), "Image": self._data_url(image, mime_type)})
|
|
300
|
+
if caption is not None:
|
|
301
|
+
corpo["Caption"] = caption
|
|
302
|
+
return self.post_chat_send_image(corpo, **opts)
|
|
303
|
+
|
|
304
|
+
def send_buttons(self, phone: str, text: str, buttons: Sequence[Union[str, Mapping[str, Any]]], **extra: Any) -> Dict[str, Any]:
|
|
305
|
+
"""Botões (``reply``, ``cta_url``, ``cta_call``, ``copy``). Strings viram ``reply``."""
|
|
306
|
+
corpo, opts = _separar(extra)
|
|
307
|
+
lista = [{"id": f"b{i + 1}", "title": b, "type": "reply"} if isinstance(b, str) else dict(b) for i, b in enumerate(buttons)]
|
|
308
|
+
return self.post_chat_send_buttons({**corpo, "Phone": destino(phone), "Body": text, "Buttons": lista}, **opts)
|
|
309
|
+
|
|
310
|
+
def send_list(self, phone: str, text: str, button_text: str, sections: Sequence[Mapping[str, Any]], *, title: Optional[str] = None, footer: Optional[str] = None, **extra: Any) -> Dict[str, Any]:
|
|
311
|
+
corpo, opts = _separar(extra)
|
|
312
|
+
corpo.update({"Phone": destino(phone), "Desc": text, "ButtonText": button_text, "Sections": list(sections)})
|
|
313
|
+
if title is not None:
|
|
314
|
+
corpo["TopText"] = title
|
|
315
|
+
if footer is not None:
|
|
316
|
+
corpo["FooterText"] = footer
|
|
317
|
+
return self.post_chat_send_list(corpo, **opts)
|
|
318
|
+
|
|
319
|
+
def send_poll(self, to: str, question: str, choices: Sequence[str], **extra: Any) -> Dict[str, Any]:
|
|
320
|
+
corpo, opts = _separar(extra)
|
|
321
|
+
return self.post_chat_send_poll({**corpo, "group": destino(to), "header": question, "options": list(choices)}, **opts)
|
|
322
|
+
|
|
323
|
+
def get_qr(self, **opts: Any) -> Dict[str, Any]:
|
|
324
|
+
"""QR de pareamento (data URL em ``QRCode``). Conecte antes com ``post_session_connect``."""
|
|
325
|
+
return self.get_session_qr(**opts)
|
|
326
|
+
|
|
327
|
+
def pair_phone(self, phone: str, **opts: Any) -> Dict[str, Any]:
|
|
328
|
+
"""Pareia por código: devolve ``LinkingCode`` para digitar no celular."""
|
|
329
|
+
return self.post_session_pairphone({"Phone": destino(phone)}, **opts)
|
|
330
|
+
|
|
331
|
+
def status(self, **opts: Any) -> Dict[str, Any]:
|
|
332
|
+
return self.get_session_status(**opts)
|
|
333
|
+
|
|
334
|
+
def create_group(self, name: str, participants: Sequence[str], **extra: Any) -> Dict[str, Any]:
|
|
335
|
+
corpo, opts = _separar(extra)
|
|
336
|
+
return self.post_group_create({**corpo, "name": name, "participants": [destino(p) for p in participants]}, **opts)
|
|
337
|
+
|
|
338
|
+
def check_numbers(self, phones: Union[str, Sequence[str]], **opts: Any) -> List[Dict[str, Any]]:
|
|
339
|
+
"""Quais números têm WhatsApp. Um item por número, na ordem enviada."""
|
|
340
|
+
lista = [phones] if isinstance(phones, str) else list(phones)
|
|
341
|
+
r = self.post_user_check({"Phone": [destino(p) for p in lista]}, **opts)
|
|
342
|
+
return list((r or {}).get("Users") or [])
|
|
343
|
+
|
|
344
|
+
def history(self, chat_jid: str, *, limit: Optional[int] = None, cursor: Optional[str] = None, **opts: Any) -> Page:
|
|
345
|
+
"""Uma página do histórico (mais recente primeiro)."""
|
|
346
|
+
r = self.request("GET", "/api/v2/chat/history", query={"chat_jid": chat_jid, "limit": limit, "cursor": cursor}, **opts)
|
|
347
|
+
itens = r.data if isinstance(r.data, list) else []
|
|
348
|
+
return Page(items=itens, next_cursor=(r.meta.get("next_cursor") or None) if itens else None, meta=r.meta)
|
|
349
|
+
|
|
350
|
+
def iter_history(self, chat_jid: str, page_size: int = 100, **opts: Any) -> Iterator[Dict[str, Any]]:
|
|
351
|
+
"""Itera o histórico inteiro, buscando páginas sob demanda."""
|
|
352
|
+
return self.paginate("/api/v2/chat/history", {"chat_jid": chat_jid, "limit": page_size}, **opts)
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------ internos
|
|
355
|
+
|
|
356
|
+
def _data_url(self, img: Union[str, bytes], mime: str) -> str:
|
|
357
|
+
if isinstance(img, (bytes, bytearray, memoryview)):
|
|
358
|
+
return f"data:{mime};base64,{base64.b64encode(bytes(img)).decode('ascii')}"
|
|
359
|
+
if img.startswith("data:"):
|
|
360
|
+
return _limpar_data_url(img)
|
|
361
|
+
if re.match(r"^https?://", img, re.I):
|
|
362
|
+
try:
|
|
363
|
+
if self._transport is urllib_transport:
|
|
364
|
+
status, rh, cru = urllib_transport("GET", img, {}, None, self._timeout, MAX_DOWNLOAD_BYTES)
|
|
365
|
+
else:
|
|
366
|
+
status, rh, cru = self._transport("GET", img, {}, None, self._timeout)
|
|
367
|
+
except OSError as e:
|
|
368
|
+
raise ZapcoreError(f"zapcore: falha de rede ao baixar a imagem: {e}", status=0, code="network_error") from e
|
|
369
|
+
if not 200 <= status < 300:
|
|
370
|
+
dica = ". A URL redirecionou: passe a URL final ou os bytes" if 300 <= status < 400 else ""
|
|
371
|
+
raise ZapcoreError(f"zapcore: não consegui baixar a imagem ({status}){dica}", status=status, code="bad_request")
|
|
372
|
+
if len(cru) > MAX_DOWNLOAD_BYTES:
|
|
373
|
+
raise _estouro(MAX_DOWNLOAD_BYTES)
|
|
374
|
+
tipo = (rh.get("content-type") or "").split(";")[0].strip().lower()
|
|
375
|
+
if tipo not in _IMAGENS_OK:
|
|
376
|
+
raise ZapcoreError(
|
|
377
|
+
f"zapcore: a URL não devolveu uma imagem (Content-Type: {tipo or 'ausente'}); passe a URL final ou os bytes",
|
|
378
|
+
status=status, code="bad_request",
|
|
379
|
+
)
|
|
380
|
+
return f"data:{tipo};base64,{base64.b64encode(cru).decode('ascii')}"
|
|
381
|
+
limpo = re.sub(r"\s+", "", img)
|
|
382
|
+
return f"data:{mime};base64,{limpo}"
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
_OPCOES = {"idempotency_key", "headers", "timeout", "query"}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _separar(extra: Mapping[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
389
|
+
corpo = {k: v for k, v in extra.items() if k not in _OPCOES}
|
|
390
|
+
opts = {k: v for k, v in extra.items() if k in _OPCOES}
|
|
391
|
+
return corpo, opts
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _montar_query(q: Optional[Mapping[str, Any]]) -> str:
|
|
395
|
+
if not q:
|
|
396
|
+
return ""
|
|
397
|
+
pares: List[Tuple[str, str]] = []
|
|
398
|
+
for k, v in q.items():
|
|
399
|
+
if v is None or v == "":
|
|
400
|
+
continue
|
|
401
|
+
if isinstance(v, bool):
|
|
402
|
+
pares.append((k, "true" if v else "false"))
|
|
403
|
+
elif isinstance(v, (list, tuple)):
|
|
404
|
+
pares.extend((k, str(x)) for x in v)
|
|
405
|
+
else:
|
|
406
|
+
pares.append((k, str(v)))
|
|
407
|
+
return "?" + urlencode(pares) if pares else ""
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _montar_erro(status: int, rh: Mapping[str, str], corpo: Any, texto: str, method: str, path: str) -> ZapcoreError:
|
|
411
|
+
e = corpo.get("error") if isinstance(corpo, dict) else None
|
|
412
|
+
meta = corpo.get("meta") if isinstance(corpo, dict) else None
|
|
413
|
+
obj = e if isinstance(e, dict) else {}
|
|
414
|
+
msg = obj.get("message") or (e if isinstance(e, str) else f"zapcore: {method} {path} respondeu {status}" + (f": {texto[:200]}" if texto and corpo is None else ""))
|
|
415
|
+
return ZapcoreError(
|
|
416
|
+
msg,
|
|
417
|
+
status=status,
|
|
418
|
+
code=obj.get("code") or _POR_STATUS.get(status, "internal_error"),
|
|
419
|
+
field=obj.get("field"),
|
|
420
|
+
request_id=(meta or {}).get("request_id") or rh.get("request-id"),
|
|
421
|
+
retry_after=parse_retry_after(rh.get("retry-after")),
|
|
422
|
+
body=corpo if corpo is not None else texto,
|
|
423
|
+
)
|
zapcore/_errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ZapcoreError(Exception):
|
|
7
|
+
"""Erro da API v2. Case por ``code`` (contrato estável), nunca pela mensagem.
|
|
8
|
+
|
|
9
|
+
``status`` é 0 em falha de rede (``code == "network_error"``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
message: str,
|
|
15
|
+
*,
|
|
16
|
+
status: int,
|
|
17
|
+
code: str,
|
|
18
|
+
field: Optional[str] = None,
|
|
19
|
+
request_id: Optional[str] = None,
|
|
20
|
+
retry_after: Optional[float] = None,
|
|
21
|
+
body: Any = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.status = status
|
|
26
|
+
self.code = code
|
|
27
|
+
self.field = field
|
|
28
|
+
self.request_id = request_id
|
|
29
|
+
self.retry_after = retry_after
|
|
30
|
+
self.body = body
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"ZapcoreError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebhookVerificationError(Exception):
|
|
37
|
+
"""A assinatura HMAC do webhook não fechou."""
|