funnelchat 0.4.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.
- funnelchat/__init__.py +86 -0
- funnelchat/_client.py +266 -0
- funnelchat/_error_codes.py +142 -0
- funnelchat/_errors.py +205 -0
- funnelchat/_pagination.py +120 -0
- funnelchat/_query.py +62 -0
- funnelchat/_resource.py +95 -0
- funnelchat/_retry.py +62 -0
- funnelchat/_routes.py +395 -0
- funnelchat/_version.py +1 -0
- funnelchat/py.typed +0 -0
- funnelchat/resources.py +3374 -0
- funnelchat/types.py +3293 -0
- funnelchat/webhooks.py +135 -0
- funnelchat-0.4.0.dist-info/METADATA +466 -0
- funnelchat-0.4.0.dist-info/RECORD +18 -0
- funnelchat-0.4.0.dist-info/WHEEL +4 -0
- funnelchat-0.4.0.dist-info/licenses/LICENSE +21 -0
funnelchat/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The official Python SDK of the Funnelchat API.
|
|
2
|
+
|
|
3
|
+
from funnelchat import Funnelchat
|
|
4
|
+
|
|
5
|
+
client = Funnelchat() # reads FUNNELCHAT_API_KEY
|
|
6
|
+
result = client.messages.send_direct(device_id=3, phone="5491100000000", text="Hi Ana!")
|
|
7
|
+
print(result["message"]["id"], result["message"]["status"]) # 1002 queued
|
|
8
|
+
|
|
9
|
+
The types of the responses and of the webhook events are in `funnelchat.types`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import webhooks
|
|
13
|
+
from ._client import BASE_URLS, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, Funnelchat, ResponseInfo
|
|
14
|
+
from ._error_codes import (
|
|
15
|
+
ACCESS_ERROR_CODES,
|
|
16
|
+
GROUP_ACTION_ERROR_CODES,
|
|
17
|
+
IDEMPOTENCY_ERROR_CODES,
|
|
18
|
+
NOT_RETRYABLE_CODES,
|
|
19
|
+
NUMBER_ERROR_CODES,
|
|
20
|
+
SEND_ERROR_CODES,
|
|
21
|
+
TEMPLATE_SYNC_ERROR_CODES,
|
|
22
|
+
is_number_quota_error,
|
|
23
|
+
is_send_rule_error,
|
|
24
|
+
)
|
|
25
|
+
from ._errors import (
|
|
26
|
+
APIConnectionError,
|
|
27
|
+
APIConnectionTimeoutError,
|
|
28
|
+
APIError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
BadRequestError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
FunnelchatError,
|
|
33
|
+
InternalServerError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
PermissionDeniedError,
|
|
36
|
+
RateLimitError,
|
|
37
|
+
UnprocessableEntityError,
|
|
38
|
+
UnsupportedMediaTypeError,
|
|
39
|
+
WebhookVerificationError,
|
|
40
|
+
)
|
|
41
|
+
from ._pagination import Page
|
|
42
|
+
from ._resource import NOT_GIVEN, NotGiven
|
|
43
|
+
from ._routes import ROUTE_GROUPS, ROUTES, WEBHOOK_EVENTS, Route, RouteGroup, WebhookEventInfo
|
|
44
|
+
from ._version import __version__
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"Funnelchat",
|
|
48
|
+
"ResponseInfo",
|
|
49
|
+
"BASE_URLS",
|
|
50
|
+
"DEFAULT_TIMEOUT",
|
|
51
|
+
"DEFAULT_MAX_RETRIES",
|
|
52
|
+
"Page",
|
|
53
|
+
"NOT_GIVEN",
|
|
54
|
+
"NotGiven",
|
|
55
|
+
"webhooks",
|
|
56
|
+
"ROUTES",
|
|
57
|
+
"ROUTE_GROUPS",
|
|
58
|
+
"WEBHOOK_EVENTS",
|
|
59
|
+
"Route",
|
|
60
|
+
"RouteGroup",
|
|
61
|
+
"WebhookEventInfo",
|
|
62
|
+
"FunnelchatError",
|
|
63
|
+
"APIError",
|
|
64
|
+
"BadRequestError",
|
|
65
|
+
"AuthenticationError",
|
|
66
|
+
"PermissionDeniedError",
|
|
67
|
+
"NotFoundError",
|
|
68
|
+
"ConflictError",
|
|
69
|
+
"UnsupportedMediaTypeError",
|
|
70
|
+
"UnprocessableEntityError",
|
|
71
|
+
"RateLimitError",
|
|
72
|
+
"InternalServerError",
|
|
73
|
+
"APIConnectionError",
|
|
74
|
+
"APIConnectionTimeoutError",
|
|
75
|
+
"WebhookVerificationError",
|
|
76
|
+
"ACCESS_ERROR_CODES",
|
|
77
|
+
"SEND_ERROR_CODES",
|
|
78
|
+
"NUMBER_ERROR_CODES",
|
|
79
|
+
"GROUP_ACTION_ERROR_CODES",
|
|
80
|
+
"IDEMPOTENCY_ERROR_CODES",
|
|
81
|
+
"TEMPLATE_SYNC_ERROR_CODES",
|
|
82
|
+
"NOT_RETRYABLE_CODES",
|
|
83
|
+
"is_send_rule_error",
|
|
84
|
+
"is_number_quota_error",
|
|
85
|
+
"__version__",
|
|
86
|
+
]
|
funnelchat/_client.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The client of the Funnelchat public API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._error_codes import NOT_RETRYABLE_CODES
|
|
14
|
+
from ._errors import APIConnectionError, APIConnectionTimeoutError, APIError, FunnelchatError, request_id_of
|
|
15
|
+
from ._pagination import Page
|
|
16
|
+
from ._query import interpolate_path, serialize_query
|
|
17
|
+
from ._retry import is_retryable, is_send_request, retry_delay
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .resources import ClientResources
|
|
20
|
+
|
|
21
|
+
__all__ = ["Funnelchat", "ResponseInfo", "BASE_URLS", "DEFAULT_TIMEOUT", "DEFAULT_MAX_RETRIES"]
|
|
22
|
+
|
|
23
|
+
#: The base URL of each environment: one URL for the whole public API.
|
|
24
|
+
BASE_URLS: Dict[str, str] = {
|
|
25
|
+
"production": "https://api.funnelchat.com",
|
|
26
|
+
"staging": "https://api-staging.funnelchat.com",
|
|
27
|
+
}
|
|
28
|
+
#: Maximum seconds of each attempt.
|
|
29
|
+
DEFAULT_TIMEOUT = 30.0
|
|
30
|
+
#: Automatic retries (see the README, Retries).
|
|
31
|
+
DEFAULT_MAX_RETRIES = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResponseInfo:
|
|
35
|
+
"""What the API answered to one request, besides the body: `status`, `request_id` (the
|
|
36
|
+
`X-Request-Id`, quote it when you report a problem), `headers`, `method` and `url`."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, status: int, headers: httpx.Headers, method: str, url: str) -> None:
|
|
39
|
+
self.status = status
|
|
40
|
+
self.headers = headers
|
|
41
|
+
self.method = method
|
|
42
|
+
self.url = url
|
|
43
|
+
self.request_id = request_id_of(headers)
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
return f"<ResponseInfo {self.method} {self.url} {self.status} request_id={self.request_id}>"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Funnelchat(ClientResources):
|
|
50
|
+
"""Client of the Funnelchat public API.
|
|
51
|
+
|
|
52
|
+
from funnelchat import Funnelchat
|
|
53
|
+
|
|
54
|
+
client = Funnelchat() # reads FUNNELCHAT_API_KEY
|
|
55
|
+
for device in client.devices.list():
|
|
56
|
+
print(device["id"], device["alias"])
|
|
57
|
+
|
|
58
|
+
Responses are plain dicts (the decoded JSON), typed with the TypedDicts of `funnelchat.types`.
|
|
59
|
+
The key never shows up in `repr(client)` or in an error.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
api_key: Optional[str] = None,
|
|
66
|
+
environment: str = "production",
|
|
67
|
+
base_url: Optional[str] = None,
|
|
68
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
69
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
70
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
71
|
+
http_client: Optional[httpx.Client] = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
- `api_key`: `<id>|fc_live_...`. Default: the `FUNNELCHAT_API_KEY` environment variable.
|
|
75
|
+
- `environment`: `production` (`https://api.funnelchat.com`) or `staging`.
|
|
76
|
+
- `base_url`: another base URL (for example the sandbox's); it wins over `environment`.
|
|
77
|
+
- `timeout`: maximum seconds of each attempt. Default 30.
|
|
78
|
+
- `max_retries`: automatic retries on `429`, `5xx` and network errors. Default 2; 0 turns them off.
|
|
79
|
+
- `default_headers`: headers sent with every request.
|
|
80
|
+
- `http_client`: your own `httpx.Client` (proxies, transports…). You close it.
|
|
81
|
+
"""
|
|
82
|
+
key = api_key if api_key is not None else os.environ.get("FUNNELCHAT_API_KEY", "")
|
|
83
|
+
key = key.strip()
|
|
84
|
+
if not key:
|
|
85
|
+
raise FunnelchatError(
|
|
86
|
+
"Missing API key: pass it as `api_key` or in the FUNNELCHAT_API_KEY environment variable."
|
|
87
|
+
)
|
|
88
|
+
if environment not in BASE_URLS:
|
|
89
|
+
raise FunnelchatError(f'Unknown environment: {environment!r}. Use "production" or "staging".')
|
|
90
|
+
self._api_key = key
|
|
91
|
+
self.base_url = (base_url or BASE_URLS[environment]).rstrip("/")
|
|
92
|
+
self.timeout = float(timeout)
|
|
93
|
+
self.max_retries = int(max_retries)
|
|
94
|
+
self._default_headers = dict(default_headers or {})
|
|
95
|
+
self._owns_http = http_client is None
|
|
96
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
97
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
98
|
+
#: The status, headers and `X-Request-Id` of the last response this client got.
|
|
99
|
+
self.last_response: Optional[ResponseInfo] = None
|
|
100
|
+
self._init_resources()
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"Funnelchat(base_url={self.base_url!r}, timeout={self.timeout:g}, max_retries={self.max_retries})"
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Closes the HTTP connections (only those of the httpx.Client the SDK built)."""
|
|
107
|
+
if self._owns_http:
|
|
108
|
+
self._http.close()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> "Funnelchat":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *exc: object) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
# -- any route ------------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def request(
|
|
119
|
+
self,
|
|
120
|
+
method: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
124
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
125
|
+
body: Any = None,
|
|
126
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
127
|
+
timeout: Optional[float] = None,
|
|
128
|
+
max_retries: Optional[int] = None,
|
|
129
|
+
retry_send: bool = False,
|
|
130
|
+
idempotency_key: Optional[str] = None,
|
|
131
|
+
) -> Any:
|
|
132
|
+
"""Calls any route and returns the decoded body (None for an empty answer).
|
|
133
|
+
|
|
134
|
+
contact = client.request("GET", "/api/v1/contacts/{contact}", path_params={"contact": 2001})
|
|
135
|
+
"""
|
|
136
|
+
body_out, _ = self._send(
|
|
137
|
+
method,
|
|
138
|
+
path,
|
|
139
|
+
path_params=path_params,
|
|
140
|
+
query=query,
|
|
141
|
+
body=body,
|
|
142
|
+
headers=headers,
|
|
143
|
+
timeout=timeout,
|
|
144
|
+
max_retries=max_retries,
|
|
145
|
+
retry_send=retry_send,
|
|
146
|
+
idempotency_key=idempotency_key,
|
|
147
|
+
)
|
|
148
|
+
return body_out
|
|
149
|
+
|
|
150
|
+
def paginate(
|
|
151
|
+
self,
|
|
152
|
+
path: str,
|
|
153
|
+
*,
|
|
154
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
155
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
156
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
157
|
+
timeout: Optional[float] = None,
|
|
158
|
+
max_retries: Optional[int] = None,
|
|
159
|
+
) -> Page[Any]:
|
|
160
|
+
"""Fetches the first page of any list route; iterate it for every item of every page."""
|
|
161
|
+
first_query = dict(query or {})
|
|
162
|
+
|
|
163
|
+
def fetch(page_query: Dict[str, Any]) -> Tuple[Any, Optional[str]]:
|
|
164
|
+
return self._send(
|
|
165
|
+
"GET",
|
|
166
|
+
path,
|
|
167
|
+
path_params=path_params,
|
|
168
|
+
query=page_query,
|
|
169
|
+
headers=headers,
|
|
170
|
+
timeout=timeout,
|
|
171
|
+
max_retries=max_retries,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
body, request_id = fetch(first_query)
|
|
175
|
+
return Page(fetch, first_query, body, request_id)
|
|
176
|
+
|
|
177
|
+
# -- transport --------------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
def _send(
|
|
180
|
+
self,
|
|
181
|
+
method: str,
|
|
182
|
+
template: str,
|
|
183
|
+
*,
|
|
184
|
+
path_params: Optional[Mapping[str, Any]] = None,
|
|
185
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
186
|
+
body: Any = None,
|
|
187
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
188
|
+
timeout: Optional[float] = None,
|
|
189
|
+
max_retries: Optional[int] = None,
|
|
190
|
+
retry_send: bool = False,
|
|
191
|
+
idempotency_key: Optional[str] = None,
|
|
192
|
+
) -> Tuple[Any, Optional[str]]:
|
|
193
|
+
verb = method.upper()
|
|
194
|
+
pathname = interpolate_path(template, path_params)
|
|
195
|
+
url = self.base_url + (pathname if pathname.startswith("/") else f"/{pathname}")
|
|
196
|
+
attempts = max(0, self.max_retries if max_retries is None else int(max_retries))
|
|
197
|
+
attempt_timeout = self.timeout if timeout is None else float(timeout)
|
|
198
|
+
is_send = is_send_request(verb, pathname.split("?")[0])
|
|
199
|
+
# A send is never repeated unless the caller asks; then it follows the rule of every write.
|
|
200
|
+
retries_allowed = retry_send or not is_send
|
|
201
|
+
# With an idempotency key the API answers the same resource to a repeated create, so it is
|
|
202
|
+
# retried like a PUT (as in funnelchat-php). A send is not, key or no key.
|
|
203
|
+
retry_as = "PUT" if idempotency_key is not None and not is_send else verb
|
|
204
|
+
|
|
205
|
+
request_headers: Dict[str, str] = {
|
|
206
|
+
"Accept": "application/json",
|
|
207
|
+
"User-Agent": f"funnelchat-python/{__version__} python/{platform.python_version()}",
|
|
208
|
+
}
|
|
209
|
+
content: Optional[bytes] = None
|
|
210
|
+
if body is not None:
|
|
211
|
+
content = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
212
|
+
request_headers["Content-Type"] = "application/json"
|
|
213
|
+
request_headers.update(self._default_headers)
|
|
214
|
+
request_headers.update(headers or {})
|
|
215
|
+
if idempotency_key is not None:
|
|
216
|
+
if not 1 <= len(idempotency_key) <= 255:
|
|
217
|
+
raise FunnelchatError("`idempotency_key` must have between 1 and 255 characters.")
|
|
218
|
+
request_headers["Idempotency-Key"] = idempotency_key
|
|
219
|
+
request_headers["Authorization"] = f"Bearer {self._api_key}"
|
|
220
|
+
params = tuple(serialize_query(query))
|
|
221
|
+
|
|
222
|
+
attempt = 0
|
|
223
|
+
while True:
|
|
224
|
+
can_retry = retries_allowed and attempt < attempts
|
|
225
|
+
try:
|
|
226
|
+
response = self._http.request(
|
|
227
|
+
verb, url, params=params, content=content, headers=request_headers, timeout=attempt_timeout
|
|
228
|
+
)
|
|
229
|
+
except httpx.TimeoutException:
|
|
230
|
+
failure: APIConnectionError = APIConnectionTimeoutError(attempt_timeout)
|
|
231
|
+
except httpx.RequestError as error:
|
|
232
|
+
failure = APIConnectionError(f"Could not connect to the Funnelchat API: {type(error).__name__}.")
|
|
233
|
+
else:
|
|
234
|
+
self.last_response = ResponseInfo(response.status_code, response.headers, verb, url)
|
|
235
|
+
decoded = _decode(response)
|
|
236
|
+
if response.is_success:
|
|
237
|
+
return decoded, self.last_response.request_id
|
|
238
|
+
api_error = APIError.from_response(
|
|
239
|
+
status=response.status_code, body=decoded, headers=response.headers, method=verb, url=url
|
|
240
|
+
)
|
|
241
|
+
if can_retry and is_retryable(retry_as, response.status_code) and api_error.code not in NOT_RETRYABLE_CODES:
|
|
242
|
+
delay = retry_delay(attempt, api_error.retry_after)
|
|
243
|
+
if delay is not None:
|
|
244
|
+
self._sleep(delay)
|
|
245
|
+
attempt += 1
|
|
246
|
+
continue
|
|
247
|
+
raise api_error
|
|
248
|
+
if can_retry and is_retryable(retry_as, None):
|
|
249
|
+
self._sleep(retry_delay(attempt, None) or 0.0)
|
|
250
|
+
attempt += 1
|
|
251
|
+
continue
|
|
252
|
+
raise failure
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _decode(response: httpx.Response) -> Any:
|
|
256
|
+
"""The body: JSON when it parses, the text otherwise, None when there is none (a `204`)."""
|
|
257
|
+
text = response.text
|
|
258
|
+
if text == "":
|
|
259
|
+
return None
|
|
260
|
+
content_type = response.headers.get("content-type", "")
|
|
261
|
+
if "json" in content_type or text.lstrip()[:1] in ("{", "["):
|
|
262
|
+
try:
|
|
263
|
+
return json.loads(text)
|
|
264
|
+
except ValueError:
|
|
265
|
+
return text
|
|
266
|
+
return text
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The stable `error` codes of the API (`APIError.code`), with their HTTP status.
|
|
2
|
+
|
|
3
|
+
Decide by `status` and `code`, never by `message`: its text and language change by area. The same
|
|
4
|
+
tables as funnelchat-node 0.4.0 (src/core/error-codes.ts). Sources, all in production on
|
|
5
|
+
04-11-2026: the key gate as accounts v1.5.97 runs it; the API entry as deployed to production on
|
|
6
|
+
25-10-2026; conversations v1.19.7 (the send rules and the checks before sending a form); accounts
|
|
7
|
+
v1.5.97 (creating and connecting numbers); communities v2.35.5 and v2.35.7 (the rejections of a
|
|
8
|
+
group action). The spec the portal publishes (portal v0.6.13) documents some of these codes in the
|
|
9
|
+
operations that answer them, but not those of the API entry and the key gate. When it documents all
|
|
10
|
+
of them, these tables can be generated.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Dict, FrozenSet, Optional
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ACCESS_ERROR_CODES",
|
|
19
|
+
"SEND_ERROR_CODES",
|
|
20
|
+
"NUMBER_ERROR_CODES",
|
|
21
|
+
"GROUP_ACTION_ERROR_CODES",
|
|
22
|
+
"IDEMPOTENCY_ERROR_CODES",
|
|
23
|
+
"TEMPLATE_SYNC_ERROR_CODES",
|
|
24
|
+
"NOT_RETRYABLE_CODES",
|
|
25
|
+
"is_send_rule_error",
|
|
26
|
+
"is_number_quota_error",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
#: Errors of the API entry and of the key gate, on any route.
|
|
30
|
+
ACCESS_ERROR_CODES: Dict[str, int] = {
|
|
31
|
+
"unauthenticated": 401, # missing or invalid API key
|
|
32
|
+
"permission_denied": 403, # the key lacks the permission the route needs (`message` names it)
|
|
33
|
+
"route_not_available": 403, # the route is not open to API keys (or is turned off)
|
|
34
|
+
"route_not_found": 404, # the path does not exist in the public API
|
|
35
|
+
"daily_cap_exceeded": 429, # with Retry-After: the daily call cap of the key in that area
|
|
36
|
+
"edge_misconfigured": 500, # our problem: report it with the request id
|
|
37
|
+
"origin_unreachable": 502, # no guarantee the area did not get the call: read the state after a write
|
|
38
|
+
"gate_unavailable": 503, # with Retry-After: nothing ran; safe to retry
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#: Rejections of a send with an API key: nothing was sent and nothing was stored.
|
|
42
|
+
SEND_ERROR_CODES: Dict[str, int] = {
|
|
43
|
+
"rate_limited": 429, # with Retry-After: the number's pace
|
|
44
|
+
"mac_lockout_24h": 429, # 24-hour lockout after using up the monthly active contacts; not worth retrying
|
|
45
|
+
"mac_limit_reached": 429, # the monthly active contacts are used up; not worth retrying
|
|
46
|
+
"window_closed": 422, # free text outside the 24-hour window on an official number: send a template
|
|
47
|
+
"antiblock_duplicate_content": 422, # the same content again to a contact that did not answer
|
|
48
|
+
"antiblock_unanswered_limit": 422, # too many messages in a row to a contact that did not answer
|
|
49
|
+
"recipient_mismatch": 422, # the `phone` / `whatsapp_id` is not the contact of the route
|
|
50
|
+
"recipient_unsupported": 422, # the contact is a group, a broadcast list or a channel
|
|
51
|
+
"provider_unsupported": 422, # the number cannot send this (see its `capabilities`)
|
|
52
|
+
"device_unavailable": 422, # the number is not connected or cannot send now
|
|
53
|
+
"not_sent": 422, # the message could not be registered for sending
|
|
54
|
+
"recipient_unreachable": 422, # the contact cannot be reached on WhatsApp (forms)
|
|
55
|
+
"contact_blocked": 422, # the contact is blocked on that number (forms)
|
|
56
|
+
"template_unresolved_placeholder": 422, # a template variable points to a field that does not exist
|
|
57
|
+
"template_not_found": 404, # the template is not one this account can send
|
|
58
|
+
"form_not_found": 404, # the form does not exist
|
|
59
|
+
"form_not_published": 422, # the form is not published
|
|
60
|
+
"form_not_on_number": 422, # the form is not published on that number
|
|
61
|
+
"number_not_eligible": 422, # the number cannot send forms
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#: Rejections of creating a number, asking for its QR or creating, reading or revoking a connect
|
|
65
|
+
#: link: nothing was created. A code with two statuses has the one of the create here.
|
|
66
|
+
NUMBER_ERROR_CODES: Dict[str, int] = {
|
|
67
|
+
"numbers_not_included": 403, # the plan does not include numbers
|
|
68
|
+
"number_limit_reached": 403, # every number of the plan is in use (`limit`, `used`, `pending_device_ids`)
|
|
69
|
+
"connect_not_allowed": 403, # the account cannot connect numbers now
|
|
70
|
+
"payment_required": 403, # `devices.qr()` of a number whose payment is due
|
|
71
|
+
"pending_number_exists": 409, # a QR number is already waiting to be scanned; the body brings its `device_id`
|
|
72
|
+
"qr_requires_coexistence": 422, # (409 on `devices.qr()`) QR only on an official number in coexistence
|
|
73
|
+
"method_already_present": 422, # the number already has that method; the body brings its `device_id`
|
|
74
|
+
"invalid_reference_device": 422, # `same_number_as` or `device_id` is not a number that can take it
|
|
75
|
+
"connection_type_mismatch": 422, # a link with `device_id` of a number that connects another way
|
|
76
|
+
"number_suspended_by_plan": 422, # (409 on `devices.qr()`) suspended: the plan has fewer numbers
|
|
77
|
+
"invalid_request": 422, # the body is not valid; `errors` says which fields
|
|
78
|
+
"trial_number_blocked": 409, # `devices.qr()` of a trial number that is blocked
|
|
79
|
+
"device_inactive": 409, # `devices.qr()` of a number that is turned off
|
|
80
|
+
"provisioning_quota_exceeded": 409, # `devices.qr()` when no more QR sessions can start now
|
|
81
|
+
"link_not_pending": 409, # revoking a connect link that already ended
|
|
82
|
+
"too_many_open_links": 429, # 20 connect links open: revoke some; not worth retrying
|
|
83
|
+
"provider_error": 500, # (502 on `devices.qr()`) the WhatsApp provider failed
|
|
84
|
+
"meta_connect_disabled": 503, # official numbers cannot be connected by link yet; QR links work
|
|
85
|
+
"credentials_not_allowed": 403, # the connect page was opened with a key or a session
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#: Rejections of a message to a group, of following a group and of reading a group action: nothing
|
|
89
|
+
#: was queued or imported. A number or group of another workspace is answered as missing.
|
|
90
|
+
GROUP_ACTION_ERROR_CODES: Dict[str, int] = {
|
|
91
|
+
"device_not_found": 404, # `device_id` is not a number of the key's workspace
|
|
92
|
+
"group_not_found": 404, # the group is not one of the key's workspace
|
|
93
|
+
"action_not_found": 404, # `groups.retrieve_action()` of an action that does not exist
|
|
94
|
+
"unsupported_group_type": 422, # the uid is a community or a channel, not a group
|
|
95
|
+
"device_not_connected": 422, # the number is not connected
|
|
96
|
+
"device_not_group_admin": 422, # a QR number that is not admin of the group
|
|
97
|
+
"device_not_group_number": 422, # an official group that belongs to another number
|
|
98
|
+
"provider_unsupported": 422, # the number cannot do it (`provider`, maybe `requires`, in the body)
|
|
99
|
+
"window_closed": 422, # an official number outside the 24-hour window of the group
|
|
100
|
+
"number_restricted": 422, # Meta restricted the official number
|
|
101
|
+
"unsupported_type": 422, # that `type` cannot be sent to this group by this number
|
|
102
|
+
"too_many_pending_actions": 429, # 50 group actions open on the number; not worth retrying
|
|
103
|
+
"import_permission_required": 403, # following a group needs Groups and campaigns → Manage groups
|
|
104
|
+
"import_rate_limited": 429, # with Retry-After: the number already followed 5 groups this minute
|
|
105
|
+
"import_in_progress": 409, # another call is following the same group right now
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#: Rejections of a create sent with `idempotency_key`.
|
|
109
|
+
IDEMPOTENCY_ERROR_CODES: Dict[str, int] = {
|
|
110
|
+
"idempotency_key_mismatch": 422, # the key was already used with another body
|
|
111
|
+
"invalid_idempotency_key": 422, # the key is not 1 to 255 characters
|
|
112
|
+
"operation_in_progress": 409, # the first call with that key is still running
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#: Rejections of bringing a number's templates from Meta (`templates.sync()`), as the spec the portal
|
|
116
|
+
#: publishes (portal v0.6.13) enumerates them for `POST /api/v1/devices/{device}/templates`. A number
|
|
117
|
+
#: that does not exist, or is of another workspace, is a `404` without a code.
|
|
118
|
+
TEMPLATE_SYNC_ERROR_CODES: Dict[str, int] = {
|
|
119
|
+
"template_sync_in_progress": 409, # one is already running for that number: do not start another
|
|
120
|
+
"number_not_connected_to_meta": 422, # a QR number, or one without a WhatsApp Business account or credentials
|
|
121
|
+
"meta_rejected": 422, # Meta refused and nothing was brought; its code in `meta_code`
|
|
122
|
+
"meta_unavailable": 502, # Meta did not answer
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#: Codes a retry would only repeat: never retried automatically.
|
|
126
|
+
NOT_RETRYABLE_CODES: FrozenSet[str] = frozenset(
|
|
127
|
+
{"mac_lockout_24h", "mac_limit_reached", "too_many_open_links", "too_many_pending_actions", "meta_connect_disabled"}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
_QUOTA_CODES = frozenset(
|
|
131
|
+
{"numbers_not_included", "number_limit_reached", "pending_number_exists", "qr_requires_coexistence", "method_already_present"}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def is_send_rule_error(code: Optional[str]) -> bool:
|
|
136
|
+
"""Whether an `APIError.code` is a rejection of a send: nothing was sent and nothing was stored."""
|
|
137
|
+
return code is not None and code in SEND_ERROR_CODES
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_number_quota_error(code: Optional[str]) -> bool:
|
|
141
|
+
"""Whether an `APIError.code` is a rejection by the number quota of the plan (the same as the panel's)."""
|
|
142
|
+
return code is not None and code in _QUOTA_CODES
|