smsgora 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.
- smsgora-1.0.0/LICENSE +21 -0
- smsgora-1.0.0/PKG-INFO +156 -0
- smsgora-1.0.0/README.md +135 -0
- smsgora-1.0.0/pyproject.toml +27 -0
- smsgora-1.0.0/setup.cfg +4 -0
- smsgora-1.0.0/src/smsgora/__init__.py +175 -0
- smsgora-1.0.0/src/smsgora.egg-info/PKG-INFO +156 -0
- smsgora-1.0.0/src/smsgora.egg-info/SOURCES.txt +9 -0
- smsgora-1.0.0/src/smsgora.egg-info/dependency_links.txt +1 -0
- smsgora-1.0.0/src/smsgora.egg-info/top_level.txt +1 -0
- smsgora-1.0.0/tests/test_smsgora.py +81 -0
smsgora-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tekservix
|
|
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.
|
smsgora-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smsgora
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the SMS Gora SMS Gateway: send and receive SMS through your own Android phones, with delivery reports and signed webhooks.
|
|
5
|
+
Author-email: Tekservix <support@tekservix.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tekservix.com/smsgora/
|
|
8
|
+
Project-URL: Documentation, https://tekservix.com/smsgora/docs/
|
|
9
|
+
Project-URL: Source, https://github.com/ellyj164/smsgora-python
|
|
10
|
+
Project-URL: Issues, https://github.com/ellyj164/smsgora-python/issues
|
|
11
|
+
Keywords: sms,sms-gateway,sms-api,bulk-sms,send-sms,android-sms-gateway,otp,textbee,smsgora,tekservix
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Communications :: Telephony
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# smsgora
|
|
23
|
+
|
|
24
|
+
Python client for the [SMS Gora SMS Gateway](https://tekservix.com/smsgora/) API.
|
|
25
|
+
|
|
26
|
+
SMS Gora turns Android phones with ordinary SIM cards into an SMS gateway. Your server calls the API, a linked phone sends the text, and replies and delivery reports come back to you. There's no sender ID registration and no per-message fee from us: your mobile operator bills the texts like any SMS.
|
|
27
|
+
|
|
28
|
+
- Zero dependencies (standard library only), Python 3.8+
|
|
29
|
+
- Works in Django, Flask, FastAPI, scripts and cron jobs
|
|
30
|
+
- Webhook signature check built in
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install smsgora
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
Create an API key in the SMS Gora dashboard (SMS Gora -> API keys) and keep it on your server.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from smsgora import SmsGora
|
|
45
|
+
|
|
46
|
+
sms = SmsGora(api_key=os.environ["SMSGORA_KEY"])
|
|
47
|
+
|
|
48
|
+
result = sms.send_sms(to="+15551234567", message="Your verification code is 482913")
|
|
49
|
+
print(result["message_ids"]) # [1873]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Sending
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
|
|
57
|
+
# Several recipients (up to 1,000 per call)
|
|
58
|
+
sms.send_sms(to=["+15551234567", "+447700900123"], message="Your order has shipped")
|
|
59
|
+
|
|
60
|
+
# From a chosen phone and SIM, scheduled for later
|
|
61
|
+
sms.send_sms(
|
|
62
|
+
to="+15551234567",
|
|
63
|
+
message="Reminder: your appointment is tomorrow at 10:00",
|
|
64
|
+
device_id=42,
|
|
65
|
+
sim_slot=1, # 0 = SIM 1, 1 = SIM 2
|
|
66
|
+
schedule_at=datetime(2026, 12, 1, 8, 0, tzinfo=timezone.utc),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Different texts for different people in one call (up to 5,000 recipients)
|
|
70
|
+
sms.send_bulk_sms(
|
|
71
|
+
42,
|
|
72
|
+
message_template="Hello, your parcel is ready for pickup.",
|
|
73
|
+
messages=[
|
|
74
|
+
{"recipients": ["+15551234567"], "message": "Hi Aline, parcel #1042 is ready."},
|
|
75
|
+
{"recipients": ["+15557654321"]},
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Delivery status and incoming SMS
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
msg = sms.get_message(1873)
|
|
84
|
+
print(msg["status"]) # queued -> dispatched -> sent -> delivered (or failed)
|
|
85
|
+
|
|
86
|
+
sms.cancel_message(1874) # works while queued or scheduled
|
|
87
|
+
|
|
88
|
+
inbox = sms.list_received_sms(since="2026-11-01", limit=50)
|
|
89
|
+
phones = sms.list_devices()
|
|
90
|
+
account = sms.get_account()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Anything else in the [API reference](https://tekservix.com/smsgora/docs/) is one call away:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
batch = sms.request("GET", "/batches/311")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Webhooks
|
|
100
|
+
|
|
101
|
+
Add your HTTPS URL in SMS Gora -> Webhooks (or with `create_webhook`) and keep the secret. Each request carries `X-SmsGora-Signature: sha256=...`, an HMAC-SHA256 of the raw body. Check it before trusting the request. Flask example:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import json, os
|
|
105
|
+
from flask import Flask, request, abort
|
|
106
|
+
from smsgora import verify_webhook
|
|
107
|
+
|
|
108
|
+
app = Flask(__name__)
|
|
109
|
+
|
|
110
|
+
@app.post("/sms/webhook")
|
|
111
|
+
def sms_webhook():
|
|
112
|
+
if not verify_webhook(request.get_data(), request.headers.get("X-SmsGora-Signature"),
|
|
113
|
+
os.environ["SMSGORA_WEBHOOK_SECRET"]):
|
|
114
|
+
abort(401)
|
|
115
|
+
event = json.loads(request.get_data())
|
|
116
|
+
if event["event"] == "sms.received":
|
|
117
|
+
print(event["data"]["from"], event["data"]["message"])
|
|
118
|
+
return "", 200 # answer within 10 seconds; queue slow work
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In Django use `request.body`; in FastAPI use `await request.body()`. Always pass the raw bytes, not re-encoded JSON.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
Failed calls raise `SmsGoraError` with `status`, `message`, field `errors` and, on 429, `retry_after` in seconds.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from smsgora import SmsGoraError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
sms.send_sms(to="+15551234567", message="Hi")
|
|
132
|
+
except SmsGoraError as err:
|
|
133
|
+
if err.status == 429:
|
|
134
|
+
pass # rate limit (300 requests/minute per key) or plan limit reached
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Never ship the key inside a browser or mobile app: anyone could read it. Call the API from your server.
|
|
138
|
+
|
|
139
|
+
## Migrating from TextBee
|
|
140
|
+
|
|
141
|
+
SMS Gora accepts TextBee-style requests, so most integrations switch by changing the base URL and key. See the [compatibility notes](https://tekservix.com/smsgora/docs/).
|
|
142
|
+
|
|
143
|
+
## Good to know
|
|
144
|
+
|
|
145
|
+
A phone-based gateway suits OTPs, order alerts, reminders and two-way conversations at moderate volume. It isn't meant for mass marketing blasts, and in the US high-volume business texting belongs on registered 10DLC or toll-free numbers.
|
|
146
|
+
|
|
147
|
+
## More from Tekservix
|
|
148
|
+
|
|
149
|
+
- [WAservix WhatsApp API Gateway](https://tekservix.com/waservix/): send WhatsApp messages from your own number (`pip install waservix`)
|
|
150
|
+
- [AI Sales Agent](https://tekservix.com/ai-sales-agent): answers customers on WhatsApp and SMS using your website's content
|
|
151
|
+
- [Website to App](https://tekservix.com/mobile-apps): turn your website into Android and iOS apps
|
|
152
|
+
- Also on npm: `npm install smsgora`
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
smsgora-1.0.0/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# smsgora
|
|
2
|
+
|
|
3
|
+
Python client for the [SMS Gora SMS Gateway](https://tekservix.com/smsgora/) API.
|
|
4
|
+
|
|
5
|
+
SMS Gora turns Android phones with ordinary SIM cards into an SMS gateway. Your server calls the API, a linked phone sends the text, and replies and delivery reports come back to you. There's no sender ID registration and no per-message fee from us: your mobile operator bills the texts like any SMS.
|
|
6
|
+
|
|
7
|
+
- Zero dependencies (standard library only), Python 3.8+
|
|
8
|
+
- Works in Django, Flask, FastAPI, scripts and cron jobs
|
|
9
|
+
- Webhook signature check built in
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install smsgora
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
Create an API key in the SMS Gora dashboard (SMS Gora -> API keys) and keep it on your server.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
from smsgora import SmsGora
|
|
24
|
+
|
|
25
|
+
sms = SmsGora(api_key=os.environ["SMSGORA_KEY"])
|
|
26
|
+
|
|
27
|
+
result = sms.send_sms(to="+15551234567", message="Your verification code is 482913")
|
|
28
|
+
print(result["message_ids"]) # [1873]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Sending
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
|
|
36
|
+
# Several recipients (up to 1,000 per call)
|
|
37
|
+
sms.send_sms(to=["+15551234567", "+447700900123"], message="Your order has shipped")
|
|
38
|
+
|
|
39
|
+
# From a chosen phone and SIM, scheduled for later
|
|
40
|
+
sms.send_sms(
|
|
41
|
+
to="+15551234567",
|
|
42
|
+
message="Reminder: your appointment is tomorrow at 10:00",
|
|
43
|
+
device_id=42,
|
|
44
|
+
sim_slot=1, # 0 = SIM 1, 1 = SIM 2
|
|
45
|
+
schedule_at=datetime(2026, 12, 1, 8, 0, tzinfo=timezone.utc),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Different texts for different people in one call (up to 5,000 recipients)
|
|
49
|
+
sms.send_bulk_sms(
|
|
50
|
+
42,
|
|
51
|
+
message_template="Hello, your parcel is ready for pickup.",
|
|
52
|
+
messages=[
|
|
53
|
+
{"recipients": ["+15551234567"], "message": "Hi Aline, parcel #1042 is ready."},
|
|
54
|
+
{"recipients": ["+15557654321"]},
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Delivery status and incoming SMS
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
msg = sms.get_message(1873)
|
|
63
|
+
print(msg["status"]) # queued -> dispatched -> sent -> delivered (or failed)
|
|
64
|
+
|
|
65
|
+
sms.cancel_message(1874) # works while queued or scheduled
|
|
66
|
+
|
|
67
|
+
inbox = sms.list_received_sms(since="2026-11-01", limit=50)
|
|
68
|
+
phones = sms.list_devices()
|
|
69
|
+
account = sms.get_account()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Anything else in the [API reference](https://tekservix.com/smsgora/docs/) is one call away:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
batch = sms.request("GET", "/batches/311")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Webhooks
|
|
79
|
+
|
|
80
|
+
Add your HTTPS URL in SMS Gora -> Webhooks (or with `create_webhook`) and keep the secret. Each request carries `X-SmsGora-Signature: sha256=...`, an HMAC-SHA256 of the raw body. Check it before trusting the request. Flask example:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import json, os
|
|
84
|
+
from flask import Flask, request, abort
|
|
85
|
+
from smsgora import verify_webhook
|
|
86
|
+
|
|
87
|
+
app = Flask(__name__)
|
|
88
|
+
|
|
89
|
+
@app.post("/sms/webhook")
|
|
90
|
+
def sms_webhook():
|
|
91
|
+
if not verify_webhook(request.get_data(), request.headers.get("X-SmsGora-Signature"),
|
|
92
|
+
os.environ["SMSGORA_WEBHOOK_SECRET"]):
|
|
93
|
+
abort(401)
|
|
94
|
+
event = json.loads(request.get_data())
|
|
95
|
+
if event["event"] == "sms.received":
|
|
96
|
+
print(event["data"]["from"], event["data"]["message"])
|
|
97
|
+
return "", 200 # answer within 10 seconds; queue slow work
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
In Django use `request.body`; in FastAPI use `await request.body()`. Always pass the raw bytes, not re-encoded JSON.
|
|
101
|
+
|
|
102
|
+
## Errors
|
|
103
|
+
|
|
104
|
+
Failed calls raise `SmsGoraError` with `status`, `message`, field `errors` and, on 429, `retry_after` in seconds.
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from smsgora import SmsGoraError
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
sms.send_sms(to="+15551234567", message="Hi")
|
|
111
|
+
except SmsGoraError as err:
|
|
112
|
+
if err.status == 429:
|
|
113
|
+
pass # rate limit (300 requests/minute per key) or plan limit reached
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Never ship the key inside a browser or mobile app: anyone could read it. Call the API from your server.
|
|
117
|
+
|
|
118
|
+
## Migrating from TextBee
|
|
119
|
+
|
|
120
|
+
SMS Gora accepts TextBee-style requests, so most integrations switch by changing the base URL and key. See the [compatibility notes](https://tekservix.com/smsgora/docs/).
|
|
121
|
+
|
|
122
|
+
## Good to know
|
|
123
|
+
|
|
124
|
+
A phone-based gateway suits OTPs, order alerts, reminders and two-way conversations at moderate volume. It isn't meant for mass marketing blasts, and in the US high-volume business texting belongs on registered 10DLC or toll-free numbers.
|
|
125
|
+
|
|
126
|
+
## More from Tekservix
|
|
127
|
+
|
|
128
|
+
- [WAservix WhatsApp API Gateway](https://tekservix.com/waservix/): send WhatsApp messages from your own number (`pip install waservix`)
|
|
129
|
+
- [AI Sales Agent](https://tekservix.com/ai-sales-agent): answers customers on WhatsApp and SMS using your website's content
|
|
130
|
+
- [Website to App](https://tekservix.com/mobile-apps): turn your website into Android and iOS apps
|
|
131
|
+
- Also on npm: `npm install smsgora`
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smsgora"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python client for the SMS Gora SMS Gateway: send and receive SMS through your own Android phones, with delivery reports and signed webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Tekservix", email = "support@tekservix.com" }]
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
keywords = ["sms", "sms-gateway", "sms-api", "bulk-sms", "send-sms", "android-sms-gateway", "otp", "textbee", "smsgora", "tekservix"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Communications :: Telephony",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://tekservix.com/smsgora/"
|
|
25
|
+
Documentation = "https://tekservix.com/smsgora/docs/"
|
|
26
|
+
Source = "https://github.com/ellyj164/smsgora-python"
|
|
27
|
+
Issues = "https://github.com/ellyj164/smsgora-python/issues"
|
smsgora-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Python client for the SMS Gora SMS Gateway API.
|
|
2
|
+
|
|
3
|
+
Docs: https://tekservix.com/smsgora/docs/
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
__all__ = ["SmsGora", "SmsGoraError", "verify_webhook"]
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://tekservix.com/smsgora/api/v1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SmsGoraError(Exception):
|
|
21
|
+
"""Raised when the API answers with an error.
|
|
22
|
+
|
|
23
|
+
Attributes: status (HTTP code), errors (field errors), body (parsed answer)
|
|
24
|
+
and retry_after (seconds, on 429).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message, status=None, errors=None, body=None, retry_after=None):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.message = message
|
|
30
|
+
self.status = status
|
|
31
|
+
self.errors = errors
|
|
32
|
+
self.body = body
|
|
33
|
+
self.retry_after = retry_after
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _q(value):
|
|
37
|
+
return urllib.parse.quote(str(value), safe="")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _query(params):
|
|
41
|
+
if not params:
|
|
42
|
+
return ""
|
|
43
|
+
clean = {k: v for k, v in params.items() if v is not None and v != ""}
|
|
44
|
+
return "?" + urllib.parse.urlencode(clean) if clean else ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SmsGora:
|
|
48
|
+
"""Client for the SMS Gora SMS Gateway.
|
|
49
|
+
|
|
50
|
+
>>> sms = SmsGora(api_key="sgk_...")
|
|
51
|
+
>>> sms.send_sms(to="+15551234567", message="Your code is 482913")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, api_key, base_url=DEFAULT_BASE_URL, timeout=30):
|
|
55
|
+
if not api_key:
|
|
56
|
+
raise ValueError("SmsGora: api_key is required (create one in SMS Gora -> API keys)")
|
|
57
|
+
self.api_key = api_key
|
|
58
|
+
self.base_url = base_url.rstrip("/")
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
|
|
61
|
+
def request(self, method, path, body=None):
|
|
62
|
+
"""Call any documented endpoint, e.g. request("GET", "/devices"). Returns the `data` field."""
|
|
63
|
+
headers = {
|
|
64
|
+
"x-api-key": self.api_key,
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"User-Agent": "smsgora-python/" + __version__,
|
|
67
|
+
}
|
|
68
|
+
data = None
|
|
69
|
+
if body is not None:
|
|
70
|
+
data = json.dumps(body).encode("utf-8")
|
|
71
|
+
headers["Content-Type"] = "application/json"
|
|
72
|
+
req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
73
|
+
try:
|
|
74
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
75
|
+
status, text, retry = res.status, res.read().decode("utf-8"), None
|
|
76
|
+
except urllib.error.HTTPError as err:
|
|
77
|
+
status, text = err.code, err.read().decode("utf-8", "replace")
|
|
78
|
+
retry = err.headers.get("Retry-After") if err.headers else None
|
|
79
|
+
try:
|
|
80
|
+
parsed = json.loads(text) if text else None
|
|
81
|
+
except ValueError:
|
|
82
|
+
parsed = None
|
|
83
|
+
failed = status >= 400 or (isinstance(parsed, dict) and parsed.get("success") is False)
|
|
84
|
+
if failed:
|
|
85
|
+
msg = parsed.get("message") if isinstance(parsed, dict) else None
|
|
86
|
+
raise SmsGoraError(
|
|
87
|
+
msg or "HTTP %d" % status,
|
|
88
|
+
status=status,
|
|
89
|
+
errors=parsed.get("errors") if isinstance(parsed, dict) else None,
|
|
90
|
+
body=parsed if parsed is not None else text,
|
|
91
|
+
retry_after=float(retry) if retry else None,
|
|
92
|
+
)
|
|
93
|
+
if isinstance(parsed, dict) and "data" in parsed:
|
|
94
|
+
return parsed["data"]
|
|
95
|
+
return parsed
|
|
96
|
+
|
|
97
|
+
def send_sms(self, to, message, device_id=None, sim_slot=None, schedule_at=None):
|
|
98
|
+
"""Send an SMS to one number or a list (up to 1,000). Picks your best online phone unless device_id is given.
|
|
99
|
+
|
|
100
|
+
sim_slot: 0 = SIM 1, 1 = SIM 2. schedule_at: datetime or ISO string.
|
|
101
|
+
"""
|
|
102
|
+
recipients = [to] if isinstance(to, str) else list(to)
|
|
103
|
+
body = {"recipients": recipients, "message": message}
|
|
104
|
+
if sim_slot is not None:
|
|
105
|
+
body["sim_slot"] = sim_slot
|
|
106
|
+
if schedule_at:
|
|
107
|
+
if isinstance(schedule_at, _dt.datetime):
|
|
108
|
+
schedule_at = schedule_at.isoformat()
|
|
109
|
+
body["schedule_at"] = schedule_at
|
|
110
|
+
path = "/devices/%s/send-sms" % _q(device_id) if device_id else "/send-sms"
|
|
111
|
+
return self.request("POST", path, body)
|
|
112
|
+
|
|
113
|
+
def send_bulk_sms(self, device_id, messages, message_template=None):
|
|
114
|
+
"""Different texts for different people in one call (up to 5,000 recipients).
|
|
115
|
+
|
|
116
|
+
messages: [{"recipients": ["+1555..."], "message": "optional own text"}, ...]
|
|
117
|
+
"""
|
|
118
|
+
body = {"messages": messages}
|
|
119
|
+
if message_template is not None:
|
|
120
|
+
body["messageTemplate"] = message_template
|
|
121
|
+
return self.request("POST", "/devices/%s/send-bulk-sms" % _q(device_id), body)
|
|
122
|
+
|
|
123
|
+
def get_message(self, message_id):
|
|
124
|
+
"""Status is queued, dispatched, sent, delivered, failed or cancelled."""
|
|
125
|
+
return self.request("GET", "/messages/%s" % _q(message_id))
|
|
126
|
+
|
|
127
|
+
def cancel_message(self, message_id):
|
|
128
|
+
"""Cancel a queued or scheduled message."""
|
|
129
|
+
return self.request("POST", "/messages/%s/cancel" % _q(message_id))
|
|
130
|
+
|
|
131
|
+
def list_messages(self, **filters):
|
|
132
|
+
"""Filters: direction (in/out), status, device_id, phone, since, after_id, page, limit."""
|
|
133
|
+
return self.request("GET", "/messages" + _query(filters))
|
|
134
|
+
|
|
135
|
+
def list_received_sms(self, device_id=None, **filters):
|
|
136
|
+
"""Incoming SMS for all phones, or one phone with device_id."""
|
|
137
|
+
path = "/devices/%s/get-received-sms" % _q(device_id) if device_id else "/received-sms"
|
|
138
|
+
return self.request("GET", path + _query(filters))
|
|
139
|
+
|
|
140
|
+
def get_batch(self, batch_id):
|
|
141
|
+
return self.request("GET", "/batches/%s" % _q(batch_id))
|
|
142
|
+
|
|
143
|
+
def list_devices(self):
|
|
144
|
+
"""Your linked Android phones."""
|
|
145
|
+
return self.request("GET", "/devices")
|
|
146
|
+
|
|
147
|
+
def get_device(self, device_id):
|
|
148
|
+
return self.request("GET", "/devices/%s" % _q(device_id))
|
|
149
|
+
|
|
150
|
+
def get_account(self):
|
|
151
|
+
"""Account, plan and usage."""
|
|
152
|
+
return self.request("GET", "/account")
|
|
153
|
+
|
|
154
|
+
def list_webhooks(self):
|
|
155
|
+
return self.request("GET", "/webhooks")
|
|
156
|
+
|
|
157
|
+
def create_webhook(self, url, events=("sms.received",), device_id=None):
|
|
158
|
+
"""Register a webhook. The answer includes the secret (only returned once)."""
|
|
159
|
+
return self.request("POST", "/webhooks", {"url": url, "events": list(events), "device_id": device_id})
|
|
160
|
+
|
|
161
|
+
def delete_webhook(self, webhook_id):
|
|
162
|
+
return self.request("DELETE", "/webhooks/%s" % _q(webhook_id))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def verify_webhook(raw_body, signature, secret):
|
|
166
|
+
"""Check the X-SmsGora-Signature header (sha256=<hex HMAC-SHA256 of the raw body>).
|
|
167
|
+
|
|
168
|
+
raw_body: bytes or str exactly as received, before JSON parsing.
|
|
169
|
+
"""
|
|
170
|
+
if not raw_body or not signature or not secret:
|
|
171
|
+
return False
|
|
172
|
+
if isinstance(raw_body, str):
|
|
173
|
+
raw_body = raw_body.encode("utf-8")
|
|
174
|
+
expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
|
|
175
|
+
return hmac.compare_digest(str(signature).encode("utf-8"), expected.encode("utf-8"))
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smsgora
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python client for the SMS Gora SMS Gateway: send and receive SMS through your own Android phones, with delivery reports and signed webhooks.
|
|
5
|
+
Author-email: Tekservix <support@tekservix.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tekservix.com/smsgora/
|
|
8
|
+
Project-URL: Documentation, https://tekservix.com/smsgora/docs/
|
|
9
|
+
Project-URL: Source, https://github.com/ellyj164/smsgora-python
|
|
10
|
+
Project-URL: Issues, https://github.com/ellyj164/smsgora-python/issues
|
|
11
|
+
Keywords: sms,sms-gateway,sms-api,bulk-sms,send-sms,android-sms-gateway,otp,textbee,smsgora,tekservix
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Communications :: Telephony
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# smsgora
|
|
23
|
+
|
|
24
|
+
Python client for the [SMS Gora SMS Gateway](https://tekservix.com/smsgora/) API.
|
|
25
|
+
|
|
26
|
+
SMS Gora turns Android phones with ordinary SIM cards into an SMS gateway. Your server calls the API, a linked phone sends the text, and replies and delivery reports come back to you. There's no sender ID registration and no per-message fee from us: your mobile operator bills the texts like any SMS.
|
|
27
|
+
|
|
28
|
+
- Zero dependencies (standard library only), Python 3.8+
|
|
29
|
+
- Works in Django, Flask, FastAPI, scripts and cron jobs
|
|
30
|
+
- Webhook signature check built in
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install smsgora
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
Create an API key in the SMS Gora dashboard (SMS Gora -> API keys) and keep it on your server.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from smsgora import SmsGora
|
|
45
|
+
|
|
46
|
+
sms = SmsGora(api_key=os.environ["SMSGORA_KEY"])
|
|
47
|
+
|
|
48
|
+
result = sms.send_sms(to="+15551234567", message="Your verification code is 482913")
|
|
49
|
+
print(result["message_ids"]) # [1873]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Sending
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
|
|
57
|
+
# Several recipients (up to 1,000 per call)
|
|
58
|
+
sms.send_sms(to=["+15551234567", "+447700900123"], message="Your order has shipped")
|
|
59
|
+
|
|
60
|
+
# From a chosen phone and SIM, scheduled for later
|
|
61
|
+
sms.send_sms(
|
|
62
|
+
to="+15551234567",
|
|
63
|
+
message="Reminder: your appointment is tomorrow at 10:00",
|
|
64
|
+
device_id=42,
|
|
65
|
+
sim_slot=1, # 0 = SIM 1, 1 = SIM 2
|
|
66
|
+
schedule_at=datetime(2026, 12, 1, 8, 0, tzinfo=timezone.utc),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Different texts for different people in one call (up to 5,000 recipients)
|
|
70
|
+
sms.send_bulk_sms(
|
|
71
|
+
42,
|
|
72
|
+
message_template="Hello, your parcel is ready for pickup.",
|
|
73
|
+
messages=[
|
|
74
|
+
{"recipients": ["+15551234567"], "message": "Hi Aline, parcel #1042 is ready."},
|
|
75
|
+
{"recipients": ["+15557654321"]},
|
|
76
|
+
],
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Delivery status and incoming SMS
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
msg = sms.get_message(1873)
|
|
84
|
+
print(msg["status"]) # queued -> dispatched -> sent -> delivered (or failed)
|
|
85
|
+
|
|
86
|
+
sms.cancel_message(1874) # works while queued or scheduled
|
|
87
|
+
|
|
88
|
+
inbox = sms.list_received_sms(since="2026-11-01", limit=50)
|
|
89
|
+
phones = sms.list_devices()
|
|
90
|
+
account = sms.get_account()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Anything else in the [API reference](https://tekservix.com/smsgora/docs/) is one call away:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
batch = sms.request("GET", "/batches/311")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Webhooks
|
|
100
|
+
|
|
101
|
+
Add your HTTPS URL in SMS Gora -> Webhooks (or with `create_webhook`) and keep the secret. Each request carries `X-SmsGora-Signature: sha256=...`, an HMAC-SHA256 of the raw body. Check it before trusting the request. Flask example:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import json, os
|
|
105
|
+
from flask import Flask, request, abort
|
|
106
|
+
from smsgora import verify_webhook
|
|
107
|
+
|
|
108
|
+
app = Flask(__name__)
|
|
109
|
+
|
|
110
|
+
@app.post("/sms/webhook")
|
|
111
|
+
def sms_webhook():
|
|
112
|
+
if not verify_webhook(request.get_data(), request.headers.get("X-SmsGora-Signature"),
|
|
113
|
+
os.environ["SMSGORA_WEBHOOK_SECRET"]):
|
|
114
|
+
abort(401)
|
|
115
|
+
event = json.loads(request.get_data())
|
|
116
|
+
if event["event"] == "sms.received":
|
|
117
|
+
print(event["data"]["from"], event["data"]["message"])
|
|
118
|
+
return "", 200 # answer within 10 seconds; queue slow work
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In Django use `request.body`; in FastAPI use `await request.body()`. Always pass the raw bytes, not re-encoded JSON.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
Failed calls raise `SmsGoraError` with `status`, `message`, field `errors` and, on 429, `retry_after` in seconds.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from smsgora import SmsGoraError
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
sms.send_sms(to="+15551234567", message="Hi")
|
|
132
|
+
except SmsGoraError as err:
|
|
133
|
+
if err.status == 429:
|
|
134
|
+
pass # rate limit (300 requests/minute per key) or plan limit reached
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Never ship the key inside a browser or mobile app: anyone could read it. Call the API from your server.
|
|
138
|
+
|
|
139
|
+
## Migrating from TextBee
|
|
140
|
+
|
|
141
|
+
SMS Gora accepts TextBee-style requests, so most integrations switch by changing the base URL and key. See the [compatibility notes](https://tekservix.com/smsgora/docs/).
|
|
142
|
+
|
|
143
|
+
## Good to know
|
|
144
|
+
|
|
145
|
+
A phone-based gateway suits OTPs, order alerts, reminders and two-way conversations at moderate volume. It isn't meant for mass marketing blasts, and in the US high-volume business texting belongs on registered 10DLC or toll-free numbers.
|
|
146
|
+
|
|
147
|
+
## More from Tekservix
|
|
148
|
+
|
|
149
|
+
- [WAservix WhatsApp API Gateway](https://tekservix.com/waservix/): send WhatsApp messages from your own number (`pip install waservix`)
|
|
150
|
+
- [AI Sales Agent](https://tekservix.com/ai-sales-agent): answers customers on WhatsApp and SMS using your website's content
|
|
151
|
+
- [Website to App](https://tekservix.com/mobile-apps): turn your website into Android and iOS apps
|
|
152
|
+
- Also on npm: `npm install smsgora`
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smsgora
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import hashlib, hmac, json, threading, unittest
|
|
2
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
3
|
+
|
|
4
|
+
from smsgora import SmsGora, SmsGoraError, verify_webhook
|
|
5
|
+
|
|
6
|
+
CALLS = []
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Handler(BaseHTTPRequestHandler):
|
|
10
|
+
def _go(self):
|
|
11
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
12
|
+
body = json.loads(self.rfile.read(n)) if n else None
|
|
13
|
+
CALLS.append((self.command, self.path, {k.lower(): v for k, v in self.headers.items()}, body))
|
|
14
|
+
if self.path.startswith("/limit"):
|
|
15
|
+
self.send_response(429); self.send_header("Retry-After", "7"); self.end_headers()
|
|
16
|
+
self.wfile.write(b'{"success":false,"message":"Too many requests"}'); return
|
|
17
|
+
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers()
|
|
18
|
+
self.wfile.write(json.dumps({"success": True, "data": {"ok": True}}).encode())
|
|
19
|
+
do_GET = do_POST = do_DELETE = do_PUT = _go
|
|
20
|
+
def log_message(self, *a): pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class T(unittest.TestCase):
|
|
24
|
+
@classmethod
|
|
25
|
+
def setUpClass(cls):
|
|
26
|
+
cls.srv = HTTPServer(("127.0.0.1", 0), Handler)
|
|
27
|
+
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
|
|
28
|
+
cls.c = SmsGora("sgk_test", base_url="http://127.0.0.1:%d/" % cls.srv.server_port)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def tearDownClass(cls):
|
|
32
|
+
cls.srv.shutdown()
|
|
33
|
+
|
|
34
|
+
def last(self):
|
|
35
|
+
return CALLS[-1]
|
|
36
|
+
|
|
37
|
+
def test_send(self):
|
|
38
|
+
self.assertEqual(self.c.send_sms(to="+1555", message="hi"), {"ok": True})
|
|
39
|
+
m, p, h, b = self.last()
|
|
40
|
+
self.assertEqual((m, p), ("POST", "/send-sms"))
|
|
41
|
+
self.assertEqual(h["x-api-key"], "sgk_test")
|
|
42
|
+
self.assertEqual(b, {"recipients": ["+1555"], "message": "hi"})
|
|
43
|
+
|
|
44
|
+
def test_send_device(self):
|
|
45
|
+
self.c.send_sms(to=["+1", "+2"], message="x", device_id=42, sim_slot=1, schedule_at="2026-12-01T08:00:00Z")
|
|
46
|
+
m, p, h, b = self.last()
|
|
47
|
+
self.assertEqual(p, "/devices/42/send-sms")
|
|
48
|
+
self.assertEqual(b["sim_slot"], 1)
|
|
49
|
+
self.assertEqual(b["schedule_at"], "2026-12-01T08:00:00Z")
|
|
50
|
+
|
|
51
|
+
def test_bulk_and_lists(self):
|
|
52
|
+
self.c.send_bulk_sms(42, [{"recipients": ["+1"]}], message_template="t")
|
|
53
|
+
self.assertEqual(self.last()[3], {"messages": [{"recipients": ["+1"]}], "messageTemplate": "t"})
|
|
54
|
+
self.c.list_received_sms(device_id=3, limit=5, since=None)
|
|
55
|
+
self.assertEqual(self.last()[1], "/devices/3/get-received-sms?limit=5")
|
|
56
|
+
self.c.list_messages(status="sent")
|
|
57
|
+
self.assertEqual(self.last()[1], "/messages?status=sent")
|
|
58
|
+
self.c.delete_webhook(9)
|
|
59
|
+
self.assertEqual(self.last()[:2], ("DELETE", "/webhooks/9"))
|
|
60
|
+
|
|
61
|
+
def test_error(self):
|
|
62
|
+
c = SmsGora("k", base_url="http://127.0.0.1:%d/limit" % self.srv.server_port)
|
|
63
|
+
with self.assertRaises(SmsGoraError) as e:
|
|
64
|
+
c.get_account()
|
|
65
|
+
self.assertEqual((e.exception.status, e.exception.retry_after, str(e.exception)), (429, 7.0, "Too many requests"))
|
|
66
|
+
|
|
67
|
+
def test_webhook(self):
|
|
68
|
+
raw = b'{"event":"sms.received"}'
|
|
69
|
+
sig = "sha256=" + hmac.new(b"s3cret", raw, hashlib.sha256).hexdigest()
|
|
70
|
+
self.assertTrue(verify_webhook(raw, sig, "s3cret"))
|
|
71
|
+
self.assertTrue(verify_webhook(raw.decode(), sig, "s3cret"))
|
|
72
|
+
self.assertFalse(verify_webhook(raw, sig, "wrong"))
|
|
73
|
+
self.assertFalse(verify_webhook(raw, None, "s3cret"))
|
|
74
|
+
|
|
75
|
+
def test_requires_key(self):
|
|
76
|
+
with self.assertRaises(ValueError):
|
|
77
|
+
SmsGora("")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
unittest.main()
|