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.
@@ -0,0 +1,5 @@
1
+ __pycache__
2
+ *.egg-info
3
+ dist
4
+ .venv
5
+ .pytest_cache
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/irnoti.svg)](https://pypi.org/project/irnoti/) [![tests](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml/badge.svg)](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/irnoti.svg)](https://pypi.org/project/irnoti/) [![tests](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml/badge.svg)](https://github.com/irnoti/python-sms-panel-api/actions/workflows/tests.yml) [![license](https://img.shields.io/badge/license-MIT-blue.svg)](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,6 @@
1
+ from .client import Irnoti
2
+ from .errors import IrnotiApiError, IrnotiError
3
+ from .webhook import parse_webhook, verify_webhook
4
+
5
+ __all__ = ["Irnoti", "IrnotiApiError", "IrnotiError", "verify_webhook", "parse_webhook"]
6
+ __version__ = "1.2.0"
@@ -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}