textbee 0.1.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.
- textbee/__init__.py +67 -0
- textbee/_client.py +456 -0
- textbee/_errors.py +31 -0
- textbee/_sms_utils.py +363 -0
- textbee/_types.py +244 -0
- textbee/_version.py +1 -0
- textbee/_webhooks.py +55 -0
- textbee/py.typed +0 -0
- textbee-0.1.0.dist-info/METADATA +307 -0
- textbee-0.1.0.dist-info/RECORD +12 -0
- textbee-0.1.0.dist-info/WHEEL +4 -0
- textbee-0.1.0.dist-info/licenses/LICENSE +21 -0
textbee/_sms_utils.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Pure SMS helpers. No API key, no network, no dependencies.
|
|
2
|
+
|
|
3
|
+
They work with any SMS provider, not just textbee.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# GSM 03.38 character tables
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
# The GSM 03.38 default alphabet, one septet per character.
|
|
18
|
+
#
|
|
19
|
+
# @ £ $ ¥ è é ù ì ò Ç LF Ø ø CR Å å Δ _ Φ Γ Λ Ω Π Ψ Σ Θ Ξ Æ æ ß É
|
|
20
|
+
# space ! " # ¤ % & ' ( ) * + , - . / 0-9 : ; < = > ?
|
|
21
|
+
# ¡ A-Z Ä Ö Ñ Ü § ¿ a-z ä ö ñ ü à
|
|
22
|
+
#
|
|
23
|
+
# Non-ASCII entries are escaped so lookalike characters cannot sneak in.
|
|
24
|
+
GSM7_BASE = (
|
|
25
|
+
"@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\n\u00d8\u00f8\r\u00c5\u00e5"
|
|
26
|
+
"\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e"
|
|
27
|
+
"\u00c6\u00e6\u00df\u00c9"
|
|
28
|
+
" !\"#\u00a4%&'()*+,-./0123456789:;<=>?"
|
|
29
|
+
"\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7"
|
|
30
|
+
"\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# The GSM 03.38 extension table. Each of these costs two septets because it is
|
|
34
|
+
# sent as an escape byte followed by the character.
|
|
35
|
+
#
|
|
36
|
+
# form feed, ^ { } \ [ ~ ] | and the euro sign
|
|
37
|
+
GSM7_EXTENSION = "\f^{}\\[~]|\u20ac"
|
|
38
|
+
|
|
39
|
+
GSM7_BASE_SET = frozenset(GSM7_BASE)
|
|
40
|
+
GSM7_EXTENSION_SET = frozenset(GSM7_EXTENSION)
|
|
41
|
+
|
|
42
|
+
GSM7_SINGLE_SEGMENT_LIMIT = 160
|
|
43
|
+
GSM7_CONCATENATED_SEGMENT_LIMIT = 153
|
|
44
|
+
UCS2_SINGLE_SEGMENT_LIMIT = 70
|
|
45
|
+
UCS2_CONCATENATED_SEGMENT_LIMIT = 67
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _gsm7_cost(char: str) -> int | None:
|
|
49
|
+
"""Septets a character costs in GSM-7, or None when it is not encodable."""
|
|
50
|
+
if char in GSM7_BASE_SET:
|
|
51
|
+
return 1
|
|
52
|
+
if char in GSM7_EXTENSION_SET:
|
|
53
|
+
return 2
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_gsm7_char(char: str) -> bool:
|
|
58
|
+
return _gsm7_cost(char) is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _utf16_units(char: str) -> int:
|
|
62
|
+
"""UTF-16 code units a code point occupies: 2 for astral characters like emoji."""
|
|
63
|
+
return 2 if ord(char) > 0xFFFF else 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Segments and encoding
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
SmsEncoding = Literal["gsm-7", "ucs-2"]
|
|
71
|
+
"""How the message body will be encoded on the wire."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class SmsSegmentInfo:
|
|
76
|
+
encoding: SmsEncoding
|
|
77
|
+
"""``gsm-7`` when every character fits the GSM 03.38 alphabet, else ``ucs-2``."""
|
|
78
|
+
|
|
79
|
+
length: int
|
|
80
|
+
"""Encoded length: septets for gsm-7 (extension characters cost 2), UTF-16
|
|
81
|
+
code units for ucs-2 (emoji and other astral characters cost 2)."""
|
|
82
|
+
|
|
83
|
+
segments: int
|
|
84
|
+
"""Number of SMS segments the message occupies. 0 for an empty string."""
|
|
85
|
+
|
|
86
|
+
remaining_in_segment: int
|
|
87
|
+
"""How many more single-unit characters fit before another segment is
|
|
88
|
+
needed. A two-unit character (extension character or emoji) may not fit
|
|
89
|
+
even when this is 1."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_sms_encoding(message: str) -> SmsEncoding:
|
|
93
|
+
"""Detect the encoding a message requires.
|
|
94
|
+
|
|
95
|
+
``gsm-7`` when every character is in the GSM 03.38 alphabet or its
|
|
96
|
+
extension table, ``ucs-2`` otherwise. An empty string reports ``gsm-7``.
|
|
97
|
+
"""
|
|
98
|
+
for char in message:
|
|
99
|
+
if not _is_gsm7_char(char):
|
|
100
|
+
return "ucs-2"
|
|
101
|
+
return "gsm-7"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def count_sms_segments(message: str) -> SmsSegmentInfo:
|
|
105
|
+
"""Compute encoding, encoded length, segment count and remaining room.
|
|
106
|
+
|
|
107
|
+
Splits the way a standards-following handset does. Single segment limits
|
|
108
|
+
are 160 septets (gsm-7) and 70 code units (ucs-2). Concatenated messages
|
|
109
|
+
carry a header, shrinking each segment to 153 and 67. A two-unit character
|
|
110
|
+
never straddles a boundary: it moves whole to the next segment and wastes
|
|
111
|
+
one unit.
|
|
112
|
+
"""
|
|
113
|
+
encoding = get_sms_encoding(message)
|
|
114
|
+
costs = _character_costs(message, encoding)
|
|
115
|
+
length = sum(costs)
|
|
116
|
+
|
|
117
|
+
single_limit = GSM7_SINGLE_SEGMENT_LIMIT if encoding == "gsm-7" else UCS2_SINGLE_SEGMENT_LIMIT
|
|
118
|
+
|
|
119
|
+
if length == 0:
|
|
120
|
+
return SmsSegmentInfo(encoding, 0, 0, single_limit)
|
|
121
|
+
|
|
122
|
+
if length <= single_limit:
|
|
123
|
+
return SmsSegmentInfo(encoding, length, 1, single_limit - length)
|
|
124
|
+
|
|
125
|
+
concatenated_limit = (
|
|
126
|
+
GSM7_CONCATENATED_SEGMENT_LIMIT if encoding == "gsm-7" else UCS2_CONCATENATED_SEGMENT_LIMIT
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
segments = 1
|
|
130
|
+
used_in_segment = 0
|
|
131
|
+
|
|
132
|
+
for cost in costs:
|
|
133
|
+
if used_in_segment + cost > concatenated_limit:
|
|
134
|
+
segments += 1
|
|
135
|
+
used_in_segment = cost
|
|
136
|
+
else:
|
|
137
|
+
used_in_segment += cost
|
|
138
|
+
|
|
139
|
+
return SmsSegmentInfo(encoding, length, segments, concatenated_limit - used_in_segment)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_non_gsm7_characters(message: str) -> list[str]:
|
|
143
|
+
"""The unique characters forcing a message into ucs-2, in first appearance order.
|
|
144
|
+
|
|
145
|
+
Empty when the message is already gsm-7 safe. Pair it with
|
|
146
|
+
``sanitize_for_gsm7`` to see what is left after sanitizing.
|
|
147
|
+
"""
|
|
148
|
+
found: dict[str, None] = {}
|
|
149
|
+
|
|
150
|
+
for char in message:
|
|
151
|
+
if not _is_gsm7_char(char):
|
|
152
|
+
found.setdefault(char, None)
|
|
153
|
+
|
|
154
|
+
return list(found)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _character_costs(message: str, encoding: SmsEncoding) -> list[int]:
|
|
158
|
+
"""Per-character unit costs, in message order."""
|
|
159
|
+
if encoding == "gsm-7":
|
|
160
|
+
return [_gsm7_cost(char) or 1 for char in message]
|
|
161
|
+
return [_utf16_units(char) for char in message]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# GSM-7 sanitization
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# Unicode characters that have a safe GSM-7 equivalent. Keys are escaped so
|
|
169
|
+
# this file stays readable and never carries the very characters it replaces.
|
|
170
|
+
GSM7_REPLACEMENTS: dict[str, str] = {
|
|
171
|
+
# curly single quotes, prime, acute accent, backtick
|
|
172
|
+
"\u2018": "'",
|
|
173
|
+
"\u2019": "'",
|
|
174
|
+
"\u201a": "'",
|
|
175
|
+
"\u201b": "'",
|
|
176
|
+
"\u2032": "'",
|
|
177
|
+
"\u00b4": "'",
|
|
178
|
+
"`": "'",
|
|
179
|
+
# curly double quotes, double prime, guillemets
|
|
180
|
+
"\u201c": '"',
|
|
181
|
+
"\u201d": '"',
|
|
182
|
+
"\u201e": '"',
|
|
183
|
+
"\u201f": '"',
|
|
184
|
+
"\u2033": '"',
|
|
185
|
+
"\u00ab": '"',
|
|
186
|
+
"\u00bb": '"',
|
|
187
|
+
# ellipsis
|
|
188
|
+
"\u2026": "...",
|
|
189
|
+
# hyphen variants, en dash, em dash, horizontal bar, minus sign
|
|
190
|
+
"\u2010": "-",
|
|
191
|
+
"\u2011": "-",
|
|
192
|
+
"\u2012": "-",
|
|
193
|
+
"\u2013": "-",
|
|
194
|
+
"\u2014": "-",
|
|
195
|
+
"\u2015": "-",
|
|
196
|
+
"\u2212": "-",
|
|
197
|
+
# non-breaking and typographic spaces
|
|
198
|
+
"\u00a0": " ",
|
|
199
|
+
"\u2000": " ",
|
|
200
|
+
"\u2001": " ",
|
|
201
|
+
"\u2002": " ",
|
|
202
|
+
"\u2003": " ",
|
|
203
|
+
"\u2004": " ",
|
|
204
|
+
"\u2005": " ",
|
|
205
|
+
"\u2006": " ",
|
|
206
|
+
"\u2007": " ",
|
|
207
|
+
"\u2008": " ",
|
|
208
|
+
"\u2009": " ",
|
|
209
|
+
"\u200a": " ",
|
|
210
|
+
"\u202f": " ",
|
|
211
|
+
"\u205f": " ",
|
|
212
|
+
"\u3000": " ",
|
|
213
|
+
# invisible characters worth dropping outright
|
|
214
|
+
"\u200b": "",
|
|
215
|
+
"\ufeff": "",
|
|
216
|
+
"\u00ad": "",
|
|
217
|
+
# fraction slash
|
|
218
|
+
"\u2044": "/",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Letters NFD cannot decompose into an ASCII base letter.
|
|
222
|
+
TRANSLITERATION_EXTRAS: dict[str, str] = {
|
|
223
|
+
"\u0152": "OE",
|
|
224
|
+
"\u0153": "oe",
|
|
225
|
+
"\u0141": "L",
|
|
226
|
+
"\u0142": "l",
|
|
227
|
+
"\u0110": "D",
|
|
228
|
+
"\u0111": "d",
|
|
229
|
+
"\u0131": "i",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
COMBINING_MARKS = re.compile("[\u0300-\u036f]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def sanitize_for_gsm7(message: str, *, transliterate_accents: bool = False) -> str:
|
|
236
|
+
"""Replace common Unicode lookalikes with GSM-7 equivalents.
|
|
237
|
+
|
|
238
|
+
Curly quotes, ellipsis, exotic spaces and dashes are swapped, so pasted
|
|
239
|
+
text does not silently fall into ucs-2 and more than double its segment
|
|
240
|
+
count.
|
|
241
|
+
|
|
242
|
+
``transliterate_accents`` strips accents from Latin letters that GSM-7 does
|
|
243
|
+
not carry, so ``ï`` becomes ``i``. Letters GSM-7 already has (é, ü, ñ, à
|
|
244
|
+
and friends) are never touched.
|
|
245
|
+
|
|
246
|
+
Best effort by design: characters with no safe replacement are left
|
|
247
|
+
alone. Check the result with ``get_sms_encoding`` and inspect leftovers
|
|
248
|
+
with ``find_non_gsm7_characters``.
|
|
249
|
+
"""
|
|
250
|
+
parts: list[str] = []
|
|
251
|
+
|
|
252
|
+
for char in message:
|
|
253
|
+
replacement = GSM7_REPLACEMENTS.get(char)
|
|
254
|
+
if replacement is not None:
|
|
255
|
+
parts.append(replacement)
|
|
256
|
+
elif _is_gsm7_char(char) or not transliterate_accents:
|
|
257
|
+
parts.append(char)
|
|
258
|
+
else:
|
|
259
|
+
parts.append(_transliterate(char))
|
|
260
|
+
|
|
261
|
+
return "".join(parts)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _transliterate(char: str) -> str:
|
|
265
|
+
"""Best-effort ASCII form of a single character, or the character unchanged."""
|
|
266
|
+
extra = TRANSLITERATION_EXTRAS.get(char)
|
|
267
|
+
if extra is not None:
|
|
268
|
+
return extra
|
|
269
|
+
|
|
270
|
+
stripped = COMBINING_MARKS.sub("", unicodedata.normalize("NFD", char))
|
|
271
|
+
if len(stripped) == 1 and stripped.isascii() and stripped.isalpha():
|
|
272
|
+
return stripped
|
|
273
|
+
|
|
274
|
+
return char
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Phone numbers
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
E164_PATTERN = re.compile(r"\+[1-9][0-9]{1,14}")
|
|
282
|
+
# JavaScript's whitespace set, so trimming matches textbee-js. Python's own
|
|
283
|
+
# set misses the byte order mark that leads many CSV exports.
|
|
284
|
+
JS_WHITESPACE = (
|
|
285
|
+
"\t\n\x0b\x0c\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005"
|
|
286
|
+
"\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
287
|
+
)
|
|
288
|
+
FORMATTING_CHARACTERS = re.compile(f"[{re.escape(JS_WHITESPACE)}.\\-()]")
|
|
289
|
+
DIGITS_ONLY = re.compile(r"[0-9]+")
|
|
290
|
+
COUNTRY_CODE_PATTERN = re.compile(r"[1-9][0-9]{0,2}")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def is_valid_e164(value: str) -> bool:
|
|
294
|
+
"""Check that a string is strictly E.164.
|
|
295
|
+
|
|
296
|
+
A plus sign, a leading digit 1-9, then 1 to 14 more digits, nothing else.
|
|
297
|
+
This is a format check only. It does not know country dialing plans, so a
|
|
298
|
+
well-formed but unassigned number still passes.
|
|
299
|
+
"""
|
|
300
|
+
return E164_PATTERN.fullmatch(value) is not None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def normalize_phone_number(
|
|
304
|
+
value: str,
|
|
305
|
+
*,
|
|
306
|
+
default_country_code: str | int | None = None,
|
|
307
|
+
) -> str | None:
|
|
308
|
+
"""Normalize free-form phone input to E.164, or return None when it cannot be done safely.
|
|
309
|
+
|
|
310
|
+
Strips spaces, dots, hyphens and parentheses, converts a leading ``00`` to
|
|
311
|
+
a plus, and optionally applies a default country code.
|
|
312
|
+
|
|
313
|
+
``default_country_code`` applies to national-format input, with or without
|
|
314
|
+
a leading plus: ``"1"`` and ``"+1"`` both work. With it, ``(202) 555-0123``
|
|
315
|
+
becomes ``+12025550123`` and one leading trunk zero is dropped. Without it,
|
|
316
|
+
national-format input returns None.
|
|
317
|
+
|
|
318
|
+
This is a formatter, not libphonenumber: it never validates a number
|
|
319
|
+
against a country dialing plan. Bad input returns None, a bad
|
|
320
|
+
``default_country_code`` raises ``ValueError``.
|
|
321
|
+
"""
|
|
322
|
+
trimmed = value.strip(JS_WHITESPACE)
|
|
323
|
+
if not trimmed:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
has_plus = trimmed.startswith("+")
|
|
327
|
+
without_plus = trimmed[1:] if has_plus else trimmed
|
|
328
|
+
digits = FORMATTING_CHARACTERS.sub("", without_plus)
|
|
329
|
+
|
|
330
|
+
if DIGITS_ONLY.fullmatch(digits) is None:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
if has_plus:
|
|
334
|
+
return _as_e164_or_none(f"+{digits}")
|
|
335
|
+
|
|
336
|
+
if digits.startswith("00"):
|
|
337
|
+
return _as_e164_or_none(f"+{digits[2:]}")
|
|
338
|
+
|
|
339
|
+
country_code = _normalize_country_code(default_country_code)
|
|
340
|
+
if country_code is None:
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
national = digits[1:] if digits.startswith("0") else digits
|
|
344
|
+
|
|
345
|
+
return _as_e164_or_none(f"+{country_code}{national}")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _as_e164_or_none(candidate: str) -> str | None:
|
|
349
|
+
return candidate if is_valid_e164(candidate) else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _normalize_country_code(value: str | int | None) -> str | None:
|
|
353
|
+
if value is None:
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
text = str(value)
|
|
357
|
+
code = text[1:] if text.startswith("+") else text
|
|
358
|
+
if COUNTRY_CODE_PATTERN.fullmatch(code) is None:
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"default_country_code must be 1 to 3 digits starting with 1-9, got {value!r}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
return code
|
textbee/_types.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Response shapes. Keys are the API's wire names, so they stay camelCase."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Literal, Optional, TypedDict, Union, final
|
|
6
|
+
|
|
7
|
+
Direction = Literal["all", "sent", "received"]
|
|
8
|
+
"""Filter values for ``direction``."""
|
|
9
|
+
|
|
10
|
+
Order = Literal["desc", "asc"]
|
|
11
|
+
"""``desc`` (default) for newest first, ``asc`` to walk forward when polling."""
|
|
12
|
+
|
|
13
|
+
MessageStatus = Literal[
|
|
14
|
+
"pending",
|
|
15
|
+
"dispatched",
|
|
16
|
+
"sent",
|
|
17
|
+
"delivered",
|
|
18
|
+
"failed",
|
|
19
|
+
"unknown",
|
|
20
|
+
"received",
|
|
21
|
+
]
|
|
22
|
+
"""Delivery state of a message, lowercase."""
|
|
23
|
+
|
|
24
|
+
SmsBatchStatus = Literal[
|
|
25
|
+
"pending",
|
|
26
|
+
"processing",
|
|
27
|
+
"completed",
|
|
28
|
+
"partial_success",
|
|
29
|
+
"failed",
|
|
30
|
+
"unknown",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
WebhookEvent = Literal[
|
|
34
|
+
"MESSAGE_RECEIVED",
|
|
35
|
+
"MESSAGE_SENT",
|
|
36
|
+
"MESSAGE_DELIVERED",
|
|
37
|
+
"MESSAGE_FAILED",
|
|
38
|
+
"UNKNOWN_STATE",
|
|
39
|
+
"SMS_STATUS_UPDATED",
|
|
40
|
+
]
|
|
41
|
+
"""Event names textbee sends in a webhook payload."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SendSmsWarning(TypedDict):
|
|
45
|
+
"""A reason the send will probably fail, such as ``SMS_PERMISSION_MISSING``."""
|
|
46
|
+
|
|
47
|
+
code: str
|
|
48
|
+
message: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _SendSmsQueuedRequired(TypedDict):
|
|
52
|
+
success: Literal[True]
|
|
53
|
+
message: str
|
|
54
|
+
smsBatchId: str
|
|
55
|
+
recipientCount: int
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@final
|
|
59
|
+
class SendSmsQueuedResponse(_SendSmsQueuedRequired, total=False):
|
|
60
|
+
"""Returned when the message was accepted for delivery and queued."""
|
|
61
|
+
|
|
62
|
+
estimatedCompletionAt: str
|
|
63
|
+
"""When a large send, spread over several waves, should finish."""
|
|
64
|
+
|
|
65
|
+
warning: SendSmsWarning
|
|
66
|
+
"""Present when the phone last reported a problem, such as the SMS permission off."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _SendSmsImmediateRequired(TypedDict):
|
|
70
|
+
successCount: int
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@final
|
|
74
|
+
class SendSmsImmediateResponse(_SendSmsImmediateRequired, total=False):
|
|
75
|
+
"""Returned when the message was dispatched to the device immediately."""
|
|
76
|
+
|
|
77
|
+
success: bool
|
|
78
|
+
failureCount: int
|
|
79
|
+
warning: SendSmsWarning
|
|
80
|
+
"""Present when the phone last reported a problem, such as the SMS permission off."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
SendSmsResponse = Union[SendSmsQueuedResponse, SendSmsImmediateResponse]
|
|
84
|
+
"""The send response shape depends on how the message was dispatched.
|
|
85
|
+
|
|
86
|
+
Narrow it with ``"smsBatchId" in result`` before reading batch fields.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _DeviceRequired(TypedDict):
|
|
91
|
+
_id: str
|
|
92
|
+
enabled: bool
|
|
93
|
+
isDefault: bool
|
|
94
|
+
sentSMSCount: int
|
|
95
|
+
receivedSMSCount: int
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Device(_DeviceRequired, total=False):
|
|
99
|
+
name: str
|
|
100
|
+
brand: str
|
|
101
|
+
manufacturer: str
|
|
102
|
+
model: str
|
|
103
|
+
lastHeartbeat: str
|
|
104
|
+
os: str
|
|
105
|
+
osVersion: str
|
|
106
|
+
appVersionName: str
|
|
107
|
+
appVersionCode: int
|
|
108
|
+
createdAt: str
|
|
109
|
+
updatedAt: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _MessageDeviceRequired(TypedDict):
|
|
113
|
+
_id: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MessageDevice(_MessageDeviceRequired, total=False):
|
|
117
|
+
name: str
|
|
118
|
+
brand: str
|
|
119
|
+
model: str
|
|
120
|
+
buildId: str
|
|
121
|
+
enabled: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _MessageRequired(TypedDict):
|
|
125
|
+
_id: str
|
|
126
|
+
message: str
|
|
127
|
+
type: Literal["SENT", "RECEIVED"]
|
|
128
|
+
"""Direction, uppercase. Deprecated: read ``direction`` instead."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _MessageFields(_MessageRequired, total=False):
|
|
132
|
+
channel: str
|
|
133
|
+
"""Message channel. Currently always ``sms``; absent means ``sms``."""
|
|
134
|
+
|
|
135
|
+
status: MessageStatus
|
|
136
|
+
"""Lowercase, and absent on messages stored before status tracking."""
|
|
137
|
+
|
|
138
|
+
sender: str
|
|
139
|
+
recipient: str
|
|
140
|
+
requestedAt: str
|
|
141
|
+
dispatchedAt: str
|
|
142
|
+
sentAt: str
|
|
143
|
+
deliveredAt: str
|
|
144
|
+
failedAt: str
|
|
145
|
+
receivedAt: str
|
|
146
|
+
errorCode: str
|
|
147
|
+
errorMessage: str
|
|
148
|
+
simSubscriptionId: int
|
|
149
|
+
smsBatch: str
|
|
150
|
+
createdAt: str
|
|
151
|
+
updatedAt: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _SmsBatchRequired(TypedDict):
|
|
155
|
+
_id: str
|
|
156
|
+
message: str
|
|
157
|
+
recipientCount: int
|
|
158
|
+
successCount: int
|
|
159
|
+
"""Messages sent or delivered: ``sentCount + deliveredCount``."""
|
|
160
|
+
|
|
161
|
+
failureCount: int
|
|
162
|
+
"""Messages that failed."""
|
|
163
|
+
|
|
164
|
+
status: SmsBatchStatus
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SmsBatch(_SmsBatchRequired, total=False):
|
|
168
|
+
"""A send batch.
|
|
169
|
+
|
|
170
|
+
The status and counts are calculated from the batch's messages, so they
|
|
171
|
+
change as each message reports.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
recipientPreview: str
|
|
175
|
+
pendingCount: int
|
|
176
|
+
"""Messages not yet handed to the phone."""
|
|
177
|
+
|
|
178
|
+
dispatchedCount: int
|
|
179
|
+
"""Messages handed to the phone with no result yet."""
|
|
180
|
+
|
|
181
|
+
sentCount: int
|
|
182
|
+
"""Messages sent with no delivery report yet."""
|
|
183
|
+
|
|
184
|
+
deliveredCount: int
|
|
185
|
+
"""Messages with a delivery report."""
|
|
186
|
+
|
|
187
|
+
unknownCount: int
|
|
188
|
+
"""Messages with no report from the phone within 20 minutes."""
|
|
189
|
+
|
|
190
|
+
error: str
|
|
191
|
+
completedAt: str
|
|
192
|
+
"""When the batch reached its current final status. Absent while processing."""
|
|
193
|
+
|
|
194
|
+
statusCheckedAt: str
|
|
195
|
+
"""When the status and counts were last calculated."""
|
|
196
|
+
|
|
197
|
+
createdAt: str
|
|
198
|
+
updatedAt: str
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class _MessageListMetaRequired(TypedDict):
|
|
202
|
+
limit: int
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class MessageListMeta(_MessageListMetaRequired, total=False):
|
|
206
|
+
"""Meta for the account-level message list.
|
|
207
|
+
|
|
208
|
+
Page mode carries the counters and the cursor fields. Cursor mode carries
|
|
209
|
+
``limit``, ``nextCursor`` and ``hasMore`` only, skipping the total count.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
page: int
|
|
213
|
+
total: int
|
|
214
|
+
totalPages: int
|
|
215
|
+
nextCursor: Optional[str]
|
|
216
|
+
"""Opaque position after the last message. ``None`` on the final page."""
|
|
217
|
+
|
|
218
|
+
hasMore: bool
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Message(_MessageFields, total=False):
|
|
222
|
+
direction: Literal["sent", "received"]
|
|
223
|
+
"""Direction, lowercase. Matches the ``direction`` filter values."""
|
|
224
|
+
|
|
225
|
+
device: MessageDevice
|
|
226
|
+
"""The sending or receiving device."""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class Sms(_MessageFields, total=False):
|
|
230
|
+
"""A message as ``get_sms`` and ``get_sms_batch`` return it: ``device`` is
|
|
231
|
+
the id string, and ``direction`` is absent (read ``type``)."""
|
|
232
|
+
|
|
233
|
+
device: str
|
|
234
|
+
"""The id of the sending or receiving device."""
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class MessageList(TypedDict):
|
|
238
|
+
data: List[Message]
|
|
239
|
+
meta: MessageListMeta
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class SmsBatchResult(TypedDict):
|
|
243
|
+
batch: SmsBatch
|
|
244
|
+
messages: List[Sms]
|
textbee/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
textbee/_webhooks.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_webhook_signature(
|
|
12
|
+
*,
|
|
13
|
+
payload: str | bytes | bytearray | Mapping[str, Any],
|
|
14
|
+
signature: str | None,
|
|
15
|
+
signing_secret: str | None,
|
|
16
|
+
) -> bool:
|
|
17
|
+
"""Check that a webhook really came from textbee.
|
|
18
|
+
|
|
19
|
+
textbee signs deliveries with HMAC-SHA256 over the request body and sends
|
|
20
|
+
the hex digest in the ``X-Signature`` header. Comparison is constant time.
|
|
21
|
+
|
|
22
|
+
``payload`` is the request body. Prefer the raw body (``bytes`` or ``str``).
|
|
23
|
+
A parsed dict works too, re-serialized the way the API serializes it, but
|
|
24
|
+
only while key order and number formatting survive the round trip.
|
|
25
|
+
``signing_secret`` is shown with the webhook subscription in the dashboard.
|
|
26
|
+
"""
|
|
27
|
+
if not signature or not signing_secret:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
expected = hmac.new(
|
|
31
|
+
signing_secret.encode("utf-8"),
|
|
32
|
+
_body_bytes(payload),
|
|
33
|
+
hashlib.sha256,
|
|
34
|
+
).hexdigest()
|
|
35
|
+
|
|
36
|
+
return hmac.compare_digest(expected.encode("ascii"), signature.lower().encode("utf-8"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _body_bytes(payload: str | bytes | bytearray | Mapping[str, Any]) -> bytes:
|
|
40
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
41
|
+
return bytes(payload)
|
|
42
|
+
if isinstance(payload, str):
|
|
43
|
+
return payload.encode("utf-8")
|
|
44
|
+
|
|
45
|
+
# Matches JSON.stringify: no spaces, non-ASCII left unescaped, and a lone
|
|
46
|
+
# surrogate (from a truncated emoji) escaped instead of raising.
|
|
47
|
+
text = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
48
|
+
return _LONE_SURROGATE.sub(_escape, text).encode("utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_LONE_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _escape(match: re.Match[str]) -> str:
|
|
55
|
+
return f"\\u{ord(match.group()):04x}"
|
textbee/py.typed
ADDED
|
File without changes
|