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 ADDED
@@ -0,0 +1,67 @@
1
+ """Official Python SDK for textbee.dev, the open source SMS gateway."""
2
+
3
+ from ._client import Textbee
4
+ from ._errors import TextbeeError
5
+ from ._sms_utils import (
6
+ SmsEncoding,
7
+ SmsSegmentInfo,
8
+ count_sms_segments,
9
+ find_non_gsm7_characters,
10
+ get_sms_encoding,
11
+ is_valid_e164,
12
+ normalize_phone_number,
13
+ sanitize_for_gsm7,
14
+ )
15
+ from ._types import (
16
+ Device,
17
+ Direction,
18
+ Message,
19
+ MessageDevice,
20
+ MessageList,
21
+ MessageListMeta,
22
+ MessageStatus,
23
+ Order,
24
+ SendSmsImmediateResponse,
25
+ SendSmsQueuedResponse,
26
+ SendSmsResponse,
27
+ SendSmsWarning,
28
+ Sms,
29
+ SmsBatch,
30
+ SmsBatchResult,
31
+ SmsBatchStatus,
32
+ WebhookEvent,
33
+ )
34
+ from ._version import __version__
35
+ from ._webhooks import verify_webhook_signature
36
+
37
+ __all__ = [
38
+ "Device",
39
+ "Direction",
40
+ "Message",
41
+ "MessageDevice",
42
+ "MessageList",
43
+ "MessageListMeta",
44
+ "MessageStatus",
45
+ "Order",
46
+ "SendSmsImmediateResponse",
47
+ "SendSmsQueuedResponse",
48
+ "SendSmsResponse",
49
+ "SendSmsWarning",
50
+ "Sms",
51
+ "SmsBatch",
52
+ "SmsBatchResult",
53
+ "SmsBatchStatus",
54
+ "SmsEncoding",
55
+ "SmsSegmentInfo",
56
+ "Textbee",
57
+ "TextbeeError",
58
+ "WebhookEvent",
59
+ "__version__",
60
+ "count_sms_segments",
61
+ "find_non_gsm7_characters",
62
+ "get_sms_encoding",
63
+ "is_valid_e164",
64
+ "normalize_phone_number",
65
+ "sanitize_for_gsm7",
66
+ "verify_webhook_signature",
67
+ ]
textbee/_client.py ADDED
@@ -0,0 +1,456 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Iterator, Mapping, Sequence
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Union, cast
8
+ from urllib.error import HTTPError
9
+ from urllib.parse import quote, urlencode, urlsplit
10
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
11
+
12
+ from ._errors import TextbeeError
13
+ from ._types import (
14
+ Device,
15
+ Direction,
16
+ Message,
17
+ MessageList,
18
+ MessageStatus,
19
+ Order,
20
+ SendSmsResponse,
21
+ Sms,
22
+ SmsBatchResult,
23
+ )
24
+ from ._version import __version__
25
+
26
+ DEFAULT_BASE_URL = "https://api.textbee.dev/api/v1"
27
+ DEFAULT_TIMEOUT = 30.0
28
+ CLIENT_ID = f"textbee-python/{__version__}"
29
+
30
+ DateInput = Union[str, datetime]
31
+ QueryValue = Union[str, int, None]
32
+
33
+
34
+ class Textbee:
35
+ """Client for the textbee REST API.
36
+
37
+ ```python
38
+ textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])
39
+ textbee.send_sms(recipients=["+12025550123"], message="Hello from textbee!")
40
+ ```
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_key: str,
46
+ *,
47
+ base_url: str | None = None,
48
+ timeout: float = DEFAULT_TIMEOUT,
49
+ ) -> None:
50
+ """Create a client.
51
+
52
+ ``api_key`` comes from the textbee dashboard and is sent as the
53
+ ``x-api-key`` header.
54
+
55
+ ``base_url`` points the client at a self-hosted instance instead of
56
+ ``https://api.textbee.dev/api/v1``. A bare origin such as
57
+ ``https://sms.example.com`` gets ``/api/v1`` added. A URL that contains
58
+ ``/api/v1`` is cut there, so a pasted endpoint URL still works. Any
59
+ other path is used as given, for proxies that map a prefix onto the
60
+ API. A value with no scheme gets ``https://``. An invalid URL raises
61
+ ``ValueError`` rather than falling back, so a typo never sends your key
62
+ to the public API.
63
+
64
+ ``timeout`` is in seconds and applies to each connect and read, not to
65
+ the request as a whole.
66
+ """
67
+ api_key = (api_key or "").strip()
68
+ if not api_key:
69
+ raise ValueError("Textbee requires an api_key")
70
+ # Never echo the key: this error ends up in logs and error trackers.
71
+ if not api_key.isascii() or not api_key.isprintable():
72
+ raise ValueError("api_key contains invalid characters")
73
+
74
+ self._api_key = api_key
75
+ self._base_url = _normalize_base_url(base_url)
76
+ self._timeout = timeout
77
+
78
+ def __repr__(self) -> str:
79
+ return f"Textbee(base_url={self._base_url!r})"
80
+
81
+ def send_sms(
82
+ self,
83
+ *,
84
+ message: str,
85
+ recipients: Sequence[str],
86
+ device_id: str | None = None,
87
+ sim_subscription_id: int | None = None,
88
+ scheduled_at: DateInput | None = None,
89
+ ) -> SendSmsResponse:
90
+ """Send an SMS to one or more recipients.
91
+
92
+ ``recipients`` are phone numbers in international format.
93
+
94
+ ``device_id`` picks the sending phone. Get ids from ``get_devices()``.
95
+ Omit it and textbee picks the sender: your default device first,
96
+ otherwise the enabled device with the most recent heartbeat. A malformed
97
+ id fails with 400 rather than falling back, so a typo cannot send from
98
+ the wrong phone.
99
+
100
+ ``sim_subscription_id`` picks the SIM on a multi-SIM phone. It is not
101
+ validated: an id that does not match a SIM in the phone is ignored and
102
+ the message goes out from the SIM configured in the app, or the system
103
+ default. Find the id in the Android app under Dashboard, "SIM Cards".
104
+
105
+ ``scheduled_at`` sends later instead of now, as an ISO 8601 string or a
106
+ timezone-aware ``datetime`` in the future.
107
+ """
108
+ if isinstance(recipients, str):
109
+ raise TypeError("recipients must be a list of phone numbers, not a string")
110
+
111
+ body: dict[str, Any] = {
112
+ "message": message,
113
+ "recipients": list(recipients),
114
+ }
115
+ if device_id is not None:
116
+ body["deviceId"] = device_id
117
+ if sim_subscription_id is not None:
118
+ body["simSubscriptionId"] = sim_subscription_id
119
+ if scheduled_at is not None:
120
+ body["scheduledAt"] = _to_iso_string(scheduled_at, "scheduled_at")
121
+
122
+ payload = self._request("POST", "/gateway/send-sms", body=body)
123
+ return cast(SendSmsResponse, _data(payload))
124
+
125
+ def get_devices(self) -> list[Device]:
126
+ """List the devices registered to this account."""
127
+ payload = self._request("GET", "/gateway/devices")
128
+ return cast(list[Device], _data(payload))
129
+
130
+ def get_device(self, device_id: str) -> Device:
131
+ """Fetch a single device by id."""
132
+ payload = self._request("GET", f"/gateway/devices/{_segment(device_id)}")
133
+ return cast(Device, _data(payload))
134
+
135
+ def set_default_device(self, device_id: str) -> Device:
136
+ """Make a device the default sender for requests that omit ``device_id``."""
137
+ payload = self._request("POST", f"/gateway/devices/{_segment(device_id)}/set-default")
138
+ return cast(Device, _data(payload))
139
+
140
+ def get_messages(
141
+ self,
142
+ *,
143
+ device_ids: Sequence[str] | None = None,
144
+ sms_batch_id: str | None = None,
145
+ direction: Direction | None = None,
146
+ status: MessageStatus | None = None,
147
+ search: str | None = None,
148
+ from_: DateInput | None = None,
149
+ to: DateInput | None = None,
150
+ order: Order | None = None,
151
+ page: int | None = None,
152
+ limit: int | None = None,
153
+ cursor: str | None = None,
154
+ ) -> MessageList:
155
+ """Page through the account's sent and received messages.
156
+
157
+ ``device_ids`` narrows to some devices; omit it for every device.
158
+ ``sms_batch_id`` narrows to one send; combine it with
159
+ ``status="failed"`` to list the recipients that failed. ``search`` is a
160
+ free text match across body, recipient and sender.
161
+
162
+ ``from_`` is an inclusive lower bound on when the platform stored the
163
+ message, ``to`` an exclusive upper bound. Pass a timezone-aware
164
+ ``datetime`` or a string with an explicit timezone
165
+ (``2026-08-01T00:00:00Z``); a bare date like ``2026-08-01`` is read as
166
+ UTC midnight.
167
+
168
+ ``page`` is 1-based and ``limit`` defaults to 50, capped at 100 by the
169
+ API. ``cursor`` is ``meta["nextCursor"]`` from a previous response and
170
+ is mutually exclusive with ``page``; in cursor mode ``meta`` omits the
171
+ total count.
172
+ """
173
+ if isinstance(device_ids, str):
174
+ raise TypeError("device_ids must be a list of device ids, not a string")
175
+
176
+ # The endpoint returns {data, meta} with no outer wrapper, so the whole
177
+ # payload is the result.
178
+ payload = self._request(
179
+ "GET",
180
+ "/gateway/messages",
181
+ query={
182
+ "deviceIds": ",".join(device_ids) if device_ids else None,
183
+ "smsBatchId": sms_batch_id,
184
+ "direction": direction,
185
+ "status": status,
186
+ "search": search,
187
+ "from": _to_iso_string(from_, "from_"),
188
+ "to": _to_iso_string(to, "to"),
189
+ "order": order,
190
+ "page": page,
191
+ "limit": limit,
192
+ "cursor": cursor,
193
+ },
194
+ )
195
+ _data(payload)
196
+ return cast(MessageList, payload)
197
+
198
+ def iterate_messages(
199
+ self,
200
+ *,
201
+ device_ids: Sequence[str] | None = None,
202
+ sms_batch_id: str | None = None,
203
+ direction: Direction | None = None,
204
+ status: MessageStatus | None = None,
205
+ search: str | None = None,
206
+ from_: DateInput | None = None,
207
+ to: DateInput | None = None,
208
+ order: Order | None = None,
209
+ page_size: int | None = None,
210
+ ) -> Iterator[Message]:
211
+ """Iterate every message matching the filters, following the cursor to the end.
212
+
213
+ Takes the ``get_messages`` filters minus the paging knobs it drives
214
+ itself. ``page_size`` is how many messages each request fetches (the
215
+ API's ``limit``), not a cap on the total. Use ``order="asc"`` with a
216
+ ``from_`` bound to drain forward when polling. ``from_`` is inclusive,
217
+ so a poll that resumes from the last ``createdAt`` sees that message
218
+ again; skip ids you have already handled.
219
+
220
+ ```python
221
+ for message in textbee.iterate_messages(direction="received"):
222
+ handle(message)
223
+ ```
224
+ """
225
+ filters: dict[str, Any] = {
226
+ "device_ids": device_ids,
227
+ "sms_batch_id": sms_batch_id,
228
+ "direction": direction,
229
+ "status": status,
230
+ "search": search,
231
+ "from_": from_,
232
+ "to": to,
233
+ "order": order,
234
+ "limit": page_size,
235
+ }
236
+
237
+ page = self.get_messages(**filters)
238
+ yield from page["data"]
239
+
240
+ while page["meta"].get("nextCursor"):
241
+ page = self.get_messages(**filters, cursor=page["meta"].get("nextCursor"))
242
+ yield from page["data"]
243
+
244
+ def get_sms(self, device_id: str, sms_id: str) -> Sms:
245
+ """Fetch a single message, including its delivery status."""
246
+ payload = self._request(
247
+ "GET",
248
+ f"/gateway/devices/{_segment(device_id)}/sms/{_segment(sms_id)}",
249
+ )
250
+ return cast(Sms, _data(payload))
251
+
252
+ def get_sms_batch(self, device_id: str, sms_batch_id: str) -> SmsBatchResult:
253
+ """Fetch a batch and every message in it, by the ``smsBatchId`` a send returns.
254
+
255
+ Needs the id of the device that sent the batch. When textbee picked the
256
+ device, use ``get_messages(sms_batch_id=...)`` instead.
257
+ """
258
+ payload = self._request(
259
+ "GET",
260
+ f"/gateway/devices/{_segment(device_id)}/sms-batch/{_segment(sms_batch_id)}",
261
+ )
262
+ return cast(SmsBatchResult, _data(payload))
263
+
264
+ def _request(
265
+ self,
266
+ method: str,
267
+ path: str,
268
+ *,
269
+ query: Mapping[str, QueryValue] | None = None,
270
+ body: Mapping[str, Any] | None = None,
271
+ ) -> Any:
272
+ url = f"{self._base_url}{path}"
273
+ params = {key: str(value) for key, value in (query or {}).items() if value is not None}
274
+ if params:
275
+ url = f"{url}?{urlencode(params)}"
276
+
277
+ headers = {
278
+ "x-api-key": self._api_key,
279
+ "user-agent": CLIENT_ID,
280
+ "x-sdk-client": CLIENT_ID,
281
+ "accept": "application/json",
282
+ }
283
+ data = None
284
+ if body is not None:
285
+ headers["content-type"] = "application/json"
286
+ data = json.dumps(body).encode("utf-8")
287
+
288
+ request = Request(url, data=data, headers=headers, method=method)
289
+ location = None
290
+ try:
291
+ with _OPENER.open(request, timeout=self._timeout) as response:
292
+ status: int = response.status
293
+ raw: bytes = response.read()
294
+ except HTTPError as error:
295
+ status = error.code
296
+ location = error.headers.get("location") if error.headers else None
297
+ raw = error.read()
298
+ error.close()
299
+
300
+ payload = _parse_body(raw)
301
+
302
+ if 300 <= status < 400:
303
+ # Redirects are refused so the key never follows one to another
304
+ # host or to plain http.
305
+ target = f" to {location}" if location else ""
306
+ raise TextbeeError(
307
+ status, f"base_url redirects{target}. Set base_url to the final URL.", payload
308
+ )
309
+ if not 200 <= status < 300:
310
+ raise TextbeeError(status, _error_message(payload, status), payload)
311
+ if not isinstance(payload, dict):
312
+ raise TextbeeError(
313
+ status,
314
+ "The response was not a JSON object. Check that base_url points at the "
315
+ "textbee API.",
316
+ payload,
317
+ )
318
+
319
+ return payload
320
+
321
+
322
+ class _RefuseRedirects(HTTPRedirectHandler):
323
+ def redirect_request(self, *args: Any, **kwargs: Any) -> None:
324
+ return None
325
+
326
+
327
+ _OPENER = build_opener(_RefuseRedirects)
328
+
329
+
330
+ def _data(payload: dict[str, Any]) -> Any:
331
+ if "data" not in payload:
332
+ raise TextbeeError(
333
+ 200,
334
+ "The response had no data field. Check that base_url points at the textbee API.",
335
+ payload,
336
+ )
337
+ return payload["data"]
338
+
339
+
340
+ def _normalize_base_url(raw: str | None) -> str:
341
+ """Resolve the API root from a base URL. Same rule as textbee-js.
342
+
343
+ A bare origin gets /api/v1, a path containing /api/v1 is cut there, and
344
+ any other path is kept as given for proxies that map a prefix onto the API.
345
+ """
346
+ value = (raw or "").strip()
347
+ if not value:
348
+ return DEFAULT_BASE_URL
349
+
350
+ with_scheme = value if _HAS_SCHEME.match(value) else f"https://{value}"
351
+ parts = urlsplit(with_scheme)
352
+ scheme = parts.scheme.lower()
353
+
354
+ if scheme not in ("http", "https"):
355
+ raise ValueError(f"base_url must be http or https, got {scheme!r}")
356
+ if "@" in parts.netloc:
357
+ # textbee never uses basic auth, and https://api.textbee.dev@evil.com
358
+ # reads like textbee while sending the key to evil.com.
359
+ raise ValueError("base_url must not contain a username or password")
360
+
361
+ try:
362
+ port = parts.port
363
+ except ValueError:
364
+ port = -1
365
+ host = _ascii_host(parts.hostname)
366
+ if port == -1 or not host or not _VALID_HOST.fullmatch(host):
367
+ raise ValueError(
368
+ "base_url is not a valid URL. Use your textbee API, for example "
369
+ "https://sms.example.com."
370
+ )
371
+
372
+ if ":" in host:
373
+ host = f"[{host}]"
374
+ origin = f"{scheme}://{host}"
375
+ if port is not None and port != _DEFAULT_PORTS[scheme]:
376
+ origin = f"{origin}:{port}"
377
+
378
+ segments = [segment for segment in parts.path.split("/") if segment]
379
+ lower = [segment.lower() for segment in segments]
380
+ cut = -1
381
+ for index in range(len(lower) - 2, -1, -1):
382
+ if lower[index] == "api" and lower[index + 1] == "v1":
383
+ cut = index + 2
384
+ break
385
+
386
+ if not segments:
387
+ path = ["api", "v1"]
388
+ elif cut == -1:
389
+ path = segments
390
+ else:
391
+ # Anything after /api/v1 is a route, not part of the root, so it is dropped.
392
+ path = [*segments[: cut - 2], "api", "v1"]
393
+
394
+ return "/".join([origin, *(quote(segment, safe=_PATH_SAFE) for segment in path)])
395
+
396
+
397
+ def _ascii_host(host: str | None) -> str | None:
398
+ """Punycode an internationalized hostname, as browsers and fetch do."""
399
+ if not host or host.isascii():
400
+ return host
401
+ try:
402
+ return host.encode("idna").decode("ascii")
403
+ except UnicodeError:
404
+ return None
405
+
406
+
407
+ _HAS_SCHEME = re.compile(r"[a-z][a-z0-9+.-]*://", re.IGNORECASE)
408
+ _VALID_HOST = re.compile(r"[A-Za-z0-9._~-]+|[0-9A-Fa-f:.]+")
409
+ _DEFAULT_PORTS = {"http": 80, "https": 443}
410
+ _PATH_SAFE = "!$&'()*+,;=:@~%"
411
+
412
+
413
+ def _segment(value: str) -> str:
414
+ return quote(value, safe="")
415
+
416
+
417
+ def _to_iso_string(value: DateInput | None, name: str) -> str | None:
418
+ if value is None or isinstance(value, str):
419
+ return value
420
+ if not isinstance(value, datetime):
421
+ raise TypeError(
422
+ f"{name} must be an ISO 8601 string or a timezone-aware datetime, "
423
+ f"not {type(value).__name__}"
424
+ )
425
+
426
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
427
+ raise ValueError(f"{name} must be a timezone-aware datetime")
428
+
429
+ utc = value.astimezone(timezone.utc)
430
+ return utc.isoformat(timespec="milliseconds").replace("+00:00", "Z")
431
+
432
+
433
+ def _parse_body(raw: bytes) -> Any:
434
+ if not raw:
435
+ return None
436
+
437
+ text = raw.decode("utf-8", errors="replace")
438
+ try:
439
+ return json.loads(text)
440
+ except ValueError:
441
+ return text
442
+
443
+
444
+ def _error_message(payload: Any, status: int) -> str:
445
+ if isinstance(payload, dict):
446
+ # Framework errors ({statusCode, message, error}) carry the detail in
447
+ # message, and error is only the reason phrase such as "Not Found".
448
+ keys = ("message", "error") if "statusCode" in payload else ("error", "message")
449
+ for key in keys:
450
+ value = payload.get(key)
451
+ if isinstance(value, str) and value:
452
+ return value
453
+ if isinstance(value, list) and value and all(isinstance(v, str) for v in value):
454
+ return "; ".join(value)
455
+
456
+ return f"HTTP {status}"
textbee/_errors.py ADDED
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class TextbeeError(Exception):
7
+ """Raised when the textbee API answers with an error, a redirect, or a response
8
+ that is not the API's JSON (usually a wrong ``base_url``).
9
+
10
+ Network failures are not wrapped: those raise whatever ``urllib`` raised,
11
+ an ``OSError`` such as ``URLError`` or a socket timeout.
12
+ """
13
+
14
+ status: int
15
+ """HTTP status code returned by the API."""
16
+
17
+ message: str
18
+ """Error text from the response body, or ``HTTP <status>``."""
19
+
20
+ body: Any
21
+ """Parsed response body, or the raw text when the body was not JSON."""
22
+
23
+ def __init__(self, status: int, message: str, body: Any = None) -> None:
24
+ super().__init__(message)
25
+ self.status = status
26
+ self.message = message
27
+ self.body = body
28
+
29
+ def __reduce__(self) -> tuple[Any, ...]:
30
+ # Keeps the error intact across pickling (multiprocessing, Celery).
31
+ return (type(self), (self.status, self.message, self.body))