smstunnel 1.0.0__tar.gz
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.
- smstunnel-1.0.0/.gitignore +17 -0
- smstunnel-1.0.0/CHANGELOG.md +19 -0
- smstunnel-1.0.0/LICENSE +21 -0
- smstunnel-1.0.0/PKG-INFO +377 -0
- smstunnel-1.0.0/README.md +346 -0
- smstunnel-1.0.0/pyproject.toml +109 -0
- smstunnel-1.0.0/src/smstunnel/__init__.py +52 -0
- smstunnel-1.0.0/src/smstunnel/_async_client.py +120 -0
- smstunnel-1.0.0/src/smstunnel/_client.py +155 -0
- smstunnel-1.0.0/src/smstunnel/_core.py +150 -0
- smstunnel-1.0.0/src/smstunnel/_fields.py +87 -0
- smstunnel-1.0.0/src/smstunnel/_version.py +1 -0
- smstunnel-1.0.0/src/smstunnel/errors.py +165 -0
- smstunnel-1.0.0/src/smstunnel/models.py +265 -0
- smstunnel-1.0.0/src/smstunnel/py.typed +0 -0
- smstunnel-1.0.0/src/smstunnel/transport.py +257 -0
- smstunnel-1.0.0/tests/__init__.py +0 -0
- smstunnel-1.0.0/tests/e2e/__init__.py +0 -0
- smstunnel-1.0.0/tests/e2e/test_local_api.py +235 -0
- smstunnel-1.0.0/tests/support.py +159 -0
- smstunnel-1.0.0/tests/test_async_client.py +109 -0
- smstunnel-1.0.0/tests/test_client.py +439 -0
- smstunnel-1.0.0/tests/test_errors.py +121 -0
- smstunnel-1.0.0/tests/test_httpx_transport.py +87 -0
- smstunnel-1.0.0/tests/test_urllib_transport.py +153 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `smstunnel` are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [1.0.0] - 2026-11-01
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `smstunnel.Client` with `send_sms()`, `send_verification_code()`, `get_message_status()`,
|
|
11
|
+
`get_received_messages()`, `get_devices()` and `get_usage()`, and `smstunnel.AsyncClient` with the same methods
|
|
12
|
+
as coroutines.
|
|
13
|
+
- Typed, read-only responses: `SendResult`, `MessageStatus`, `ReceivedMessages` / `ReceivedMessage`,
|
|
14
|
+
`DeviceList` / `Device`, `Usage`. Times are timezone-aware `datetime` objects.
|
|
15
|
+
- Exceptions: `ApiError` with the HTTP status, SMSTunnel's error code and message, and the subclasses
|
|
16
|
+
`AuthenticationError` (401), `PermissionDeniedError` (403) and `RateLimitError` (429, with `retry_after`);
|
|
17
|
+
`TransportError` when SMSTunnel cannot be reached. All extend `SmsTunnelError`.
|
|
18
|
+
- A standard-library transport (no dependencies, never follows redirects), and optional httpx transports
|
|
19
|
+
(`pip install "smstunnel[httpx]"`).
|
smstunnel-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NARBOWEB SRL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
smstunnel-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: smstunnel
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client for SMSTunnel: send texts from your own apps through your own Android phone, from your own number.
|
|
5
|
+
Project-URL: Homepage, https://smstunnel.io
|
|
6
|
+
Project-URL: Documentation, https://github.com/narcisbodea/smstunnel-python#readme
|
|
7
|
+
Project-URL: Source, https://github.com/narcisbodea/smstunnel-python
|
|
8
|
+
Project-URL: Issues, https://github.com/narcisbodea/smstunnel-python/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/narcisbodea/smstunnel-python/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Narcis Bodea <contact@narboweb.ro>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: 2fa,android,otp,sms,smstunnel,text message,verification code
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
25
|
+
Classifier: Topic :: Communications :: Telephony
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Provides-Extra: httpx
|
|
29
|
+
Requires-Dist: httpx>=0.27; extra == 'httpx'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# SMSTunnel for Python
|
|
33
|
+
|
|
34
|
+
`smstunnel`: a small Python client for **[SMSTunnel](https://smstunnel.io)**. Send texts from your own Python apps through **your own Android phone**, from **your own number**.
|
|
35
|
+
|
|
36
|
+
Your app hands the text to your SMSTunnel account and your phone sends it from its own SIM, so recipients see a number they know and can reply to. The phone is linked to your account when you sign in to the SMSTunnel app (or scan the QR code from your dashboard), and you can cut an app off at any time by deleting its API key in your dashboard.
|
|
37
|
+
|
|
38
|
+
Your phone needs the SMSTunnel app from Google Play:
|
|
39
|
+
|
|
40
|
+
<a href="https://play.google.com/store/apps/details?id=io.smstunnel.app"><img src="https://smstunnel.io/google-play-badge.png" alt="Get it on Google Play" height="56"></a>
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
- Python 3.10 or newer
|
|
45
|
+
- No dependencies. [httpx](https://www.python-httpx.org) is optional (see [Your own HTTP client](#your-own-http-client)).
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install smstunnel
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Get an API key
|
|
54
|
+
|
|
55
|
+
1. Install the [SMSTunnel app from Google Play](https://play.google.com/store/apps/details?id=io.smstunnel.app) on your Android phone and sign in. Sign in at [smstunnel.io](https://smstunnel.io) on your computer with the same account: the phone appears under **Phones** (you can also link it with the QR code from **Phones → Add phone**).
|
|
56
|
+
2. In your [SMSTunnel dashboard](https://dashboard.smstunnel.io/apps), open **Apps** → **Add an app** and create its key. The key can send texts and look up whether they went out (`sms:send` and `sms:read`).
|
|
57
|
+
3. Reading received texts only works with a key you have explicitly allowed to (**Can also read received texts**, the `sms:inbox` permission, a separate opt-in per key). Syncing received texts is off by default in the app; when you turn it on, only texts that arrive from then on are synced, and the phone's existing messages are never read.
|
|
58
|
+
|
|
59
|
+
Keep the key out of your code: put it in an environment variable such as `SMSTUNNEL_API_KEY`.
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import os
|
|
65
|
+
import smstunnel
|
|
66
|
+
|
|
67
|
+
client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"]) # sk_live_...
|
|
68
|
+
|
|
69
|
+
result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
|
|
70
|
+
|
|
71
|
+
print(result.message_id) # keep it to look up the status later
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Send a text
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
result = client.send_sms(
|
|
78
|
+
"+14155550123", # international format
|
|
79
|
+
"See you at 10:30.", # up to 1600 characters
|
|
80
|
+
device_id=None, # a phone id from get_devices(), or None to let SMSTunnel pick one of yours
|
|
81
|
+
default_country_code=None, # e.g. "+40": completes numbers given without + or 00
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
result.message_id # str
|
|
85
|
+
result.queued # True when the phone was offline: it sends the text as soon as it is back
|
|
86
|
+
result.branded # True when the text carries a short SMSTunnel signature (plans with a free quota)
|
|
87
|
+
result.remaining # today's quota left, e.g. {"clean": 95, "total": "unlimited"} (-1 means no limit)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
One call sends one text to one number. To text several people, call `send_sms()` once for each.
|
|
91
|
+
|
|
92
|
+
### Send a sign-in or verification code
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
client.send_verification_code("+14155550123", "Your sign-in code is 482913")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The text must contain the code, 4 to 10 digits standing on their own (for example `482913` or `123-456`); SMSTunnel refuses it otherwise. Codes count against a separate quota, and the text can be up to 320 characters.
|
|
99
|
+
|
|
100
|
+
### Look up a status
|
|
101
|
+
|
|
102
|
+
Needs a key with `sms:read`.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from smstunnel import MessageStatus
|
|
106
|
+
|
|
107
|
+
status = client.get_message_status(result.message_id)
|
|
108
|
+
|
|
109
|
+
status.status # "pending", "sent", "delivered" or "failed"
|
|
110
|
+
status.delivery_status # the same, or "unconfirmed": handed to the SIM but the phone got no confirmation
|
|
111
|
+
status.sent_at # a datetime (UTC), or None
|
|
112
|
+
status.error_message # why it failed, or None
|
|
113
|
+
status.is_final # True once it will not change any more
|
|
114
|
+
|
|
115
|
+
if status.status == MessageStatus.FAILED:
|
|
116
|
+
...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Read received texts
|
|
120
|
+
|
|
121
|
+
Needs a key you allowed to read received texts (`sms:inbox`) and syncing turned on in the app.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from datetime import datetime, timedelta, timezone
|
|
125
|
+
|
|
126
|
+
page = client.get_received_messages(
|
|
127
|
+
limit=20, # at most 100
|
|
128
|
+
page=1,
|
|
129
|
+
since=datetime.now(timezone.utc) - timedelta(days=1), # or an ISO 8601 string
|
|
130
|
+
phone="+14155550123", # only from this sender
|
|
131
|
+
device_id=None, # only this phone
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
for text in page:
|
|
135
|
+
print(text.sender, text.message) # text.received_at, text.device_name, ...
|
|
136
|
+
|
|
137
|
+
page.total # all texts that match, on every page
|
|
138
|
+
page.has_more # ask for the next page
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Your phones and your usage
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
for phone in client.get_devices():
|
|
145
|
+
print(phone.name, "online" if phone.is_online else "offline") # phone.id, phone.last_seen_at
|
|
146
|
+
|
|
147
|
+
usage = client.get_usage()
|
|
148
|
+
usage.plan # e.g. "pro"
|
|
149
|
+
usage.usage # {"smsClean": 15, "smsBranded": 0, "twoFaClean": 4}
|
|
150
|
+
usage.remaining # {"smsClean": "unlimited", "twoFaClean": 96}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Every response object is a read-only dataclass with a `raw` field, the whole answer, for fields this version does not map yet. The package ships type hints (`py.typed`).
|
|
154
|
+
|
|
155
|
+
### Async
|
|
156
|
+
|
|
157
|
+
`smstunnel.AsyncClient` has the same methods, as coroutines:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
import asyncio
|
|
161
|
+
import os
|
|
162
|
+
|
|
163
|
+
import smstunnel
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def main() -> None:
|
|
167
|
+
async with smstunnel.AsyncClient(os.environ["SMSTUNNEL_API_KEY"]) as client:
|
|
168
|
+
await client.send_sms("+14155550123", "Your table is ready.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
asyncio.run(main())
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
It sends through httpx when httpx is installed (`pip install "smstunnel[httpx]"`); otherwise it runs the standard-library transport in a worker thread.
|
|
175
|
+
|
|
176
|
+
## Errors
|
|
177
|
+
|
|
178
|
+
Every call raises a `smstunnel.SmsTunnelError` when it does not go through:
|
|
179
|
+
|
|
180
|
+
| Exception | When |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `AuthenticationError` (401) | The API key is wrong, was deleted, or its account is suspended. |
|
|
183
|
+
| `PermissionDeniedError` (403) | The key lacks a permission (`error_code` is `PERMISSION_REQUIRED`, `permission` names it, e.g. `sms:inbox`), or the plan does not include the feature. |
|
|
184
|
+
| `RateLimitError` (429) | Too many requests. `retry_after` gives the seconds to wait, when SMSTunnel says. |
|
|
185
|
+
| `ApiError` | Any other error answer: 400 (a number it cannot read, a verification text without a code), 404 (no such message), 5xx. The three above extend it. |
|
|
186
|
+
| `TransportError` | SMSTunnel could not be reached (DNS, TLS, timeout). |
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
import logging
|
|
190
|
+
import time
|
|
191
|
+
|
|
192
|
+
from smstunnel import ApiError, RateLimitError, TransportError
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
client.send_sms("+14155550123", "Your table is ready.")
|
|
196
|
+
except RateLimitError as error:
|
|
197
|
+
time.sleep(error.retry_after or 30) # then try again
|
|
198
|
+
except ApiError as error:
|
|
199
|
+
logging.warning("SMSTunnel %s %s: %s", error.status_code, error.error_code or "-", error.message)
|
|
200
|
+
except TransportError:
|
|
201
|
+
... # network problem: try again later
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`message` (also `str(error)`) is SMSTunnel's own explanation, `status_code` the HTTP status, `body` the decoded answer. An empty number or text raises `ValueError` before anything is sent.
|
|
205
|
+
|
|
206
|
+
## Django
|
|
207
|
+
|
|
208
|
+
Read the key from the environment in `settings.py`:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
# settings.py
|
|
212
|
+
import os
|
|
213
|
+
|
|
214
|
+
SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Make one client for the whole app, and send once the order is saved:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
# shop/sms.py
|
|
221
|
+
from functools import cache
|
|
222
|
+
|
|
223
|
+
import smstunnel
|
|
224
|
+
from django.conf import settings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@cache
|
|
228
|
+
def sms() -> smstunnel.Client:
|
|
229
|
+
return smstunnel.Client(settings.SMSTUNNEL_API_KEY)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
# shop/views.py
|
|
234
|
+
import logging
|
|
235
|
+
|
|
236
|
+
from django.db import transaction
|
|
237
|
+
from django.shortcuts import get_object_or_404, redirect
|
|
238
|
+
|
|
239
|
+
from smstunnel import SmsTunnelError
|
|
240
|
+
|
|
241
|
+
from .models import Order
|
|
242
|
+
from .sms import sms
|
|
243
|
+
|
|
244
|
+
log = logging.getLogger(__name__)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def text_customer(order: Order) -> None:
|
|
248
|
+
try:
|
|
249
|
+
sms().send_sms(order.phone, f"Your order #{order.pk} has shipped.")
|
|
250
|
+
except SmsTunnelError:
|
|
251
|
+
log.exception("The shipping text for order %s did not go out", order.pk)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def mark_shipped(request, pk):
|
|
255
|
+
order = get_object_or_404(Order, pk=pk)
|
|
256
|
+
order.status = "shipped"
|
|
257
|
+
order.save()
|
|
258
|
+
transaction.on_commit(lambda: text_customer(order))
|
|
259
|
+
return redirect("order-detail", pk=pk)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
To keep requests fast, you can call `text_customer()` from a background task (Celery, RQ, Django's tasks) instead.
|
|
263
|
+
|
|
264
|
+
## Flask
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
import os
|
|
268
|
+
|
|
269
|
+
from flask import Flask, abort, request
|
|
270
|
+
from smstunnel import Client, SmsTunnelError
|
|
271
|
+
|
|
272
|
+
app = Flask(__name__)
|
|
273
|
+
app.config["SMSTUNNEL_API_KEY"] = os.environ["SMSTUNNEL_API_KEY"]
|
|
274
|
+
sms = Client(app.config["SMSTUNNEL_API_KEY"])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.post("/appointments/<int:appointment_id>/remind")
|
|
278
|
+
def remind(appointment_id: int):
|
|
279
|
+
phone = request.form.get("phone", "")
|
|
280
|
+
if not phone.startswith("+"):
|
|
281
|
+
abort(400, "Give the number in international format, e.g. +14155550123.")
|
|
282
|
+
try:
|
|
283
|
+
result = sms.send_sms(phone, "Reminder: your appointment is tomorrow at 10:30.")
|
|
284
|
+
except SmsTunnelError as error:
|
|
285
|
+
app.logger.warning("SMSTunnel: %s", error)
|
|
286
|
+
return {"sent": False}, 502
|
|
287
|
+
return {"sent": True, "messageId": result.message_id}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Your own HTTP client
|
|
291
|
+
|
|
292
|
+
The default transport uses only the standard library (`urllib.request`), a 30-second timeout and never follows redirects, so the API key is never sent to another address. It uses the proxy from the `HTTPS_PROXY` environment variable. Change the timeout, or trust your own certificate authority:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
import ssl
|
|
296
|
+
|
|
297
|
+
from smstunnel import Client
|
|
298
|
+
from smstunnel.transport import UrllibTransport
|
|
299
|
+
|
|
300
|
+
client = Client(api_key, timeout=10, transport=UrllibTransport(ssl_context=ssl.create_default_context(cafile="ca.pem")))
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Or send through httpx, so SMSTunnel calls share your app's HTTP setup:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
import httpx
|
|
307
|
+
from smstunnel import AsyncClient, Client
|
|
308
|
+
from smstunnel.transport import AsyncHttpxTransport, HttpxTransport
|
|
309
|
+
|
|
310
|
+
client = Client(api_key, transport=HttpxTransport(httpx.Client(proxy="http://proxy.internal:3128")))
|
|
311
|
+
async_client = AsyncClient(api_key, transport=AsyncHttpxTransport(httpx.AsyncClient()))
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
In your own tests, give the client an object with a `send(request) -> HttpResponse` method (see `smstunnel.transport.Transport`) to answer without a network, or mock `smstunnel.Client`.
|
|
315
|
+
|
|
316
|
+
`Client(api_key, "https://your-own-host")` points the client at a self-hosted SMSTunnel; the default is `https://smstunnel.io`.
|
|
317
|
+
|
|
318
|
+
## Notes and limits
|
|
319
|
+
|
|
320
|
+
- **Rate limit**: sending allows 30 requests a minute.
|
|
321
|
+
- **Length**: up to 1600 characters (10 joined SMS); verification codes up to 320.
|
|
322
|
+
- **Numbers**: use the international format (`+14155550123`). For local numbers, pass `default_country_code`: it always takes precedence (`+49`, `49` and `0049` all work).
|
|
323
|
+
- **Offline phone**: the text waits on your account (`queued` is `True`) and goes out when the phone is back online.
|
|
324
|
+
|
|
325
|
+
## Build it with an AI assistant
|
|
326
|
+
|
|
327
|
+
Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt.
|
|
328
|
+
|
|
329
|
+
```text
|
|
330
|
+
I want my Python app to send texts with SMSTunnel. SMSTunnel sends each text from my own Android phone, from my own number: the phone runs the SMSTunnel app and is linked to my SMSTunnel account.
|
|
331
|
+
|
|
332
|
+
Use the SMSTunnel package: `pip install smstunnel`, then `client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"])`.
|
|
333
|
+
API facts:
|
|
334
|
+
- client.send_sms(to, message, device_id=None, default_country_code=None) calls POST https://smstunnel.io/api/v1/sms/send with the header X-API-Key and the JSON body {"to", "message", "deviceId"?, "defaultCountryCode"?}. It returns message_id and queued.
|
|
335
|
+
- One recipient per call. To text several people, call it once per number.
|
|
336
|
+
- client.send_verification_code(...) (POST /api/v1/sms/send-2fa) is only for sign-in codes: the text must contain a code of 4 to 10 digits standing on its own (e.g. 482913 or 123-456), at most 320 characters; it uses a separate quota.
|
|
337
|
+
- Numbers in international format (+14155550123). default_country_code (e.g. "+40") completes numbers written without + or 00.
|
|
338
|
+
- Limits: 30 send requests a minute, 1600 characters a text.
|
|
339
|
+
- Errors: 401 AuthenticationError (bad key), 403 PermissionDeniedError (missing permission or plan; error_code such as PERMISSION_REQUIRED), 429 RateLimitError (retry_after seconds), other 4xx/5xx ApiError, TransportError when SMSTunnel can't be reached.
|
|
340
|
+
- If the phone is offline, the text waits on the account (queued is True) and goes out when the phone is back.
|
|
341
|
+
|
|
342
|
+
What I want: [describe what should send a text, to whom, and when]
|
|
343
|
+
|
|
344
|
+
Rules:
|
|
345
|
+
- Read the API key from an environment variable or the platform's secret store. Never put it in source code, in the repository or in client-side code.
|
|
346
|
+
- Validate phone numbers before sending, and handle every error above: log it clearly, don't retry 401/403, wait and retry 429.
|
|
347
|
+
- Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Responsible use
|
|
351
|
+
|
|
352
|
+
Send only texts that people expect from you: alerts to yourself, appointment reminders, sign-in codes, order updates. Unsolicited messages from a personal SIM break most carriers' terms of service. Please make sure your usage complies with your carrier's terms and with local regulations (in the EU, that includes GDPR and ePrivacy rules on electronic communications).
|
|
353
|
+
|
|
354
|
+
## Development
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
python -m venv .venv && . .venv/bin/activate
|
|
358
|
+
pip install -e . --group dev # pip 25.1 or newer
|
|
359
|
+
pytest # unit tests
|
|
360
|
+
ruff check . && ruff format --check .
|
|
361
|
+
mypy # strict
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The end-to-end tests run only against a test server, never production. They read the server address and a test key from a JSON file (`{"serverUrl": "http://127.0.0.1:3101", "apiKey": "...", "deviceId": "..."}`) and, when `SMSTUNNEL_E2E_MONGO_URL` is set, check each text in the server's database:
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
pip install --group e2e
|
|
368
|
+
SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json SMSTUNNEL_E2E_MONGO_URL=mongodb://127.0.0.1:27018/smstunnel_app_dev pytest tests/e2e
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Changelog
|
|
372
|
+
|
|
373
|
+
See [CHANGELOG.md](https://github.com/narcisbodea/smstunnel-python/blob/main/CHANGELOG.md).
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
MIT, © NARBOWEB SRL. See [LICENSE](https://github.com/narcisbodea/smstunnel-python/blob/main/LICENSE).
|