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/_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