irnoti 1.2.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.
- irnoti-1.2.0/.gitignore +5 -0
- irnoti-1.2.0/CHANGELOG.md +22 -0
- irnoti-1.2.0/LICENSE.md +21 -0
- irnoti-1.2.0/PKG-INFO +125 -0
- irnoti-1.2.0/README.md +93 -0
- irnoti-1.2.0/pyproject.toml +47 -0
- irnoti-1.2.0/src/irnoti/__init__.py +6 -0
- irnoti-1.2.0/src/irnoti/client.py +114 -0
- irnoti-1.2.0/src/irnoti/errors.py +61 -0
- irnoti-1.2.0/src/irnoti/py.typed +0 -0
- irnoti-1.2.0/src/irnoti/resources.py +330 -0
- irnoti-1.2.0/src/irnoti/webhook.py +32 -0
- irnoti-1.2.0/tests/test_sdk.py +116 -0
irnoti-1.2.0/.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. This project follows [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [1.2.0] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- `line_id` is now optional everywhere (`sms.send`, `sms.quote`, link tracker, number banks). When omitted it is not sent and the server chooses the sender line. Passing it still works exactly as before.
|
|
9
|
+
|
|
10
|
+
## [1.1.0] - 2026-10-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Telegram broker (`broker.*` scopes): register/rename/delete bots, replace token, rotate relay link, call any Bot API method (`request`), pull `updates`, and manage the return webhook.
|
|
14
|
+
|
|
15
|
+
## [1.0.0] - 2026-10-10
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Initial release of the official irnoti Python SDK for the platform API (`https://api.irnoti.com/v1`).
|
|
19
|
+
- SMS: send (single, bulk, group, scheduled, gradual, idempotent), quote, pattern, SOS.
|
|
20
|
+
- Messages, batches, lines, wallet, phonebook (groups, contacts, CSV import).
|
|
21
|
+
- Provider services (regional, targeted, peyamap, smart, messenger, voice, international), link tracker, number banks, verify sessions, bots, VoIP.
|
|
22
|
+
- Typed error with stable `code` and helpers; webhook signature verification.
|
irnoti-1.2.0/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) irnoti
|
|
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
|
|
7
|
+
deal in the Software without restriction, including without limitation the
|
|
8
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
9
|
+
sell 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
|
|
13
|
+
all 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.
|
irnoti-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: irnoti
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Official Python SDK for the irnoti platform API (SMS, pattern, SOS, phonebook, services, verify, bots, VoIP, webhooks).
|
|
5
|
+
Project-URL: Homepage, https://irnoti.com
|
|
6
|
+
Project-URL: Documentation, https://irnoti.com/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/irnoti/python-sms-panel-api
|
|
8
|
+
Project-URL: Issues, https://github.com/irnoti/python-sms-panel-api/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/irnoti/python-sms-panel-api/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: irnoti <support@irnoti.com>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE.md
|
|
13
|
+
Keywords: api-client,iran,irnoti,otp,sdk,sms,sms-api
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
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: Topic :: Communications
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Requires-Dist: httpx>=0.25
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# irnoti (Python)
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/irnoti/) [](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml) [](LICENSE.md)
|
|
36
|
+
|
|
37
|
+
Python SDK for the irnoti API, built on `httpx`.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install irnoti
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from irnoti import Irnoti, IrnotiApiError, parse_webhook
|
|
45
|
+
|
|
46
|
+
irnoti = Irnoti(api_key="irn_live_…") # base_url, timeout, retries, http_client are optional
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
line = irnoti.lines.list()[0]["id"]
|
|
50
|
+
irnoti.sms.quote(line, "Hi", to="09121234567")
|
|
51
|
+
irnoti.sms.send(line, "Hi", to=["09121234567"], group_ids=[12], idempotency_key="k1")
|
|
52
|
+
irnoti.sms.pattern("k3f9a2", "09121234567", {"code": "4821"})
|
|
53
|
+
irnoti.services.create("regional", {"province": "tehran", "count": 5000, "text": "Sale"})
|
|
54
|
+
except IrnotiApiError as e:
|
|
55
|
+
if e.is_insufficient_balance:
|
|
56
|
+
... # top up
|
|
57
|
+
else:
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
# Flask: verify against the raw body
|
|
61
|
+
event = parse_webhook(request.get_data(), request.headers["X-Irnoti-Signature"], WEBHOOK_SECRET)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Multi-tenant: `irnoti.with_api_key(customer_key)` returns an independent client. Optional request fields use the API's camelCase names where they are passed as `**options` / dicts (e.g. `verify.create(to=…, ttlSec=300)`).
|
|
65
|
+
|
|
66
|
+
## Quickstart
|
|
67
|
+
|
|
68
|
+
New here? Follow **[QUICKSTART.md](QUICKSTART.md)**: install, get a key, send your first (free, sandbox) SMS in 5 minutes.
|
|
69
|
+
|
|
70
|
+
## What can this SDK do? (function reference)
|
|
71
|
+
|
|
72
|
+
Everything hangs off one client, `irnoti = Irnoti(api_key=...)`. Each group below is a *resource* (`irnoti.<resource>.<function>()`), and each links to its section in the API docs at [irnoti.com/docs](https://irnoti.com/docs) (open the docs panel and look for the section named in the "Docs section" column).
|
|
73
|
+
|
|
74
|
+
| Resource | Docs section | What it is for | Functions |
|
|
75
|
+
|---|---|---|---|
|
|
76
|
+
| `irnoti.sms` | SMS | Send text messages | `send` (single / bulk / group / scheduled / gradual), `quote` (exact price, no send), `pattern` (approved OTP/transactional template), `sos` (guaranteed delivery with failover) |
|
|
77
|
+
| `irnoti.messages` | SMS → Messages | Look up sent messages | `list`, `get`, `check` (ask operator for final delivery state) |
|
|
78
|
+
| `irnoti.batches` | SMS → Batches | Control scheduled/gradual sends | `scheduled`, `cancel`, `pause`, `resume` |
|
|
79
|
+
| `irnoti.lines` | SMS → Lines | Your sender numbers (you need a `line_id` to send) | `list` |
|
|
80
|
+
| `irnoti.wallet` | Wallet | Balance and history | `balance`, `transactions` |
|
|
81
|
+
| `irnoti.phonebook` | Phonebook | Contact groups and contacts | `groups`, `create_group`, `delete_group`, `contacts`, `add_contacts`, `import_csv`, `update_contact`, `delete_contact` |
|
|
82
|
+
| `irnoti.services` | Services | Provider products: `regional`, `targeted`, `peyamap`, `smart`, `messenger`, `voice`, `international` | `prices`, `international_rates`, `list`, `quote`, `create`, `get`, `refresh`, `cancel` |
|
|
83
|
+
| `irnoti.link_tracker` | Link tracker | SMS with tracked links, A/B variants, holdout | `send`, `holdouts`, `send_now`, `campaigns`, `campaign` |
|
|
84
|
+
| `irnoti.number_banks` | Number banks | Send to purchased/filtered number banks | `list`, `facets`, `quote`, `send` |
|
|
85
|
+
| `irnoti.verify` | Verify | OTP verification sessions (SMS or bot) | `info`, `create`, `list`, `get`, `cancel`, `stats` |
|
|
86
|
+
| `irnoti.bots` | Bot builder | Read bots, send messages/OTP through them (creating bots is panel-only) | `info`, `list`, `get`, `stats`, `subscribers`, `messages`, `send`, `otp`, `shop_orders`, `shop_stats` |
|
|
87
|
+
| `irnoti.broker` | Telegram broker | Run Telegram bots through a private relay | `list`, `get`, `create`, `rename`, `replace_token`, `rotate`, `delete`, `request`, `updates`, `webhook`, `set_webhook`, `delete_webhook` |
|
|
88
|
+
| `irnoti.voip` | VoIP | Click-to-call | `calls`, `price`, `call` |
|
|
89
|
+
|
|
90
|
+
Top-level helpers (no network call):
|
|
91
|
+
|
|
92
|
+
| Function | What it does |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `Irnoti(api_key, base_url=, timeout=, retries=, http_client=)` | Create the client |
|
|
95
|
+
| `irnoti.with_api_key(key)` | New independent client for another customer key (multi-tenant) |
|
|
96
|
+
| `verify_webhook(raw_body, signature, secret)` | `True`/`False`: is this webhook really from irnoti? |
|
|
97
|
+
| `parse_webhook(raw_body, signature, secret)` | Verify and return the decoded event, raises `ValueError` if the signature is bad |
|
|
98
|
+
| `IrnotiApiError` | Raised on any non-2xx answer; has `.status`, `.code`, `.details` and helpers like `.is_insufficient_balance` |
|
|
99
|
+
|
|
100
|
+
Every function is a thin wrapper over one HTTP endpoint and returns the API's JSON as plain `dict`/`list`. Read the docstring in [`src/irnoti/resources.py`](src/irnoti/resources.py) for exact arguments, e.g. `help(irnoti.sms.send)`.
|
|
101
|
+
|
|
102
|
+
> Typical flow: `lines.list()` (pick a sender) → `sms.quote()` (see the cost) → `sms.send()` (with an `idempotency_key`) → `messages.get()` / webhook (delivery result). Use an `irn_test_…` key and `line_id="sandbox"` while experimenting: nothing is sent or charged.
|
|
103
|
+
|
|
104
|
+
### Telegram broker
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
bot = irnoti.broker.create("Shop bot", botfather_token) # scope broker.write (account owner); returns the relay `link`
|
|
108
|
+
irnoti.broker.request(bot["id"], "sendMessage", {"chat_id": 123, "text": "Hi"}) # scope broker.request
|
|
109
|
+
irnoti.broker.updates(bot["id"], limit=20) # pull mode
|
|
110
|
+
irnoti.broker.set_webhook(bot["id"], "https://example.com/tg", secret_token="s3cret") # push mode
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Behaviour shared by all irnoti SDKs
|
|
114
|
+
|
|
115
|
+
- **Auth:** `Authorization: Bearer irn_live_…`. Use an `irn_test_…` key with `lineId: "sandbox"` to try sends for free. Keys carry scopes (`sms.send`, `sms.read`, `wallet.read`, `phonebook.read|write`, `bot.send|read`, `broker.read|write|request`, `verify.send|read`); a missing scope is a `403`.
|
|
116
|
+
- **Money** is integer **Rial** returned as a **string** (`"1960"`) — never parse it into a float.
|
|
117
|
+
- **Idempotency:** every send takes an idempotency key; retrying with the same key never sends twice (`replayed: true`). Automatic retries apply only to `GET`s and sends that carry a key.
|
|
118
|
+
- **Errors:** one error type carrying `status`, the API's machine `code` (`insufficient_balance`, `invalid_recipients`, `no_price`, … — empty for plain 401/403/404) and `details`, with helpers such as `isInsufficientBalance` / `isRateLimited` / `isUnauthenticated`.
|
|
119
|
+
- **Limits:** send 600 req/min, pattern 1200 req/min per IP (`429` when exceeded).
|
|
120
|
+
- **Webhooks:** verify the **raw** body against `X-Irnoti-Signature: t=<unix>,v1=<hex>` (HMAC-SHA256 of `"<t>.<body>"` with your `whsec_…` key); signatures older than 5 minutes are rejected.
|
|
121
|
+
- Bot-builder creation/configuration and verify settings are panel-only; Telegram broker bots can be managed through the API.
|
|
122
|
+
|
|
123
|
+
Sibling SDKs: [Laravel](https://github.com/irnoti/laravel-sms-panel-api) · [Node.js](https://github.com/irnoti/node-sms-panel-api) · [Python](https://github.com/irnoti/python-sms-panel-api) · [Go](https://github.com/irnoti/go-sms-panel-api) · [PHP](https://github.com/irnoti/php-sms-panel-api). API reference: [irnoti.com/docs](https://irnoti.com/docs).
|
|
124
|
+
|
|
125
|
+
MIT licensed.
|
irnoti-1.2.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# irnoti (Python)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/irnoti/) [](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml) [](LICENSE.md)
|
|
4
|
+
|
|
5
|
+
Python SDK for the irnoti API, built on `httpx`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install irnoti
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from irnoti import Irnoti, IrnotiApiError, parse_webhook
|
|
13
|
+
|
|
14
|
+
irnoti = Irnoti(api_key="irn_live_…") # base_url, timeout, retries, http_client are optional
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
line = irnoti.lines.list()[0]["id"]
|
|
18
|
+
irnoti.sms.quote(line, "Hi", to="09121234567")
|
|
19
|
+
irnoti.sms.send(line, "Hi", to=["09121234567"], group_ids=[12], idempotency_key="k1")
|
|
20
|
+
irnoti.sms.pattern("k3f9a2", "09121234567", {"code": "4821"})
|
|
21
|
+
irnoti.services.create("regional", {"province": "tehran", "count": 5000, "text": "Sale"})
|
|
22
|
+
except IrnotiApiError as e:
|
|
23
|
+
if e.is_insufficient_balance:
|
|
24
|
+
... # top up
|
|
25
|
+
else:
|
|
26
|
+
raise
|
|
27
|
+
|
|
28
|
+
# Flask: verify against the raw body
|
|
29
|
+
event = parse_webhook(request.get_data(), request.headers["X-Irnoti-Signature"], WEBHOOK_SECRET)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Multi-tenant: `irnoti.with_api_key(customer_key)` returns an independent client. Optional request fields use the API's camelCase names where they are passed as `**options` / dicts (e.g. `verify.create(to=…, ttlSec=300)`).
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
New here? Follow **[QUICKSTART.md](QUICKSTART.md)**: install, get a key, send your first (free, sandbox) SMS in 5 minutes.
|
|
37
|
+
|
|
38
|
+
## What can this SDK do? (function reference)
|
|
39
|
+
|
|
40
|
+
Everything hangs off one client, `irnoti = Irnoti(api_key=...)`. Each group below is a *resource* (`irnoti.<resource>.<function>()`), and each links to its section in the API docs at [irnoti.com/docs](https://irnoti.com/docs) (open the docs panel and look for the section named in the "Docs section" column).
|
|
41
|
+
|
|
42
|
+
| Resource | Docs section | What it is for | Functions |
|
|
43
|
+
|---|---|---|---|
|
|
44
|
+
| `irnoti.sms` | SMS | Send text messages | `send` (single / bulk / group / scheduled / gradual), `quote` (exact price, no send), `pattern` (approved OTP/transactional template), `sos` (guaranteed delivery with failover) |
|
|
45
|
+
| `irnoti.messages` | SMS → Messages | Look up sent messages | `list`, `get`, `check` (ask operator for final delivery state) |
|
|
46
|
+
| `irnoti.batches` | SMS → Batches | Control scheduled/gradual sends | `scheduled`, `cancel`, `pause`, `resume` |
|
|
47
|
+
| `irnoti.lines` | SMS → Lines | Your sender numbers (you need a `line_id` to send) | `list` |
|
|
48
|
+
| `irnoti.wallet` | Wallet | Balance and history | `balance`, `transactions` |
|
|
49
|
+
| `irnoti.phonebook` | Phonebook | Contact groups and contacts | `groups`, `create_group`, `delete_group`, `contacts`, `add_contacts`, `import_csv`, `update_contact`, `delete_contact` |
|
|
50
|
+
| `irnoti.services` | Services | Provider products: `regional`, `targeted`, `peyamap`, `smart`, `messenger`, `voice`, `international` | `prices`, `international_rates`, `list`, `quote`, `create`, `get`, `refresh`, `cancel` |
|
|
51
|
+
| `irnoti.link_tracker` | Link tracker | SMS with tracked links, A/B variants, holdout | `send`, `holdouts`, `send_now`, `campaigns`, `campaign` |
|
|
52
|
+
| `irnoti.number_banks` | Number banks | Send to purchased/filtered number banks | `list`, `facets`, `quote`, `send` |
|
|
53
|
+
| `irnoti.verify` | Verify | OTP verification sessions (SMS or bot) | `info`, `create`, `list`, `get`, `cancel`, `stats` |
|
|
54
|
+
| `irnoti.bots` | Bot builder | Read bots, send messages/OTP through them (creating bots is panel-only) | `info`, `list`, `get`, `stats`, `subscribers`, `messages`, `send`, `otp`, `shop_orders`, `shop_stats` |
|
|
55
|
+
| `irnoti.broker` | Telegram broker | Run Telegram bots through a private relay | `list`, `get`, `create`, `rename`, `replace_token`, `rotate`, `delete`, `request`, `updates`, `webhook`, `set_webhook`, `delete_webhook` |
|
|
56
|
+
| `irnoti.voip` | VoIP | Click-to-call | `calls`, `price`, `call` |
|
|
57
|
+
|
|
58
|
+
Top-level helpers (no network call):
|
|
59
|
+
|
|
60
|
+
| Function | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `Irnoti(api_key, base_url=, timeout=, retries=, http_client=)` | Create the client |
|
|
63
|
+
| `irnoti.with_api_key(key)` | New independent client for another customer key (multi-tenant) |
|
|
64
|
+
| `verify_webhook(raw_body, signature, secret)` | `True`/`False`: is this webhook really from irnoti? |
|
|
65
|
+
| `parse_webhook(raw_body, signature, secret)` | Verify and return the decoded event, raises `ValueError` if the signature is bad |
|
|
66
|
+
| `IrnotiApiError` | Raised on any non-2xx answer; has `.status`, `.code`, `.details` and helpers like `.is_insufficient_balance` |
|
|
67
|
+
|
|
68
|
+
Every function is a thin wrapper over one HTTP endpoint and returns the API's JSON as plain `dict`/`list`. Read the docstring in [`src/irnoti/resources.py`](src/irnoti/resources.py) for exact arguments, e.g. `help(irnoti.sms.send)`.
|
|
69
|
+
|
|
70
|
+
> Typical flow: `lines.list()` (pick a sender) → `sms.quote()` (see the cost) → `sms.send()` (with an `idempotency_key`) → `messages.get()` / webhook (delivery result). Use an `irn_test_…` key and `line_id="sandbox"` while experimenting: nothing is sent or charged.
|
|
71
|
+
|
|
72
|
+
### Telegram broker
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
bot = irnoti.broker.create("Shop bot", botfather_token) # scope broker.write (account owner); returns the relay `link`
|
|
76
|
+
irnoti.broker.request(bot["id"], "sendMessage", {"chat_id": 123, "text": "Hi"}) # scope broker.request
|
|
77
|
+
irnoti.broker.updates(bot["id"], limit=20) # pull mode
|
|
78
|
+
irnoti.broker.set_webhook(bot["id"], "https://example.com/tg", secret_token="s3cret") # push mode
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Behaviour shared by all irnoti SDKs
|
|
82
|
+
|
|
83
|
+
- **Auth:** `Authorization: Bearer irn_live_…`. Use an `irn_test_…` key with `lineId: "sandbox"` to try sends for free. Keys carry scopes (`sms.send`, `sms.read`, `wallet.read`, `phonebook.read|write`, `bot.send|read`, `broker.read|write|request`, `verify.send|read`); a missing scope is a `403`.
|
|
84
|
+
- **Money** is integer **Rial** returned as a **string** (`"1960"`) — never parse it into a float.
|
|
85
|
+
- **Idempotency:** every send takes an idempotency key; retrying with the same key never sends twice (`replayed: true`). Automatic retries apply only to `GET`s and sends that carry a key.
|
|
86
|
+
- **Errors:** one error type carrying `status`, the API's machine `code` (`insufficient_balance`, `invalid_recipients`, `no_price`, … — empty for plain 401/403/404) and `details`, with helpers such as `isInsufficientBalance` / `isRateLimited` / `isUnauthenticated`.
|
|
87
|
+
- **Limits:** send 600 req/min, pattern 1200 req/min per IP (`429` when exceeded).
|
|
88
|
+
- **Webhooks:** verify the **raw** body against `X-Irnoti-Signature: t=<unix>,v1=<hex>` (HMAC-SHA256 of `"<t>.<body>"` with your `whsec_…` key); signatures older than 5 minutes are rejected.
|
|
89
|
+
- Bot-builder creation/configuration and verify settings are panel-only; Telegram broker bots can be managed through the API.
|
|
90
|
+
|
|
91
|
+
Sibling SDKs: [Laravel](https://github.com/irnoti/laravel-sms-panel-api) · [Node.js](https://github.com/irnoti/node-sms-panel-api) · [Python](https://github.com/irnoti/python-sms-panel-api) · [Go](https://github.com/irnoti/go-sms-panel-api) · [PHP](https://github.com/irnoti/php-sms-panel-api). API reference: [irnoti.com/docs](https://irnoti.com/docs).
|
|
92
|
+
|
|
93
|
+
MIT licensed.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "irnoti"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Official Python SDK for the irnoti platform API (SMS, pattern, SOS, phonebook, services, verify, bots, VoIP, webhooks)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.md"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["httpx>=0.25"]
|
|
14
|
+
keywords = ["irnoti", "sms", "sms-api", "sdk", "api-client", "otp", "iran"]
|
|
15
|
+
authors = [{ name = "irnoti", email = "support@irnoti.com" }]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Communications",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://irnoti.com"
|
|
32
|
+
Documentation = "https://irnoti.com/docs"
|
|
33
|
+
Repository = "https://github.com/irnoti/python-sms-panel-api"
|
|
34
|
+
Issues = "https://github.com/irnoti/python-sms-panel-api/issues"
|
|
35
|
+
Changelog = "https://github.com/irnoti/python-sms-panel-api/blob/main/CHANGELOG.md"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = ["pytest>=8", "build", "twine"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/irnoti"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
pythonpath = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src", "tests", "README.md", "LICENSE.md", "CHANGELOG.md"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Iterable, Mapping, Optional, Union
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import IrnotiApiError
|
|
9
|
+
from .resources import (
|
|
10
|
+
Batches, Bots, Broker, Lines, LinkTracker, Messages, NumberBanks, Phonebook, Services, Sms, Verify, Voip, Wallet,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.irnoti.com"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Transport:
|
|
17
|
+
def __init__(self, api_key: str, base_url: str, timeout: float, retries: int, client: Optional[httpx.Client]):
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise ValueError("irnoti: api_key is required")
|
|
20
|
+
self._base = base_url.rstrip("/") + "/v1"
|
|
21
|
+
self._retries = retries
|
|
22
|
+
self._http = client or httpx.Client(timeout=timeout)
|
|
23
|
+
self._headers = {"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
|
|
24
|
+
|
|
25
|
+
def request(
|
|
26
|
+
self,
|
|
27
|
+
method: str,
|
|
28
|
+
path: str,
|
|
29
|
+
*,
|
|
30
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
31
|
+
body: Any = None,
|
|
32
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
33
|
+
files: Optional[Mapping[str, Any]] = None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
params = {k: v for k, v in (query or {}).items() if v is not None}
|
|
36
|
+
hdrs = {**self._headers, **(headers or {})}
|
|
37
|
+
# Only calls that are safe to repeat are retried: GETs and sends carrying an Idempotency-Key.
|
|
38
|
+
attempts = 1 + (self._retries if method == "GET" or "Idempotency-Key" in hdrs else 0)
|
|
39
|
+
last: Optional[Exception] = None
|
|
40
|
+
for i in range(attempts):
|
|
41
|
+
try:
|
|
42
|
+
res = self._http.request(
|
|
43
|
+
method, self._base + path, params=params or None, headers=hdrs,
|
|
44
|
+
json=_clean(body) if body is not None and not files else None, files=files,
|
|
45
|
+
)
|
|
46
|
+
except httpx.TransportError as exc:
|
|
47
|
+
last = exc
|
|
48
|
+
continue
|
|
49
|
+
if res.status_code >= 500 and i < attempts - 1:
|
|
50
|
+
continue
|
|
51
|
+
return self._unwrap(res)
|
|
52
|
+
assert last is not None
|
|
53
|
+
raise last
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _unwrap(res: httpx.Response) -> Any:
|
|
57
|
+
try:
|
|
58
|
+
data = res.json() if res.content else None
|
|
59
|
+
except ValueError:
|
|
60
|
+
data = None
|
|
61
|
+
if res.is_success:
|
|
62
|
+
return data
|
|
63
|
+
msg = data.get("message") if isinstance(data, dict) else None
|
|
64
|
+
if isinstance(msg, list):
|
|
65
|
+
msg = " ".join(map(str, msg))
|
|
66
|
+
raise IrnotiApiError(
|
|
67
|
+
msg or f"irnoti API error (HTTP {res.status_code})",
|
|
68
|
+
res.status_code,
|
|
69
|
+
str(data.get("code", "")) if isinstance(data, dict) else "",
|
|
70
|
+
data.get("details") if isinstance(data, dict) else None,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _clean(value: Any) -> Any:
|
|
75
|
+
"""Drop ``None`` values (optional fields are omitted) and serialise datetimes."""
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return {k: _clean(v) for k, v in value.items() if v is not None}
|
|
78
|
+
if isinstance(value, (list, tuple)):
|
|
79
|
+
return [_clean(v) for v in value]
|
|
80
|
+
if isinstance(value, datetime):
|
|
81
|
+
return value.isoformat()
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Irnoti:
|
|
86
|
+
"""Entry point. ``Irnoti(api_key="irn_live_…").sms.send(line_id=1, to="0912…", text="hi")``"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
api_key: str,
|
|
91
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
92
|
+
timeout: float = 15.0,
|
|
93
|
+
retries: int = 1,
|
|
94
|
+
http_client: Optional[httpx.Client] = None,
|
|
95
|
+
):
|
|
96
|
+
self._cfg = dict(base_url=base_url, timeout=timeout, retries=retries, http_client=http_client)
|
|
97
|
+
t = Transport(api_key, base_url, timeout, retries, http_client)
|
|
98
|
+
self.sms = Sms(t)
|
|
99
|
+
self.messages = Messages(t)
|
|
100
|
+
self.batches = Batches(t)
|
|
101
|
+
self.lines = Lines(t)
|
|
102
|
+
self.wallet = Wallet(t)
|
|
103
|
+
self.phonebook = Phonebook(t)
|
|
104
|
+
self.services = Services(t)
|
|
105
|
+
self.link_tracker = LinkTracker(t)
|
|
106
|
+
self.number_banks = NumberBanks(t)
|
|
107
|
+
self.verify = Verify(t)
|
|
108
|
+
self.bots = Bots(t)
|
|
109
|
+
self.broker = Broker(t)
|
|
110
|
+
self.voip = Voip(t)
|
|
111
|
+
|
|
112
|
+
def with_api_key(self, api_key: str) -> "Irnoti":
|
|
113
|
+
"""A new independent client bound to another key (multi-tenant apps)."""
|
|
114
|
+
return Irnoti(api_key, **self._cfg) # type: ignore[arg-type]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IrnotiError(Exception):
|
|
7
|
+
"""Base class for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IrnotiApiError(IrnotiError):
|
|
11
|
+
"""Non-2xx answer. ``code`` is the API's machine-readable code ('' for plain 401/403/404)."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, status: int, code: str = "", details: Any = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
self.details = details
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def is_validation_error(self) -> bool:
|
|
22
|
+
return self.code in ("validation_error", "invalid_recipients", "missing_params")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def is_invalid_recipients(self) -> bool:
|
|
26
|
+
return self.code == "invalid_recipients"
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_insufficient_balance(self) -> bool:
|
|
30
|
+
return self.status == 402 or self.code == "insufficient_balance"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_no_price(self) -> bool:
|
|
34
|
+
return self.code == "no_price"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_rate_limited(self) -> bool:
|
|
38
|
+
return self.status == 429
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_unauthenticated(self) -> bool:
|
|
42
|
+
return self.status == 401
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_forbidden(self) -> bool:
|
|
46
|
+
return self.status == 403
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_not_found(self) -> bool:
|
|
50
|
+
return self.status == 404
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_conflict(self) -> bool:
|
|
54
|
+
return self.status == 409
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def is_server_error(self) -> bool:
|
|
58
|
+
return self.status >= 500
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"IrnotiApiError(status={self.status}, code={self.code!r}, message={self.message!r})"
|
|
File without changes
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Union
|
|
5
|
+
|
|
6
|
+
Id = Union[str, int]
|
|
7
|
+
Json = Dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _e(v: Id) -> str:
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
return quote(str(v), safe="")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _idem(key: Optional[str]) -> Optional[Dict[str, str]]:
|
|
17
|
+
return {"Idempotency-Key": key} if key else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _line(line_id: Any) -> Optional[str]:
|
|
21
|
+
"""``lineId`` is optional: ``None``/empty is dropped from the body so the server picks the line."""
|
|
22
|
+
return None if line_id is None or line_id == "" else str(line_id)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ids(g: Optional[Iterable[Id]]) -> Optional[List[str]]:
|
|
26
|
+
return [str(x) for x in g] if g else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Resource:
|
|
30
|
+
def __init__(self, t: Any):
|
|
31
|
+
self._t = t
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Sms(_Resource):
|
|
35
|
+
def send(
|
|
36
|
+
self,
|
|
37
|
+
line_id: Union[Id, str, None] = None,
|
|
38
|
+
text: str = "",
|
|
39
|
+
to: Union[str, List[str], None] = None,
|
|
40
|
+
group_ids: Optional[Iterable[Id]] = None,
|
|
41
|
+
scheduled_at: Union[datetime, str, None] = None,
|
|
42
|
+
gradual: Optional[Mapping[str, int]] = None,
|
|
43
|
+
idempotency_key: Optional[str] = None,
|
|
44
|
+
) -> Json:
|
|
45
|
+
"""Scope ``sms.send``. ``line_id`` is optional (omitted → the server picks the sender line). ``line_id="sandbox"`` + an ``irn_test_`` key sends nothing and costs nothing.
|
|
46
|
+
|
|
47
|
+
``scheduled_at``: 1 minute – 60 days ahead. ``gradual``: ``{"chunkSize": 500, "intervalMinutes": 10}``.
|
|
48
|
+
Returns ``{batchId, count, totalPrice, replayed}`` (``totalPrice`` is integer Rial as a string).
|
|
49
|
+
"""
|
|
50
|
+
return self._t.request("POST", "/sms/send", headers=_idem(idempotency_key), body={
|
|
51
|
+
"lineId": _line(line_id), "to": to, "groupIds": _ids(group_ids), "text": text,
|
|
52
|
+
"scheduledAt": scheduled_at, "gradual": dict(gradual) if gradual else None,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
def quote(self, line_id: Union[Id, str, None] = None, text: str = "", to: Union[str, List[str], None] = None,
|
|
56
|
+
group_ids: Optional[Iterable[Id]] = None) -> Json:
|
|
57
|
+
"""Scope ``sms.read``: exact cost without sending or charging."""
|
|
58
|
+
return self._t.request("POST", "/sms/quote", body={
|
|
59
|
+
"lineId": _line(line_id), "to": to, "groupIds": _ids(group_ids), "text": text})
|
|
60
|
+
|
|
61
|
+
def pattern(self, code: str, to: str, params: Optional[Mapping[str, Any]] = None,
|
|
62
|
+
idempotency_key: Optional[str] = None) -> Json:
|
|
63
|
+
"""Approved template (OTP / transactional). Scope ``sms.send``."""
|
|
64
|
+
return self._t.request("POST", "/sms/pattern", headers=_idem(idempotency_key),
|
|
65
|
+
body={"code": code, "to": to, "params": dict(params or {})})
|
|
66
|
+
|
|
67
|
+
def sos(self, to: Union[str, List[str]], text: str, idempotency_key: Optional[str] = None) -> Json:
|
|
68
|
+
"""Guaranteed delivery with provider failover (<= 200 recipients, ``sms.sos`` tariff)."""
|
|
69
|
+
return self._t.request("POST", "/sms/sos", headers=_idem(idempotency_key), body={"to": to, "text": text})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Messages(_Resource):
|
|
73
|
+
def list(self, take: Optional[int] = None, before: Optional[Id] = None) -> List[Json]:
|
|
74
|
+
return self._t.request("GET", "/sms/messages", query={"take": take, "before": None if before is None else str(before)})
|
|
75
|
+
|
|
76
|
+
def get(self, id: Id) -> Json:
|
|
77
|
+
return self._t.request("GET", f"/sms/messages/{_e(id)}")
|
|
78
|
+
|
|
79
|
+
def check(self, id: Id) -> Json:
|
|
80
|
+
"""Ask the operator for the final delivery state now (30/min)."""
|
|
81
|
+
return self._t.request("POST", f"/sms/messages/{_e(id)}/check")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Batches(_Resource):
|
|
85
|
+
def scheduled(self) -> List[Json]:
|
|
86
|
+
return self._t.request("GET", "/sms/scheduled")
|
|
87
|
+
|
|
88
|
+
def cancel(self, batch_id: Id) -> Json:
|
|
89
|
+
return self._t.request("POST", f"/sms/batches/{_e(batch_id)}/cancel")
|
|
90
|
+
|
|
91
|
+
def pause(self, batch_id: Id) -> None:
|
|
92
|
+
self._t.request("POST", f"/sms/batches/{_e(batch_id)}/pause")
|
|
93
|
+
|
|
94
|
+
def resume(self, batch_id: Id) -> None:
|
|
95
|
+
self._t.request("POST", f"/sms/batches/{_e(batch_id)}/resume")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Lines(_Resource):
|
|
99
|
+
def list(self) -> List[Json]:
|
|
100
|
+
return self._t.request("GET", "/sms/lines")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Wallet(_Resource):
|
|
104
|
+
def balance(self) -> Json:
|
|
105
|
+
return self._t.request("GET", "/wallet")
|
|
106
|
+
|
|
107
|
+
def transactions(self) -> List[Json]:
|
|
108
|
+
return self._t.request("GET", "/wallet/transactions")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Phonebook(_Resource):
|
|
112
|
+
def groups(self) -> List[Json]:
|
|
113
|
+
return self._t.request("GET", "/phonebook/groups")
|
|
114
|
+
|
|
115
|
+
def create_group(self, name: str) -> Json:
|
|
116
|
+
return self._t.request("POST", "/phonebook/groups", body={"name": name})
|
|
117
|
+
|
|
118
|
+
def delete_group(self, id: Id) -> None:
|
|
119
|
+
self._t.request("DELETE", f"/phonebook/groups/{_e(id)}")
|
|
120
|
+
|
|
121
|
+
def contacts(self, group_id: Id, take: Optional[int] = None, after: Optional[Id] = None, q: Optional[str] = None) -> Any:
|
|
122
|
+
return self._t.request("GET", f"/phonebook/groups/{_e(group_id)}/contacts",
|
|
123
|
+
query={"take": take, "after": None if after is None else str(after), "q": q})
|
|
124
|
+
|
|
125
|
+
def add_contacts(self, group_id: Id, contacts: List[Mapping[str, Any]]) -> Json:
|
|
126
|
+
"""Up to 10,000 per call; invalid/duplicate numbers are counted, not fatal."""
|
|
127
|
+
return self._t.request("POST", f"/phonebook/groups/{_e(group_id)}/contacts", body={"contacts": contacts})
|
|
128
|
+
|
|
129
|
+
def import_csv(self, group_id: Id, csv: Union[str, bytes], filename: str = "contacts.csv") -> Json:
|
|
130
|
+
data = csv.encode() if isinstance(csv, str) else csv
|
|
131
|
+
return self._t.request("POST", f"/phonebook/groups/{_e(group_id)}/import",
|
|
132
|
+
files={"file": (filename, data, "text/csv")})
|
|
133
|
+
|
|
134
|
+
def update_contact(self, id: Id, **attributes: Any) -> Json:
|
|
135
|
+
"""camelCase API fields: mobile, name, lastName, company, email, city, note, birth, fields, optedOut."""
|
|
136
|
+
return self._t.request("PATCH", f"/phonebook/contacts/{_e(id)}", body=attributes)
|
|
137
|
+
|
|
138
|
+
def delete_contact(self, id: Id) -> None:
|
|
139
|
+
self._t.request("DELETE", f"/phonebook/contacts/{_e(id)}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Services(_Resource):
|
|
143
|
+
"""regional, targeted, peyamap, smart, messenger, voice, international: quote -> create -> refresh -> cancel."""
|
|
144
|
+
|
|
145
|
+
def prices(self) -> List[Json]:
|
|
146
|
+
return self._t.request("GET", "/services/prices")
|
|
147
|
+
|
|
148
|
+
def international_rates(self) -> Any:
|
|
149
|
+
return self._t.request("GET", "/services/international/rates")
|
|
150
|
+
|
|
151
|
+
def list(self, product: str) -> List[Json]:
|
|
152
|
+
return self._t.request("GET", f"/services/{product}")
|
|
153
|
+
|
|
154
|
+
def quote(self, product: str, body: Mapping[str, Any]) -> Json:
|
|
155
|
+
return self._t.request("POST", f"/services/{product}/quote", body=dict(body))
|
|
156
|
+
|
|
157
|
+
def create(self, product: str, body: Mapping[str, Any]) -> Json:
|
|
158
|
+
return self._t.request("POST", f"/services/{product}", body=dict(body))
|
|
159
|
+
|
|
160
|
+
def get(self, product: str, id: Id) -> Json:
|
|
161
|
+
return self._t.request("GET", f"/services/{product}/{_e(id)}")
|
|
162
|
+
|
|
163
|
+
def refresh(self, product: str, id: Id) -> Json:
|
|
164
|
+
return self._t.request("POST", f"/services/{product}/{_e(id)}/refresh")
|
|
165
|
+
|
|
166
|
+
def cancel(self, product: str, id: Id) -> Json:
|
|
167
|
+
"""Only before the provider starts; refunded."""
|
|
168
|
+
return self._t.request("POST", f"/services/{product}/{_e(id)}/cancel")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class LinkTracker(_Resource):
|
|
172
|
+
def send(self, line_id: Optional[Id] = None, text: str = "", url: str = "", to: Optional[List[str]] = None,
|
|
173
|
+
group_ids: Optional[Iterable[Id]] = None, variants: Optional[List[str]] = None,
|
|
174
|
+
holdout_percent: Optional[int] = None, decide_after_minutes: Optional[int] = None) -> Json:
|
|
175
|
+
return self._t.request("POST", "/link-tracker/send", body={
|
|
176
|
+
"lineId": _line(line_id), "text": text, "url": url, "to": to, "groupIds": _ids(group_ids),
|
|
177
|
+
"variants": variants, "holdoutPercent": holdout_percent, "decideAfterMinutes": decide_after_minutes})
|
|
178
|
+
|
|
179
|
+
def holdouts(self) -> List[Json]:
|
|
180
|
+
return self._t.request("GET", "/link-tracker/holdouts")
|
|
181
|
+
|
|
182
|
+
def send_now(self, holdout_id: Id) -> Json:
|
|
183
|
+
return self._t.request("POST", f"/link-tracker/holdouts/{_e(holdout_id)}/send-now")
|
|
184
|
+
|
|
185
|
+
def campaigns(self) -> List[Json]:
|
|
186
|
+
return self._t.request("GET", "/link-tracker/campaigns")
|
|
187
|
+
|
|
188
|
+
def campaign(self, batch_id: Id) -> Json:
|
|
189
|
+
return self._t.request("GET", f"/link-tracker/campaigns/{_e(batch_id)}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class NumberBanks(_Resource):
|
|
193
|
+
def list(self) -> List[Json]:
|
|
194
|
+
return self._t.request("GET", "/number-banks")
|
|
195
|
+
|
|
196
|
+
def facets(self, bank_id: Id) -> Json:
|
|
197
|
+
return self._t.request("GET", f"/number-banks/{_e(bank_id)}/facets")
|
|
198
|
+
|
|
199
|
+
def _body(self, bank_id: Id, count: int, line_id: Optional[Id], text: str, filters: Optional[Mapping[str, str]]) -> Json:
|
|
200
|
+
return {"bankId": str(bank_id), "count": count, "lineId": _line(line_id), "text": text, "filters": dict(filters or {})}
|
|
201
|
+
|
|
202
|
+
def quote(self, bank_id: Id, count: int, line_id: Optional[Id], text: str, filters: Optional[Mapping[str, str]] = None) -> Json:
|
|
203
|
+
return self._t.request("POST", "/number-banks/quote", body=self._body(bank_id, count, line_id, text, filters))
|
|
204
|
+
|
|
205
|
+
def send(self, bank_id: Id, count: int, line_id: Optional[Id], text: str, filters: Optional[Mapping[str, str]] = None,
|
|
206
|
+
idempotency_key: Optional[str] = None) -> Json:
|
|
207
|
+
return self._t.request("POST", "/number-banks/send", headers=_idem(idempotency_key),
|
|
208
|
+
body=self._body(bank_id, count, line_id, text, filters))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Verify(_Resource):
|
|
212
|
+
def info(self) -> Json:
|
|
213
|
+
return self._t.request("GET", "/verify/info")
|
|
214
|
+
|
|
215
|
+
def create(self, to: Optional[str] = None, bot: Optional[Mapping[str, Any]] = None, **options: Any) -> Json:
|
|
216
|
+
"""Exactly one of ``to`` (mobile) or ``bot`` (``{"id", "ref"}``). Options (camelCase): lineId, mode, length,
|
|
217
|
+
ttlSec, maxAttempts, redirectUrl, callbackUrl, clientRef, brandName, brandColor."""
|
|
218
|
+
return self._t.request("POST", "/verify/sessions", body={**options, "to": to, "bot": dict(bot) if bot else None})
|
|
219
|
+
|
|
220
|
+
def list(self, take: Optional[int] = None, before: Optional[Id] = None) -> List[Json]:
|
|
221
|
+
return self._t.request("GET", "/verify/sessions", query={"take": take, "before": None if before is None else str(before)})
|
|
222
|
+
|
|
223
|
+
def get(self, id: Id) -> Json:
|
|
224
|
+
return self._t.request("GET", f"/verify/sessions/{_e(id)}")
|
|
225
|
+
|
|
226
|
+
def cancel(self, id: Id) -> None:
|
|
227
|
+
self._t.request("POST", f"/verify/sessions/{_e(id)}/cancel")
|
|
228
|
+
|
|
229
|
+
def stats(self) -> Json:
|
|
230
|
+
return self._t.request("GET", "/verify/stats")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class Bots(_Resource):
|
|
234
|
+
"""Bot builder. Creating/configuring bots is panel-only; ``to``/``message`` follow the panel's bot sender shapes."""
|
|
235
|
+
|
|
236
|
+
def info(self) -> Json:
|
|
237
|
+
return self._t.request("GET", "/botbuilder/info")
|
|
238
|
+
|
|
239
|
+
def list(self) -> List[Json]:
|
|
240
|
+
return self._t.request("GET", "/botbuilder/bots")
|
|
241
|
+
|
|
242
|
+
def get(self, bot_id: Id) -> Json:
|
|
243
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}")
|
|
244
|
+
|
|
245
|
+
def stats(self, bot_id: Id) -> Json:
|
|
246
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}/stats")
|
|
247
|
+
|
|
248
|
+
def subscribers(self, bot_id: Id, take: Optional[int] = None, before: Optional[Id] = None) -> List[Json]:
|
|
249
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}/subscribers",
|
|
250
|
+
query={"take": take, "before": None if before is None else str(before)})
|
|
251
|
+
|
|
252
|
+
def messages(self, bot_id: Id, take: Optional[int] = None, before: Optional[Id] = None) -> List[Json]:
|
|
253
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}/messages",
|
|
254
|
+
query={"take": take, "before": None if before is None else str(before)})
|
|
255
|
+
|
|
256
|
+
def send(self, bot_id: Id, to: Mapping[str, Any], message: Mapping[str, Any], idempotency_key: Optional[str] = None) -> Json:
|
|
257
|
+
return self._t.request("POST", f"/botbuilder/bots/{_e(bot_id)}/send", headers=_idem(idempotency_key),
|
|
258
|
+
body={"to": dict(to), "message": dict(message)})
|
|
259
|
+
|
|
260
|
+
def otp(self, bot_id: Id, to: Mapping[str, Any], length: Optional[int] = None, template: Optional[str] = None,
|
|
261
|
+
idempotency_key: Optional[str] = None) -> Json:
|
|
262
|
+
return self._t.request("POST", f"/botbuilder/bots/{_e(bot_id)}/otp", headers=_idem(idempotency_key),
|
|
263
|
+
body={"to": dict(to), "length": length, "template": template})
|
|
264
|
+
|
|
265
|
+
def shop_orders(self, bot_id: Id, status: Optional[str] = None) -> List[Json]:
|
|
266
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}/shop/orders", query={"status": status})
|
|
267
|
+
|
|
268
|
+
def shop_stats(self, bot_id: Id) -> Json:
|
|
269
|
+
return self._t.request("GET", f"/botbuilder/bots/{_e(bot_id)}/shop/stats")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Broker(_Resource):
|
|
273
|
+
"""Telegram broker: register a bot token, get a private relay link, call the Bot API and pull updates through it."""
|
|
274
|
+
|
|
275
|
+
def list(self) -> List[Json]:
|
|
276
|
+
return self._t.request("GET", "/broker/bots")
|
|
277
|
+
|
|
278
|
+
def get(self, id: Id) -> Json:
|
|
279
|
+
return self._t.request("GET", f"/broker/bots/{_e(id)}")
|
|
280
|
+
|
|
281
|
+
def create(self, label: str, token: str) -> Json:
|
|
282
|
+
"""Scope ``broker.write``, account owner only. The token is never returned afterwards; the response carries the relay ``link``."""
|
|
283
|
+
return self._t.request("POST", "/broker/bots", body={"label": label, "token": token})
|
|
284
|
+
|
|
285
|
+
def rename(self, id: Id, label: str) -> None:
|
|
286
|
+
self._t.request("PATCH", f"/broker/bots/{_e(id)}", body={"label": label})
|
|
287
|
+
|
|
288
|
+
def replace_token(self, id: Id, token: str) -> Json:
|
|
289
|
+
"""Swap in a regenerated BotFather token. Clears the return webhook."""
|
|
290
|
+
return self._t.request("PUT", f"/broker/bots/{_e(id)}/token", body={"token": token})
|
|
291
|
+
|
|
292
|
+
def rotate(self, id: Id) -> Json:
|
|
293
|
+
"""New relay link; the previous one stops working."""
|
|
294
|
+
return self._t.request("POST", f"/broker/bots/{_e(id)}/rotate")
|
|
295
|
+
|
|
296
|
+
def delete(self, id: Id) -> None:
|
|
297
|
+
self._t.request("DELETE", f"/broker/bots/{_e(id)}")
|
|
298
|
+
|
|
299
|
+
def request(self, id: Id, method: str, params: Optional[Mapping[str, Any]] = None) -> Json:
|
|
300
|
+
"""Scope ``broker.request``: call any Telegram Bot API method. Returns ``{status, response}``."""
|
|
301
|
+
return self._t.request("POST", f"/broker/bots/{_e(id)}/request", body={"method": method, "params": dict(params or {})})
|
|
302
|
+
|
|
303
|
+
def updates(self, id: Id, offset: Optional[int] = None, limit: Optional[int] = None, timeout: Optional[int] = None,
|
|
304
|
+
allowed_updates: Optional[List[str]] = None) -> Json:
|
|
305
|
+
"""Scope ``broker.request``: pull updates (long-poll ``timeout`` <= 25 s)."""
|
|
306
|
+
return self._t.request("POST", f"/broker/bots/{_e(id)}/updates",
|
|
307
|
+
body={"offset": offset, "limit": limit, "timeout": timeout, "allowedUpdates": allowed_updates})
|
|
308
|
+
|
|
309
|
+
def webhook(self, id: Id) -> Json:
|
|
310
|
+
return self._t.request("GET", f"/broker/bots/{_e(id)}/webhook")
|
|
311
|
+
|
|
312
|
+
def set_webhook(self, id: Id, url: str, secret_token: Optional[str] = None, allowed_updates: Optional[List[str]] = None,
|
|
313
|
+
drop_pending_updates: Optional[bool] = None) -> Json:
|
|
314
|
+
return self._t.request("PUT", f"/broker/bots/{_e(id)}/webhook",
|
|
315
|
+
body={"url": url, "secretToken": secret_token, "allowedUpdates": allowed_updates,
|
|
316
|
+
"dropPendingUpdates": drop_pending_updates})
|
|
317
|
+
|
|
318
|
+
def delete_webhook(self, id: Id, drop_pending_updates: Optional[bool] = None) -> Json:
|
|
319
|
+
return self._t.request("DELETE", f"/broker/bots/{_e(id)}/webhook", body={"dropPendingUpdates": drop_pending_updates})
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class Voip(_Resource):
|
|
323
|
+
def calls(self) -> List[Json]:
|
|
324
|
+
return self._t.request("GET", "/voip/calls")
|
|
325
|
+
|
|
326
|
+
def price(self) -> Json:
|
|
327
|
+
return self._t.request("GET", "/voip/price")
|
|
328
|
+
|
|
329
|
+
def call(self, from_: str, to: str) -> Json:
|
|
330
|
+
return self._t.request("POST", "/voip/calls", body={"from": from_, "to": to})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Union
|
|
9
|
+
|
|
10
|
+
_SIG = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def verify_webhook(raw_body: Union[str, bytes], signature_header: str, secret: str, tolerance: int = 300) -> bool:
|
|
14
|
+
"""Check ``X-Irnoti-Signature`` (``t=<unix>,v1=<hex>``): HMAC-SHA256 of ``"<t>.<raw body>"`` with your ``whsec_`` key.
|
|
15
|
+
|
|
16
|
+
Pass the RAW request body, not re-serialised JSON.
|
|
17
|
+
"""
|
|
18
|
+
m = _SIG.match(signature_header or "")
|
|
19
|
+
if not m:
|
|
20
|
+
return False
|
|
21
|
+
if abs(time.time() - int(m.group(1))) > tolerance:
|
|
22
|
+
return False
|
|
23
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
24
|
+
expected = hmac.new(secret.encode(), m.group(1).encode() + b"." + body, hashlib.sha256).hexdigest()
|
|
25
|
+
return hmac.compare_digest(expected, m.group(2))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_webhook(raw_body: Union[str, bytes], signature_header: str, secret: str, tolerance: int = 300) -> dict[str, Any]:
|
|
29
|
+
"""Verify and decode; raises ``ValueError`` on a bad signature."""
|
|
30
|
+
if not verify_webhook(raw_body, signature_header, secret, tolerance):
|
|
31
|
+
raise ValueError("Invalid irnoti webhook signature")
|
|
32
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from irnoti import Irnoti, IrnotiApiError, parse_webhook, verify_webhook
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def make(status=200, payload=None):
|
|
14
|
+
calls = []
|
|
15
|
+
|
|
16
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
17
|
+
calls.append(req)
|
|
18
|
+
return httpx.Response(status, json=payload) if payload is not None else httpx.Response(status)
|
|
19
|
+
|
|
20
|
+
api = Irnoti("irn_test_x", base_url="https://irnoti.test/", retries=0,
|
|
21
|
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
|
22
|
+
return api, calls
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_send_builds_request():
|
|
26
|
+
api, calls = make(201, {"batchId": "1", "count": 2, "totalPrice": "1960", "replayed": False})
|
|
27
|
+
r = api.sms.send(1, "hi", to=["0912"], group_ids=[12], idempotency_key="k1",
|
|
28
|
+
scheduled_at=datetime(2026, 10, 20, 9, tzinfo=timezone.utc))
|
|
29
|
+
assert r["totalPrice"] == "1960"
|
|
30
|
+
req = calls[0]
|
|
31
|
+
assert str(req.url) == "https://irnoti.test/v1/sms/send"
|
|
32
|
+
assert req.headers["authorization"] == "Bearer irn_test_x"
|
|
33
|
+
assert req.headers["idempotency-key"] == "k1"
|
|
34
|
+
assert json.loads(req.content) == {"lineId": "1", "to": ["0912"], "groupIds": ["12"], "text": "hi",
|
|
35
|
+
"scheduledAt": "2026-10-20T09:00:00+00:00"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_send_omits_line_id_when_not_given():
|
|
39
|
+
api, calls = make(201, {"batchId": "1", "count": 1, "totalPrice": "980", "replayed": False})
|
|
40
|
+
api.sms.send(text="hi", to="0912")
|
|
41
|
+
assert json.loads(calls[0].content) == {"to": "0912", "text": "hi"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_query_skips_none_and_pattern_params_object():
|
|
45
|
+
api, calls = make(200, [])
|
|
46
|
+
api.messages.list(take=2, before=90)
|
|
47
|
+
assert str(calls[0].url) == "https://irnoti.test/v1/sms/messages?take=2&before=90"
|
|
48
|
+
api.bots.shop_orders(5)
|
|
49
|
+
assert str(calls[1].url) == "https://irnoti.test/v1/botbuilder/bots/5/shop/orders"
|
|
50
|
+
api.sms.pattern("k3f9a2", "0912")
|
|
51
|
+
assert json.loads(calls[2].content)["params"] == {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_errors():
|
|
55
|
+
api, _ = make(400, {"statusCode": 400, "code": "invalid_recipients", "details": ["0912"]})
|
|
56
|
+
with pytest.raises(IrnotiApiError) as ei:
|
|
57
|
+
api.sms.send(1, "x", to="0912")
|
|
58
|
+
e = ei.value
|
|
59
|
+
assert (e.status, e.code, e.details) == (400, "invalid_recipients", ["0912"])
|
|
60
|
+
assert e.is_invalid_recipients and e.is_validation_error
|
|
61
|
+
|
|
62
|
+
api, _ = make(402, {"statusCode": 402, "code": "insufficient_balance", "message": ["Not", "enough"]})
|
|
63
|
+
with pytest.raises(IrnotiApiError) as ei:
|
|
64
|
+
api.wallet.balance()
|
|
65
|
+
assert ei.value.is_insufficient_balance and str(ei.value) == "Not enough"
|
|
66
|
+
|
|
67
|
+
api, _ = make(401, {"statusCode": 401, "message": "Invalid API key"})
|
|
68
|
+
with pytest.raises(IrnotiApiError) as ei:
|
|
69
|
+
api.lines.list()
|
|
70
|
+
assert ei.value.is_unauthenticated and ei.value.code == ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_204_and_csv_multipart():
|
|
74
|
+
api, calls = make(204)
|
|
75
|
+
assert api.batches.pause(7) is None
|
|
76
|
+
assert str(calls[0].url) == "https://irnoti.test/v1/sms/batches/7/pause"
|
|
77
|
+
api, calls = make(200, {"added": 1, "duplicates": 0, "invalid": 0})
|
|
78
|
+
assert api.phonebook.import_csv(12, "0912,Ali\n")["added"] == 1
|
|
79
|
+
assert calls[0].headers["content-type"].startswith("multipart/form-data")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_with_api_key():
|
|
83
|
+
api, _ = make()
|
|
84
|
+
assert api.with_api_key("irn_live_y") is not api
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_webhook():
|
|
88
|
+
body = '{"id":"1","event":"message.delivered","createdAt":"x","data":{}}'
|
|
89
|
+
|
|
90
|
+
def sign(t, b=body):
|
|
91
|
+
return f"t={t},v1=" + hmac.new(b"whsec", f"{t}.{b}".encode(), hashlib.sha256).hexdigest()
|
|
92
|
+
|
|
93
|
+
now = int(time.time())
|
|
94
|
+
assert verify_webhook(body, sign(now), "whsec")
|
|
95
|
+
assert parse_webhook(body.encode(), sign(now), "whsec")["event"] == "message.delivered"
|
|
96
|
+
assert not verify_webhook(body + " ", sign(now), "whsec")
|
|
97
|
+
assert not verify_webhook(body, sign(now - 3600), "whsec")
|
|
98
|
+
assert not verify_webhook(body, "junk", "whsec")
|
|
99
|
+
with pytest.raises(ValueError):
|
|
100
|
+
parse_webhook(body, "junk", "whsec")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_broker_routes_and_bodies():
|
|
104
|
+
api, calls = make(200, {})
|
|
105
|
+
api.broker.request(7, "sendMessage", {"chat_id": 1, "text": "hi"})
|
|
106
|
+
api.broker.updates(7, limit=10, allowed_updates=["message"])
|
|
107
|
+
api.broker.set_webhook(7, "https://example.com/hook", secret_token="s")
|
|
108
|
+
api.broker.delete_webhook(7, True)
|
|
109
|
+
api.broker.replace_token(7, "123456:ABCDEF-token")
|
|
110
|
+
assert [(c.method, c.url.path) for c in calls] == [
|
|
111
|
+
("POST", "/v1/broker/bots/7/request"), ("POST", "/v1/broker/bots/7/updates"), ("PUT", "/v1/broker/bots/7/webhook"),
|
|
112
|
+
("DELETE", "/v1/broker/bots/7/webhook"), ("PUT", "/v1/broker/bots/7/token"),
|
|
113
|
+
]
|
|
114
|
+
assert json.loads(calls[0].content) == {"method": "sendMessage", "params": {"chat_id": 1, "text": "hi"}}
|
|
115
|
+
assert json.loads(calls[1].content) == {"limit": 10, "allowedUpdates": ["message"]}
|
|
116
|
+
assert json.loads(calls[3].content) == {"dropPendingUpdates": True}
|