nilva-django-sms 0.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.
Files changed (37) hide show
  1. nilva_django_sms-0.2.0/LICENSE +21 -0
  2. nilva_django_sms-0.2.0/PKG-INFO +160 -0
  3. nilva_django_sms-0.2.0/README.md +136 -0
  4. nilva_django_sms-0.2.0/nilva_django_sms/__init__.py +52 -0
  5. nilva_django_sms-0.2.0/nilva_django_sms/api.py +45 -0
  6. nilva_django_sms-0.2.0/nilva_django_sms/base.py +67 -0
  7. nilva_django_sms-0.2.0/nilva_django_sms/engines/__init__.py +0 -0
  8. nilva_django_sms-0.2.0/nilva_django_sms/engines/console.py +23 -0
  9. nilva_django_sms-0.2.0/nilva_django_sms/engines/kavenegar.py +61 -0
  10. nilva_django_sms-0.2.0/nilva_django_sms/engines/magfa.py +51 -0
  11. nilva_django_sms-0.2.0/nilva_django_sms/engines/memory.py +38 -0
  12. nilva_django_sms-0.2.0/nilva_django_sms/engines/pardis.py +141 -0
  13. nilva_django_sms-0.2.0/nilva_django_sms/engines/pishgam.py +88 -0
  14. nilva_django_sms-0.2.0/nilva_django_sms/exceptions.py +31 -0
  15. nilva_django_sms-0.2.0/nilva_django_sms/kavenegar_sdk.py +172 -0
  16. nilva_django_sms-0.2.0/nilva_django_sms/otp.py +89 -0
  17. nilva_django_sms-0.2.0/nilva_django_sms/registry.py +43 -0
  18. nilva_django_sms-0.2.0/nilva_django_sms/settings.py +157 -0
  19. nilva_django_sms-0.2.0/nilva_django_sms/tasks.py +70 -0
  20. nilva_django_sms-0.2.0/nilva_django_sms.egg-info/PKG-INFO +160 -0
  21. nilva_django_sms-0.2.0/nilva_django_sms.egg-info/SOURCES.txt +35 -0
  22. nilva_django_sms-0.2.0/nilva_django_sms.egg-info/dependency_links.txt +1 -0
  23. nilva_django_sms-0.2.0/nilva_django_sms.egg-info/requires.txt +7 -0
  24. nilva_django_sms-0.2.0/nilva_django_sms.egg-info/top_level.txt +1 -0
  25. nilva_django_sms-0.2.0/pyproject.toml +63 -0
  26. nilva_django_sms-0.2.0/setup.cfg +4 -0
  27. nilva_django_sms-0.2.0/tests/test_api.py +44 -0
  28. nilva_django_sms-0.2.0/tests/test_engines_basic.py +28 -0
  29. nilva_django_sms-0.2.0/tests/test_kavenegar.py +91 -0
  30. nilva_django_sms-0.2.0/tests/test_magfa.py +35 -0
  31. nilva_django_sms-0.2.0/tests/test_otp.py +84 -0
  32. nilva_django_sms-0.2.0/tests/test_package.py +25 -0
  33. nilva_django_sms-0.2.0/tests/test_pardis.py +94 -0
  34. nilva_django_sms-0.2.0/tests/test_pishgam.py +84 -0
  35. nilva_django_sms-0.2.0/tests/test_registry.py +41 -0
  36. nilva_django_sms-0.2.0/tests/test_settings.py +34 -0
  37. nilva_django_sms-0.2.0/tests/test_tasks.py +22 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Nilva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: nilva-django-sms
3
+ Version: 0.2.0
4
+ Summary: Nilva house SMS/OTP toolkit — one engine interface over Pishgam, Magfa, Kavenegar and MCI Pardis with fallback chains, typed errors, cooldown-throttled OTP and Celery delivery
5
+ Author-email: Nilva <dev@nilva.ai>
6
+ License-Expression: MIT
7
+ Project-URL: homepage, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-sms
8
+ Project-URL: repository, https://gitlab.nilva.ai/nilva/dev-assets/django/nilva-django-sms
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Topic :: Communications :: Telephony
15
+ Requires-Python: >=3.14
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.32
19
+ Provides-Extra: django
20
+ Requires-Dist: django>=6.0; extra == "django"
21
+ Provides-Extra: celery
22
+ Requires-Dist: celery>=5.4; extra == "celery"
23
+ Dynamic: license-file
24
+
25
+ # nilva-django-sms
26
+
27
+ Nilva's house SMS / OTP toolkit for Django and plain-Python services. One
28
+ engine interface over every provider used across the Nilva backends —
29
+ **Pishgam/Pishgaman Rayan, Magfa, Kavenegar, MCI Pardis** — plus **console**
30
+ and **memory** engines for development and tests. Replaces the copy-pasted
31
+ `SMSService` / `OTPService` clients that had drifted across Cheshmeh, Etka,
32
+ Selfcare, Ghadir, Shop and Tiran.
33
+
34
+ What it standardizes:
35
+
36
+ * **Typed errors** — `SMSTransientError` (retry can help) vs
37
+ `SMSPermanentError` (it can't); Celery retry policies key off the split.
38
+ * **Fallback chains** — primary engine plus ordered fallbacks.
39
+ * **OTP cooldown throttling** — previously only Tiran had it; now default.
40
+ * **Success normalization** — no `statusCode = 32` hacks leaking to callers.
41
+ * **One vendored Kavenegar SDK** (`nilva_django_sms.kavenegar_sdk`, the
42
+ timeout/proxy-patched fork) instead of a copy per repo.
43
+
44
+ Importing the package pulls neither Django, `requests` nor Celery — engines
45
+ and the OTP service load lazily. Only `requests` is a hard install
46
+ dependency.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ uv add nilva-django-sms # engines + registry + facade
52
+ uv add "nilva-django-sms[django]" # + OTP service (Django cache)
53
+ uv add "nilva-django-sms[celery]" # + queued delivery with retries
54
+ ```
55
+
56
+ ## Configure — `NILVA_SMS`
57
+
58
+ ```python
59
+ # settings.py
60
+ NILVA_SMS = {
61
+ "ENGINE": "pishgam", # default engine ("console" if unset)
62
+ "FALLBACK_ENGINES": ("magfa",), # tried in order when pishgam raises
63
+ "PROXY": SETTINGS.SOCKS5_PROXY, # shared by all HTTP engines
64
+ "TIMEOUT": 5.0,
65
+
66
+ "PISHGAM": {
67
+ "GATEWAY_URL": "https://gw.pishgamrayan.com",
68
+ "TOKEN": "...", "GATEWAY_TOKEN": "...",
69
+ "SENDER": "3000...", "OTP_ID": 123,
70
+ # OTP template parameters; "{code}" is substituted:
71
+ "OTP_PARAMS": ("myapp", "{code}", "myapp.ir"),
72
+ },
73
+ "MAGFA": {"USERNAME": "...", "DOMAIN": "...", "PASSWORD": "...", "SENDER": "9800..."},
74
+ "KAVENEGAR": {"API_KEY": "...", "OTP_TEMPLATE": "login-otp"},
75
+ "PARDIS": {"BASE_URL": "...", "AUTH_HOST": "...", "BULK_HOST": "...",
76
+ "CLIENT_ID": "...", "USERNAME": "...", "PASSWORD": "...", "SOURCE": "98..."},
77
+
78
+ # OTP service
79
+ "OTP_LENGTH": 6,
80
+ "OTP_TTL": 300,
81
+ "OTP_COOLDOWN": 60,
82
+ "OTP_STATIC_CODE": None, # staging bypass code; None disables
83
+
84
+ # Celery delivery
85
+ "USE_CELERY": True,
86
+ "CELERY_QUEUE": "low",
87
+ }
88
+ ```
89
+
90
+ Engine blocks merge over their defaults, so set only what you have. Every
91
+ setting is read through `nilva_django_sms.settings.sms_settings` (the
92
+ `nilva-django-logger` settings-object pattern) and works without Django too.
93
+
94
+ ## Send
95
+
96
+ ```python
97
+ from nilva_django_sms import send_sms, send_otp
98
+
99
+ send_sms("پیام شما", "09123456789") # walks ENGINE + fallbacks
100
+ send_otp("123456", "09123456789") # OTP_ENGINE (or ENGINE) first
101
+ send_sms("...", "0912...", engine="kavenegar") # explicit engine
102
+ send_sms("...", "0912...", fail_silently=True) # SendResult(success=False) instead of raising
103
+ ```
104
+
105
+ Queued delivery with transient-error retries (backoff + jitter, 4 attempts):
106
+
107
+ ```python
108
+ from nilva_django_sms.tasks import queue_send_sms, queue_send_otp
109
+ queue_send_sms("پیام", "09123456789", delay=10) # sync fallback without Celery
110
+ ```
111
+
112
+ ## OTP
113
+
114
+ ```python
115
+ from nilva_django_sms.otp import otp_service
116
+
117
+ otp_service.send("09123456789") # generate + cache + deliver
118
+ # raises OTPCooldownError(retry_after=…) inside the cooldown window
119
+
120
+ otp_service.verify("09123456789", "۱۲۳۴۵۶") # True once — single-use,
121
+ # Persian/Arabic digits accepted
122
+ ```
123
+
124
+ Codes are `secrets`-random, stored in the Django cache
125
+ (`OTP_CACHE_ALIAS`/`OTP_CACHE_PREFIX`), consumed on first successful verify.
126
+ `OTP_STATIC_CODE` enables the staging bypass the projects hacked in
127
+ individually.
128
+
129
+ ## Engines
130
+
131
+ | name | provider | OTP mechanism |
132
+ |-------------|-------------------------|------------------------------------------------|
133
+ | `pishgam` | Pishgam / Pishgaman | `/Messages/SendOtp` template, or gateway `/Authenticate/Send` with `USE_GATEWAY_OTP` |
134
+ | `magfa` | Magfa v2 HTTP | plain send with `OTP_MESSAGE` template |
135
+ | `kavenegar` | Kavenegar | `verify_lookup` template (`url=` → `token2`); `HOST`/`VERIFY_SSL` support the kavenegar.nilva.ai mirror |
136
+ | `pardis` | MCI Bulk Sender P2P | plain send with `OTP_MESSAGE` (OAuth token cached) |
137
+ | `console` | logs only (default) | logs the code |
138
+ | `memory` | in-process outbox | for tests; `{"FAIL": True}` to exercise fallbacks |
139
+
140
+ Project-specific engines: subclass `nilva_django_sms.SmsEngine` and register
141
+ via `NILVA_SMS["EXTRA_ENGINES"] = {"myengine": "myapp.sms.MyEngine"}`.
142
+
143
+ ## Testing your app
144
+
145
+ ```python
146
+ from nilva_django_sms.engines.memory import MemoryEngine
147
+
148
+ @override_settings(NILVA_SMS={"ENGINE": "memory"})
149
+ def test_signup_sends_otp(self):
150
+ ...
151
+ kind, recipient, code = MemoryEngine.outbox[-1]
152
+ ```
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ uv sync --group dev
158
+ ./run_tests.sh # ./run_tests.sh -c for coverage
159
+ uv run ruff check .
160
+ ```
@@ -0,0 +1,136 @@
1
+ # nilva-django-sms
2
+
3
+ Nilva's house SMS / OTP toolkit for Django and plain-Python services. One
4
+ engine interface over every provider used across the Nilva backends —
5
+ **Pishgam/Pishgaman Rayan, Magfa, Kavenegar, MCI Pardis** — plus **console**
6
+ and **memory** engines for development and tests. Replaces the copy-pasted
7
+ `SMSService` / `OTPService` clients that had drifted across Cheshmeh, Etka,
8
+ Selfcare, Ghadir, Shop and Tiran.
9
+
10
+ What it standardizes:
11
+
12
+ * **Typed errors** — `SMSTransientError` (retry can help) vs
13
+ `SMSPermanentError` (it can't); Celery retry policies key off the split.
14
+ * **Fallback chains** — primary engine plus ordered fallbacks.
15
+ * **OTP cooldown throttling** — previously only Tiran had it; now default.
16
+ * **Success normalization** — no `statusCode = 32` hacks leaking to callers.
17
+ * **One vendored Kavenegar SDK** (`nilva_django_sms.kavenegar_sdk`, the
18
+ timeout/proxy-patched fork) instead of a copy per repo.
19
+
20
+ Importing the package pulls neither Django, `requests` nor Celery — engines
21
+ and the OTP service load lazily. Only `requests` is a hard install
22
+ dependency.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv add nilva-django-sms # engines + registry + facade
28
+ uv add "nilva-django-sms[django]" # + OTP service (Django cache)
29
+ uv add "nilva-django-sms[celery]" # + queued delivery with retries
30
+ ```
31
+
32
+ ## Configure — `NILVA_SMS`
33
+
34
+ ```python
35
+ # settings.py
36
+ NILVA_SMS = {
37
+ "ENGINE": "pishgam", # default engine ("console" if unset)
38
+ "FALLBACK_ENGINES": ("magfa",), # tried in order when pishgam raises
39
+ "PROXY": SETTINGS.SOCKS5_PROXY, # shared by all HTTP engines
40
+ "TIMEOUT": 5.0,
41
+
42
+ "PISHGAM": {
43
+ "GATEWAY_URL": "https://gw.pishgamrayan.com",
44
+ "TOKEN": "...", "GATEWAY_TOKEN": "...",
45
+ "SENDER": "3000...", "OTP_ID": 123,
46
+ # OTP template parameters; "{code}" is substituted:
47
+ "OTP_PARAMS": ("myapp", "{code}", "myapp.ir"),
48
+ },
49
+ "MAGFA": {"USERNAME": "...", "DOMAIN": "...", "PASSWORD": "...", "SENDER": "9800..."},
50
+ "KAVENEGAR": {"API_KEY": "...", "OTP_TEMPLATE": "login-otp"},
51
+ "PARDIS": {"BASE_URL": "...", "AUTH_HOST": "...", "BULK_HOST": "...",
52
+ "CLIENT_ID": "...", "USERNAME": "...", "PASSWORD": "...", "SOURCE": "98..."},
53
+
54
+ # OTP service
55
+ "OTP_LENGTH": 6,
56
+ "OTP_TTL": 300,
57
+ "OTP_COOLDOWN": 60,
58
+ "OTP_STATIC_CODE": None, # staging bypass code; None disables
59
+
60
+ # Celery delivery
61
+ "USE_CELERY": True,
62
+ "CELERY_QUEUE": "low",
63
+ }
64
+ ```
65
+
66
+ Engine blocks merge over their defaults, so set only what you have. Every
67
+ setting is read through `nilva_django_sms.settings.sms_settings` (the
68
+ `nilva-django-logger` settings-object pattern) and works without Django too.
69
+
70
+ ## Send
71
+
72
+ ```python
73
+ from nilva_django_sms import send_sms, send_otp
74
+
75
+ send_sms("پیام شما", "09123456789") # walks ENGINE + fallbacks
76
+ send_otp("123456", "09123456789") # OTP_ENGINE (or ENGINE) first
77
+ send_sms("...", "0912...", engine="kavenegar") # explicit engine
78
+ send_sms("...", "0912...", fail_silently=True) # SendResult(success=False) instead of raising
79
+ ```
80
+
81
+ Queued delivery with transient-error retries (backoff + jitter, 4 attempts):
82
+
83
+ ```python
84
+ from nilva_django_sms.tasks import queue_send_sms, queue_send_otp
85
+ queue_send_sms("پیام", "09123456789", delay=10) # sync fallback without Celery
86
+ ```
87
+
88
+ ## OTP
89
+
90
+ ```python
91
+ from nilva_django_sms.otp import otp_service
92
+
93
+ otp_service.send("09123456789") # generate + cache + deliver
94
+ # raises OTPCooldownError(retry_after=…) inside the cooldown window
95
+
96
+ otp_service.verify("09123456789", "۱۲۳۴۵۶") # True once — single-use,
97
+ # Persian/Arabic digits accepted
98
+ ```
99
+
100
+ Codes are `secrets`-random, stored in the Django cache
101
+ (`OTP_CACHE_ALIAS`/`OTP_CACHE_PREFIX`), consumed on first successful verify.
102
+ `OTP_STATIC_CODE` enables the staging bypass the projects hacked in
103
+ individually.
104
+
105
+ ## Engines
106
+
107
+ | name | provider | OTP mechanism |
108
+ |-------------|-------------------------|------------------------------------------------|
109
+ | `pishgam` | Pishgam / Pishgaman | `/Messages/SendOtp` template, or gateway `/Authenticate/Send` with `USE_GATEWAY_OTP` |
110
+ | `magfa` | Magfa v2 HTTP | plain send with `OTP_MESSAGE` template |
111
+ | `kavenegar` | Kavenegar | `verify_lookup` template (`url=` → `token2`); `HOST`/`VERIFY_SSL` support the kavenegar.nilva.ai mirror |
112
+ | `pardis` | MCI Bulk Sender P2P | plain send with `OTP_MESSAGE` (OAuth token cached) |
113
+ | `console` | logs only (default) | logs the code |
114
+ | `memory` | in-process outbox | for tests; `{"FAIL": True}` to exercise fallbacks |
115
+
116
+ Project-specific engines: subclass `nilva_django_sms.SmsEngine` and register
117
+ via `NILVA_SMS["EXTRA_ENGINES"] = {"myengine": "myapp.sms.MyEngine"}`.
118
+
119
+ ## Testing your app
120
+
121
+ ```python
122
+ from nilva_django_sms.engines.memory import MemoryEngine
123
+
124
+ @override_settings(NILVA_SMS={"ENGINE": "memory"})
125
+ def test_signup_sends_otp(self):
126
+ ...
127
+ kind, recipient, code = MemoryEngine.outbox[-1]
128
+ ```
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ uv sync --group dev
134
+ ./run_tests.sh # ./run_tests.sh -c for coverage
135
+ uv run ruff check .
136
+ ```
@@ -0,0 +1,52 @@
1
+ """Nilva Django SMS — the Nilva house SMS / OTP toolkit.
2
+
3
+ One engine interface over every SMS provider used across the Nilva backends
4
+ (Pishgam/Pishgaman, Magfa, Kavenegar, MCI Pardis) plus console and in-memory
5
+ engines for development and tests. Ships a registry with fallback chains,
6
+ typed transient/permanent errors, a cache-backed OTP service with cooldown
7
+ throttling, and optional Celery delivery.
8
+
9
+ Configuration follows the house ``NILVA_*`` settings-dict pattern (see
10
+ ``nilva_django_sms.settings``). Importing this package pulls neither Django
11
+ nor ``requests`` — engines and the OTP service load lazily.
12
+ """
13
+
14
+ __version__ = "0.2.0"
15
+
16
+ from .base import SendResult, SmsEngine
17
+ from .exceptions import (
18
+ OTPCooldownError,
19
+ SMSConfigurationError,
20
+ SMSError,
21
+ SMSPermanentError,
22
+ SMSTransientError,
23
+ )
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "SendResult",
28
+ "SmsEngine",
29
+ "SMSError",
30
+ "SMSConfigurationError",
31
+ "SMSPermanentError",
32
+ "SMSTransientError",
33
+ "OTPCooldownError",
34
+ "get_engine",
35
+ "send_sms",
36
+ "send_otp",
37
+ ]
38
+
39
+ _LAZY = {
40
+ "get_engine": "registry",
41
+ "send_sms": "api",
42
+ "send_otp": "api",
43
+ }
44
+
45
+
46
+ def __getattr__(name):
47
+ module_name = _LAZY.get(name)
48
+ if module_name is None:
49
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
50
+ from importlib import import_module
51
+
52
+ return getattr(import_module(f".{module_name}", __name__), name)
@@ -0,0 +1,45 @@
1
+ """Send facade — walks the engine fallback chain.
2
+
3
+ Each engine in the chain is tried in order; the last engine's error
4
+ propagates when all fail (with every attempt logged), preserving the
5
+ transient/permanent type for retry policies. ``fail_silently=True`` returns a
6
+ failed :class:`SendResult` instead of raising.
7
+ """
8
+
9
+ import logging
10
+
11
+ from .base import SendResult
12
+ from .exceptions import SMSError
13
+ from .registry import engine_chain, get_engine
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _send(method: str, args: tuple, kwargs: dict, engine: str | None, fail_silently: bool, for_otp: bool):
19
+ errors = []
20
+ for name in engine_chain(engine, for_otp=for_otp):
21
+ try:
22
+ result = getattr(get_engine(name), method)(*args, **kwargs)
23
+ if errors:
24
+ logger.warning("sms sent via fallback engine", extra={"engine": name, "failed": len(errors)})
25
+ return result
26
+ except SMSError as exc:
27
+ logger.error("sms engine failed", extra={"engine": name, "error_info": str(exc)})
28
+ errors.append((name, exc))
29
+ if fail_silently:
30
+ return SendResult(success=False, raw={"errors": [(n, str(e)) for n, e in errors]})
31
+ raise errors[-1][1]
32
+
33
+
34
+ def send_sms(message: str, recipient: str, engine: str | None = None, fail_silently: bool = False) -> SendResult:
35
+ """Send a plain SMS through the configured engine chain."""
36
+ return _send("send_sms", (message, recipient), {}, engine, fail_silently, for_otp=False)
37
+
38
+
39
+ def send_otp(code: str, recipient: str, engine: str | None = None, fail_silently: bool = False, **kwargs) -> SendResult:
40
+ """Send an OTP through the configured engine chain (``OTP_ENGINE`` first).
41
+
42
+ Extra ``kwargs`` reach the engine (e.g. Kavenegar's ``url`` second token,
43
+ Pishgam's ``is_dashboard``-style template switches via ``otp_id``).
44
+ """
45
+ return _send("send_otp", (code, recipient), kwargs, engine, fail_silently, for_otp=True)
@@ -0,0 +1,67 @@
1
+ """Engine interface (Cheshmeh's ``SmsEngine`` ABC, made config-injectable).
2
+
3
+ An engine either returns a :class:`SendResult` or raises a typed error from
4
+ :mod:`nilva_django_sms.exceptions` — success is never signalled by a magic
5
+ provider status code leaking to the caller.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass
14
+ class SendResult:
15
+ success: bool = True
16
+ engine: str = ""
17
+ raw: Any = None
18
+ message_ids: list = field(default_factory=list)
19
+
20
+
21
+ class SmsEngine(ABC):
22
+ """Base engine. ``config`` defaults to the engine's block in ``NILVA_SMS``
23
+ (e.g. ``NILVA_SMS["PISHGAM"]``); pass a dict explicitly in tests."""
24
+
25
+ #: registry key and the ``NILVA_SMS`` block name (upper-cased).
26
+ name = ""
27
+
28
+ def __init__(self, config: dict | None = None):
29
+ from .settings import sms_settings
30
+
31
+ if config is None:
32
+ block = self.name.upper()
33
+ # Engines outside DEFAULTS (memory, EXTRA_ENGINES) still get their
34
+ # NILVA_SMS[block] dict.
35
+ config = getattr(sms_settings, block, None) or sms_settings.user_settings.get(block) or {}
36
+ self.config = dict(config)
37
+ self.proxy = self.config.get("PROXY", sms_settings.PROXY)
38
+ self.timeout = self.config.get("TIMEOUT", sms_settings.TIMEOUT)
39
+
40
+ @property
41
+ def proxies(self) -> dict | None:
42
+ if not self.proxy:
43
+ return None
44
+ return {"http": self.proxy, "https": self.proxy}
45
+
46
+ def require(self, key: str):
47
+ """Config value or :class:`SMSConfigurationError` naming the setting."""
48
+ value = self.config.get(key)
49
+ if value in (None, ""):
50
+ from .exceptions import SMSConfigurationError
51
+
52
+ raise SMSConfigurationError(f"NILVA_SMS[{self.name.upper()!r}][{key!r}] is required for {self.name}")
53
+ return value
54
+
55
+ def otp_message(self, code: str) -> str:
56
+ """Plain-text OTP body for providers without OTP templates."""
57
+ from .settings import sms_settings
58
+
59
+ return str(sms_settings.OTP_MESSAGE).format(code=code)
60
+
61
+ @abstractmethod
62
+ def send_sms(self, message: str, recipient: str) -> SendResult:
63
+ raise NotImplementedError
64
+
65
+ @abstractmethod
66
+ def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
67
+ raise NotImplementedError
@@ -0,0 +1,23 @@
1
+ """Development engine: logs instead of sending (Tiran's console mode).
2
+
3
+ The default engine, so a fresh project never sends real SMS until it
4
+ explicitly configures a provider.
5
+ """
6
+
7
+ import logging
8
+
9
+ from ..base import SendResult, SmsEngine
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ConsoleEngine(SmsEngine):
15
+ name = "console"
16
+
17
+ def send_sms(self, message: str, recipient: str) -> SendResult:
18
+ logger.info("SMS to %s: %s", recipient, message)
19
+ return SendResult(engine=self.name)
20
+
21
+ def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
22
+ logger.info("OTP for %s: %s", recipient, code)
23
+ return SendResult(engine=self.name)
@@ -0,0 +1,61 @@
1
+ """Kavenegar engine — wraps the vendored SDK (``nilva_django_sms.kavenegar_sdk``,
2
+ the timeout/proxy-patched copy Shop and Selfcare each vendored separately)
3
+ so the fleet ships exactly one blessed copy.
4
+
5
+ OTPs use ``verify_lookup`` with the configured template (``token``; an extra
6
+ ``url=`` kwarg becomes ``token2``, Shop's pattern); plain SMS uses
7
+ ``sms_send``.
8
+ """
9
+
10
+ import logging
11
+
12
+ from ..base import SendResult, SmsEngine
13
+ from ..exceptions import SMSPermanentError, SMSTransientError
14
+ from ..kavenegar_sdk import APIException, HTTPException, KavenegarAPI
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class KavenegarEngine(SmsEngine):
20
+ name = "kavenegar"
21
+
22
+ def __init__(self, config: dict | None = None):
23
+ super().__init__(config)
24
+ # HOST supports the kavenegar.nilva.ai mirror (InternetAccess pattern)
25
+ # when api.kavenegar.com is unreachable from the cluster; mirrors
26
+ # usually need VERIFY_SSL off.
27
+ self._api = KavenegarAPI(
28
+ self.require("API_KEY"),
29
+ timeout=self.timeout,
30
+ proxies=self.proxies,
31
+ host=self.config.get("HOST"),
32
+ verify=self.config.get("VERIFY_SSL", True),
33
+ )
34
+
35
+ def _call(self, method: str, params: dict) -> list:
36
+ try:
37
+ return getattr(self._api, method)(params)
38
+ except APIException as exc:
39
+ raise SMSPermanentError(f"kavenegar api error: {exc}") from exc
40
+ except HTTPException as exc:
41
+ raise SMSTransientError(f"kavenegar connection failure: {exc}") from exc
42
+
43
+ def _check_entry(self, entries: list) -> SendResult:
44
+ entry = entries[0] if entries else None
45
+ statuses = tuple(self.config.get("SUCCESS_STATUSES") or (5,))
46
+ if not entry or entry.get("status") not in statuses:
47
+ raise SMSPermanentError(f"kavenegar rejected send with status {(entry or {}).get('status')!r}")
48
+ return SendResult(engine=self.name, raw=entry, message_ids=[str(entry.get("messageid", ""))])
49
+
50
+ def send_sms(self, message: str, recipient: str) -> SendResult:
51
+ params = {"receptor": recipient, "message": message}
52
+ sender = self.config.get("SENDER")
53
+ if sender:
54
+ params["sender"] = sender
55
+ return self._check_entry(self._call("sms_send", params))
56
+
57
+ def send_otp(self, code: str, recipient: str, url: str | None = None, **kwargs) -> SendResult:
58
+ params = {"receptor": recipient, "template": self.require("OTP_TEMPLATE"), "token": str(code)}
59
+ if url:
60
+ params["token2"] = url
61
+ return self._check_entry(self._call("verify_lookup", params))
@@ -0,0 +1,51 @@
1
+ """Magfa engine (HTTP v2 send API, basic auth ``username/domain:password``).
2
+
3
+ Consolidates the ``MagfaSMSService`` copies in Cheshmeh, Etka, Selfcare and
4
+ Ghadir. Magfa has no OTP-template endpoint, so OTPs are plain sends using the
5
+ ``NILVA_SMS["OTP_MESSAGE"]`` template.
6
+ """
7
+
8
+ import logging
9
+
10
+ import requests
11
+
12
+ from ..base import SendResult, SmsEngine
13
+ from ..exceptions import SMSPermanentError, SMSTransientError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SUCCESS_STATUS = 0
18
+
19
+
20
+ class MagfaEngine(SmsEngine):
21
+ name = "magfa"
22
+
23
+ def send_sms(self, message: str, recipient: str) -> SendResult:
24
+ sender = self.require("SENDER")
25
+ payload = {"senders": [sender], "messages": [message], "recipients": [recipient]}
26
+ url = f"{str(self.require('SERVER_URL')).rstrip('/')}/http/sms/v2/send"
27
+ auth = (f"{self.require('USERNAME')}/{self.require('DOMAIN')}", self.require("PASSWORD"))
28
+ headers = {"accept": "application/json", "cache-control": "no-cache"}
29
+ try:
30
+ response = requests.post(
31
+ url, headers=headers, auth=auth, json=payload, proxies=self.proxies, timeout=self.timeout
32
+ )
33
+ except requests.Timeout as exc:
34
+ raise SMSTransientError("magfa request timed out") from exc
35
+ except requests.RequestException as exc:
36
+ raise SMSTransientError(f"magfa request failed: {exc}") from exc
37
+ if response.status_code >= 500:
38
+ raise SMSTransientError(f"magfa returned {response.status_code}: {response.text}")
39
+ if response.status_code >= 400:
40
+ raise SMSPermanentError(f"magfa rejected request with {response.status_code}: {response.text}")
41
+ try:
42
+ raw = response.json()
43
+ except ValueError as exc:
44
+ raise SMSTransientError(f"magfa returned a non-JSON response: {response.text}") from exc
45
+ if raw.get("status") != SUCCESS_STATUS:
46
+ raise SMSPermanentError(f"magfa rejected send with status {raw.get('status')!r}")
47
+ logger.info("magfa sms sent", extra={"engine": self.name})
48
+ return SendResult(engine=self.name, raw=raw)
49
+
50
+ def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
51
+ return self.send_sms(self.otp_message(code), recipient)
@@ -0,0 +1,38 @@
1
+ """Test engine: records every send in a class-level outbox.
2
+
3
+ Configure ``{"FAIL": True}`` (or ``{"FAIL": "transient"}``) to make it raise,
4
+ for exercising fallback chains and retry paths.
5
+ """
6
+
7
+ from ..base import SendResult, SmsEngine
8
+ from ..exceptions import SMSPermanentError, SMSTransientError
9
+
10
+
11
+ class MemoryEngine(SmsEngine):
12
+ name = "memory"
13
+
14
+ #: [(kind, recipient, payload), ...] — shared across instances, like
15
+ #: django.core.mail.outbox. Clear it between tests.
16
+ outbox: list[tuple[str, str, str]] = []
17
+
18
+ def _maybe_fail(self):
19
+ fail = self.config.get("FAIL")
20
+ if not fail:
21
+ return
22
+ if fail == "transient":
23
+ raise SMSTransientError("memory engine configured to fail (transient)")
24
+ raise SMSPermanentError("memory engine configured to fail")
25
+
26
+ def send_sms(self, message: str, recipient: str) -> SendResult:
27
+ self._maybe_fail()
28
+ self.outbox.append(("sms", recipient, message))
29
+ return SendResult(engine=self.name)
30
+
31
+ def send_otp(self, code: str, recipient: str, **kwargs) -> SendResult:
32
+ self._maybe_fail()
33
+ self.outbox.append(("otp", recipient, code))
34
+ return SendResult(engine=self.name)
35
+
36
+ @classmethod
37
+ def clear(cls):
38
+ cls.outbox.clear()