sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,30 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
from django.conf import settings
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMSResult:
|
||||
success: bool
|
||||
provider_id: str = ""
|
||||
error: str = ""
|
||||
provider_code: str = ""
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMSDeliveryResult:
|
||||
status: str
|
||||
provider_code: str
|
||||
description: str
|
||||
is_final: bool = True
|
||||
def webotp_message(code):
|
||||
host=settings.WEBOTP_HOST or urlparse(settings.PUBLIC_BASE_URL).hostname
|
||||
if not host: raise ValueError("WEBOTP_HOST یا PUBLIC_BASE_URL معتبر تنظیم نشده است.")
|
||||
return f"کد شما: {code}\n@{host} #{code}"
|
||||
class SMSProvider(ABC):
|
||||
"""Stable application-facing contract for every SMS vendor."""
|
||||
def send_otp(self,mobile,code): return self.send_message(mobile,webotp_message(code))
|
||||
@abstractmethod
|
||||
def send_message(self,mobile,message): raise NotImplementedError
|
||||
|
||||
def get_delivery_status(self, provider_id):
|
||||
"""Return None when a provider does not support delivery tracking."""
|
||||
return None
|
||||
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
import zeep
|
||||
from django.conf import settings
|
||||
from zeep.helpers import serialize_object
|
||||
from .base import SMSProvider, SMSResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BehinSMSProvider(SMSProvider):
|
||||
"""SOAP adapter for panel.behinsms.com, isolated from application code."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = settings.BEHIN_SMS_USERNAME
|
||||
self.password = settings.BEHIN_SMS_PASSWORD
|
||||
self.special_number = settings.BEHIN_SMS_SPECIAL_NUMBER
|
||||
self.wsdl_url = settings.BEHIN_SMS_WSDL_URL
|
||||
self._client = None
|
||||
|
||||
def _configuration_error(self):
|
||||
missing = [name for name, value in (
|
||||
("BEHIN_SMS_USERNAME", self.username),
|
||||
("BEHIN_SMS_PASSWORD", self.password),
|
||||
("BEHIN_SMS_SPECIAL_NUMBER", self.special_number)
|
||||
) if not value]
|
||||
return f"تنظیمات پیامک بهین ناقص است: {', '.join(missing)}" if missing else ""
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
# استفاده از تنظیمات پیشفرض Zeep (مشابه نمونهٔ کاری)
|
||||
self._client = zeep.Client(wsdl=self.wsdl_url)
|
||||
return self._client
|
||||
|
||||
def get_credit(self):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
return self.client.service.GetCredit(UserName=self.username, Password=self.password)
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
return self.send_many([mobile], message)
|
||||
|
||||
def send_many(self, mobile_numbers, message, is_flash=False):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSResult(False, error=error)
|
||||
|
||||
recipients = list(dict.fromkeys(str(number).strip() for number in mobile_numbers if str(number).strip()))
|
||||
if not recipients:
|
||||
return SMSResult(False, error="شماره گیرنده پیامک خالی است.")
|
||||
|
||||
try:
|
||||
response = self.client.service.SendArray(
|
||||
UserName=self.username,
|
||||
Password=self.password,
|
||||
RecipientNumber={"string": recipients},
|
||||
MessageBody=message,
|
||||
SpecialNumber=self.special_number,
|
||||
IsFlashMessage=is_flash,
|
||||
CheckingMessageID={"long": [0] * len(recipients)}
|
||||
)
|
||||
serialized = serialize_object(response)
|
||||
if serialized is False or serialized is None:
|
||||
return SMSResult(False, error="وبسرویس بهین ارسال پیامک را تأیید نکرد.")
|
||||
return SMSResult(True, provider_id=str(serialized)[:500])
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Behin SMS delivery failed")
|
||||
return SMSResult(False, error=str(exc)[:1000])
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Provider کنسول برای تست/دیباگ؛ پیامک واقعی ارسال نمیکند."""
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .base import SMSProvider, SMSResult, webotp_message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def console_write(text):
|
||||
"""چاپ روی کنسول بهگونهای که **هرگز** استثنا پرت نکند.
|
||||
|
||||
وظیفهٔ provider ارسال است، نه نمایش. کنسول ویندوز (cp1252) فارسی را کدگذاری
|
||||
نمیکند و ``print`` مستقیم باعث ``UnicodeEncodeError`` میشد؛ چون فراخوانیکننده
|
||||
(worker) هر استثنا را «ارسال ناموفق» میفهمد، یک خطای نمایشی پیامک را در صف
|
||||
``failed`` میگذاشت. پس اول تلاش میکنیم متن کامل را بنویسیم، و اگر کنسول
|
||||
توانش را نداشت نسخهٔ ASCII-شده را مینویسیم.
|
||||
"""
|
||||
for payload in (text, text.encode("ascii", "replace").decode("ascii")):
|
||||
try:
|
||||
sys.stdout.write(payload + "\n")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
except (UnicodeEncodeError, ValueError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
class ConsoleSMSProvider(SMSProvider):
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def send_otp(self, mobile, code):
|
||||
if settings.DEBUG:
|
||||
console_write(f"[DEV OTP] {mobile}: {webotp_message(code)}")
|
||||
return SMSResult(True, provider_code="console")
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
logger.info("[DEV SMS] destination=***%s message=%s", (mobile or "")[-4:], message)
|
||||
if settings.DEBUG:
|
||||
console_write(f"[DEV SMS] {mobile}: {message}")
|
||||
return SMSResult(True, provider_code="console")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""انتخاب provider پیامک بر اساس تنظیمات ذخیرهشده در دیتابیس (Django Admin)."""
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from .base import SMSProvider
|
||||
|
||||
# نگاشت کلید انتخابشده در Admin به مسیر کلاس provider.
|
||||
PROVIDER_CLASSES = {
|
||||
"sms0098": "integrations.sms.sms0098.ZeroZeroNinetyEightSMSProvider",
|
||||
"sms0098ws": "integrations.sms.sms0098ws.ZeroZeroNinetyEightWebServiceProvider",
|
||||
"http": "integrations.sms.http.HTTPSMSProvider",
|
||||
"console": "integrations.sms.console.ConsoleSMSProvider",
|
||||
}
|
||||
|
||||
|
||||
def get_provider_class(config):
|
||||
path = PROVIDER_CLASSES.get(config.provider)
|
||||
if not path:
|
||||
raise ValueError(f"provider پیامک ناشناخته است: {config.provider}")
|
||||
provider_class = import_string(path)
|
||||
if not issubclass(provider_class, SMSProvider):
|
||||
raise TypeError("کلاس provider باید از SMSProvider ارثبری کند.")
|
||||
return provider_class
|
||||
|
||||
|
||||
def get_sms_provider(config=None):
|
||||
"""یک نمونه provider برای config دادهشده (یا تنظیمات جاری) میسازد.
|
||||
|
||||
import مدل بهصورت lazy انجام میشود تا از حلقهٔ import در زمان بارگذاری اپ جلوگیری شود.
|
||||
"""
|
||||
if config is None:
|
||||
from admin_panel.models import SMSProviderConfig
|
||||
config = SMSProviderConfig.load()
|
||||
return get_provider_class(config)(config)
|
||||
|
||||
|
||||
def reset_sms_provider():
|
||||
"""سازگاری با فراخوانیهای قبلی؛ provider دیگر کش نمیشود."""
|
||||
return None
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Provider عمومی مبتنی بر HTTP؛ مقادیر از SMSProviderConfig خوانده میشوند."""
|
||||
import requests
|
||||
|
||||
from .base import SMSProvider, SMSResult
|
||||
from .normalize import normalize_mobile
|
||||
|
||||
|
||||
class HTTPSMSProvider(SMSProvider):
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
config = self.config
|
||||
if config is None or not config.api_url:
|
||||
return SMSResult(False, error="آدرس سرویس پیامک (api_url) تنظیم نشده است.")
|
||||
if not normalize_mobile(str(mobile)):
|
||||
return SMSResult(
|
||||
False, provider_code="INVALID_NUMBER",
|
||||
error="شمارهٔ موبایل گیرنده معتبر نیست.",
|
||||
)
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
if config.api_token:
|
||||
headers["Authorization"] = f"Bearer {config.api_token}"
|
||||
try:
|
||||
response = requests.post(
|
||||
config.api_url,
|
||||
json={"to": mobile, "message": message, "sender": config.sender},
|
||||
headers=headers,
|
||||
timeout=config.timeout_seconds or 30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
data = response.json() if response.content else {}
|
||||
except ValueError:
|
||||
# بدنهٔ پاسخ JSON نبود (مثلاً صفحهٔ HTML خطای سرویسدهنده).
|
||||
return SMSResult(
|
||||
False, provider_code="BAD_RESPONSE",
|
||||
error=f"پاسخ سرویس پیامک JSON معتبر نبود (HTTP {response.status_code}).",
|
||||
)
|
||||
# بعضی سرویسدهندهها بهجای آبجکت، لیست برمیگردانند.
|
||||
if isinstance(data, list):
|
||||
data = data[0] if data and isinstance(data[0], dict) else {}
|
||||
elif not isinstance(data, dict):
|
||||
data = {}
|
||||
if data.get("success") is False:
|
||||
return SMSResult(False, error=str(data.get("error", "Provider rejected SMS")))
|
||||
return SMSResult(True, str(data.get("id") or data.get("message_id") or "http"))
|
||||
except requests.RequestException as exc:
|
||||
return SMSResult(False, error=str(exc))
|
||||
@@ -0,0 +1,40 @@
|
||||
"""نرمالسازی شماره موبایل ایرانی برای سرویسهای پیامک."""
|
||||
from persiantools import digits
|
||||
|
||||
|
||||
def _clean(raw):
|
||||
if raw is None:
|
||||
return ""
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
# تبدیل ارقام فارسی/عربی به انگلیسی و حذف هر کاراکتر غیررقمی (بهجز +).
|
||||
text = digits.fa_to_en(digits.ar_to_fa(text))
|
||||
return "".join(ch for ch in text if ch.isdigit() or ch == "+")
|
||||
|
||||
|
||||
def normalize_mobile(raw):
|
||||
"""شماره را به شکل یکنواخت ``+989xxxxxxxxx`` برمیگرداند؛ نامعتبر ⇒ رشته خالی."""
|
||||
text = _clean(raw)
|
||||
if not text:
|
||||
return ""
|
||||
if text.startswith("+"):
|
||||
text = text[1:]
|
||||
if text.startswith("0098"):
|
||||
text = text[4:]
|
||||
elif text.startswith("98") and len(text) == 12:
|
||||
text = text[2:]
|
||||
elif text.startswith("0"):
|
||||
text = text[1:]
|
||||
|
||||
if len(text) != 10 or not text.startswith("9"):
|
||||
return ""
|
||||
return f"+98{text}"
|
||||
|
||||
|
||||
def to_local(raw):
|
||||
"""شماره را به شکل محلی ``09xxxxxxxxx`` برمیگرداند (لازمِ برخی وبسرویسها)."""
|
||||
normalized = normalize_mobile(raw)
|
||||
if not normalized:
|
||||
return ""
|
||||
return "0" + normalized[3:]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""نمای عمومی سازگاری برای integration پیامک.
|
||||
|
||||
توجه: providerهای وابسته به ``zeep`` (behin و sms0098ws) عمداً اینجا import
|
||||
نمیشوند تا نبودِ zeep باعث خطای import کل اپ نشود؛ از مسیر
|
||||
``factory.get_sms_provider`` و ``import_string`` بهصورت تنبل بارگذاری میشوند.
|
||||
(sms0098 لینکی دیگر SOAP نیست و zeep لازم ندارد.)
|
||||
"""
|
||||
from .base import SMSDeliveryResult, SMSProvider, SMSResult, webotp_message
|
||||
from .console import ConsoleSMSProvider
|
||||
from .factory import PROVIDER_CLASSES, get_sms_provider, reset_sms_provider
|
||||
from .http import HTTPSMSProvider
|
||||
from .normalize import normalize_mobile, to_local
|
||||
|
||||
__all__ = [
|
||||
"SMSProvider",
|
||||
"SMSResult",
|
||||
"SMSDeliveryResult",
|
||||
"webotp_message",
|
||||
"ConsoleSMSProvider",
|
||||
"HTTPSMSProvider",
|
||||
"PROVIDER_CLASSES",
|
||||
"get_sms_provider",
|
||||
"reset_sms_provider",
|
||||
"normalize_mobile",
|
||||
"to_local",
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""ارسال پیامک از طریق سرویس لینکی 0098SMS (HTTP GET).
|
||||
|
||||
سرویس 0098SMS برای ارسال، یک درخواست GET به ``sendsmslink.aspx`` با پارامترهای
|
||||
``USERNAME/PASSWORD/FROM/TO/TEXT`` میپذیرد و در بدنهٔ پاسخ یک کد کوتاه (مثلاً ``0``)
|
||||
برمیگرداند. این سرویس SOAP/WSDL ندارد؛ اگر آدرس WSDL در تنظیمات گذاشته شود،
|
||||
``zeep`` صفحهٔ HTML میگیرد و خطای «Invalid XML content received» میدهد.
|
||||
|
||||
مقادیر اتصال (URL، نام کاربری، رمز و شمارهٔ خط) از ``SMSProviderConfig`` خوانده
|
||||
میشوند؛ یعنی همانهایی که ادمین در Django Admin یا ``/panel/sms/settings`` وارد میکند.
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
from .base import SMSProvider, SMSResult
|
||||
from .normalize import to_local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# آدرس پیشفرض سرویس لینکی؛ اگر ادمین آدرس دیگری وارد کند، همان استفاده میشود.
|
||||
LINK_SEND_URL = "http://www.0098sms.com/sendsmslink.aspx"
|
||||
|
||||
# نام پارامترها قرارداد خود vendor است؛ حروف بزرگ عمدیاند و نباید تغییر کنند.
|
||||
USERNAME_PARAM = "USERNAME"
|
||||
PASSWORD_PARAM = "PASSWORD"
|
||||
SENDER_PARAM = "FROM"
|
||||
RECIPIENT_PARAM = "TO"
|
||||
TEXT_PARAM = "TEXT"
|
||||
|
||||
# کدهای پیشفرض پاسخ. با تنظیمات ``SMS_SUCCESS_CODES``/``SMS_FAILURE_CODES``
|
||||
# (رشتهٔ جداشده با کاما یا لیست) قابل تغییرند، بدون دستزدن به کد.
|
||||
DEFAULT_SUCCESS_CODES = ("0",)
|
||||
DEFAULT_FAILURE_CODES = ("error",)
|
||||
|
||||
|
||||
def _configured_codes(name, default):
|
||||
raw = getattr(settings, name, default)
|
||||
if isinstance(raw, str):
|
||||
raw = raw.split(",")
|
||||
return {str(value).strip().casefold() for value in raw if str(value).strip()}
|
||||
|
||||
|
||||
def _response_code(response):
|
||||
"""کد برگشتی را از بدنهٔ پاسخ جدا میکند (BOM و فاصلههای اضافی حذف میشوند)."""
|
||||
return (response.text or "").strip().lstrip("\ufeff").strip()
|
||||
|
||||
|
||||
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
|
||||
"""سرویس لینکی 0098SMS: یک درخواست GET و یک کد کوتاه در پاسخ."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
self.username = getattr(config, "username", "") or ""
|
||||
self.password = getattr(config, "password", "") or ""
|
||||
self.sender = getattr(config, "sender", "") or ""
|
||||
self.url = (getattr(config, "api_url", "") or "").strip() or LINK_SEND_URL
|
||||
self.timeout = getattr(config, "timeout_seconds", None) or 30
|
||||
|
||||
def _configuration_error(self):
|
||||
missing = [
|
||||
name for name, value in (
|
||||
("نام کاربری", self.username),
|
||||
("رمز عبور", self.password),
|
||||
("شماره خط فرستنده", self.sender),
|
||||
) if not value
|
||||
]
|
||||
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
|
||||
|
||||
def _safe_exception(self, exc):
|
||||
"""متن خطا را از لو رفتن نام کاربری/رمز در لاگ و UI پاک میکند."""
|
||||
message = str(exc)
|
||||
for secret in (self.username, self.password):
|
||||
if secret:
|
||||
message = message.replace(secret, "***")
|
||||
return message[:500]
|
||||
|
||||
def _unconfirmed(self, detail):
|
||||
"""پاسخی که «ارسالشده» را تأیید نمیکند ولی «ردشده» هم نیست.
|
||||
|
||||
وضعیت را ناموفق برمیگردانیم تا صف با backoff دوباره تلاش کند؛ در برابر
|
||||
جایگزینِ «موفق فرضکردن»، این انتخاب صادقانهتر است و سبب گمشدن یادآوری
|
||||
نمیشود. (vendor ممکن است پیامک را فرستاده و پاسخش گم شده باشد.)
|
||||
"""
|
||||
logger.warning("پاسخ نامشخص سرویس 0098SMS: %s", detail)
|
||||
return SMSResult(
|
||||
False, provider_code="UNCONFIRMED",
|
||||
error=f"وضعیت ارسال 0098SMS تأیید نشد (پاسخ: {str(detail)[:120] or 'خالی'}).",
|
||||
)
|
||||
|
||||
def send_message(self, mobile, message):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSResult(False, error=error, provider_code="CONFIG")
|
||||
|
||||
# این سرویس شماره را به شکل محلی «09xxxxxxxxx» میخواهد (نه +98 و نه 98).
|
||||
recipient = to_local(mobile)
|
||||
# تضمین صفر ابتدایی: عدد هرگز به int تبدیل نمیشود (int صفر ابتدایی را حذف میکند)
|
||||
# و همیشه بهصورت رشتهٔ «09xxxxxxxxx» فرستاده میشود.
|
||||
if recipient and not recipient.startswith("0"):
|
||||
recipient = "0" + recipient.lstrip("0")
|
||||
if not recipient:
|
||||
# بدون این گارد، شمارهٔ نامعتبر به vendor میرفت (هزینهٔ ارسال + خطای گنگ).
|
||||
return SMSResult(
|
||||
False, provider_code="INVALID_NUMBER",
|
||||
error="شمارهٔ موبایل گیرنده معتبر نیست.",
|
||||
)
|
||||
|
||||
params = {
|
||||
USERNAME_PARAM: self.username,
|
||||
PASSWORD_PARAM: self.password,
|
||||
SENDER_PARAM: str(self.sender),
|
||||
RECIPIENT_PARAM: recipient,
|
||||
TEXT_PARAM: str(message),
|
||||
}
|
||||
try:
|
||||
# سرویس 0098 داخل ایران میزبانی میشود؛ روی ماشینهایی که
|
||||
# ``HTTP_PROXY`` برای دسترسی به سایتهای خارجی تنظیم شده، عبور از
|
||||
# پراکسی باعث timeout در تماس با این سرویس میشود. مقدار ``None``
|
||||
# برای هر scheme، پراکسی محیط را برای همین درخواست بیاثر میکند.
|
||||
response = requests.get(
|
||||
self.url, params=params, timeout=self.timeout,
|
||||
proxies={"http": None, "https": None},
|
||||
)
|
||||
except (requests.Timeout, requests.ConnectionError) as exc:
|
||||
# مهلت تمامشده/قطعی ارتباط: ممکن است پیامک رفته باشد؛ قطعی «ناموفق» نیست.
|
||||
return self._unconfirmed(self._safe_exception(exc))
|
||||
except requests.RequestException as exc:
|
||||
return SMSResult(
|
||||
False, provider_code="CONNECTION",
|
||||
error=f"خطا در ارتباط با سرویس 0098SMS: {self._safe_exception(exc)}",
|
||||
)
|
||||
|
||||
if response.status_code >= 500:
|
||||
# خطای سمت vendor؛ ممکن است پیش از خطا ارسال شده باشد.
|
||||
return self._unconfirmed(f"HTTP {response.status_code}")
|
||||
|
||||
code = _response_code(response)
|
||||
folded = code.casefold()
|
||||
if folded in _configured_codes("SMS_SUCCESS_CODES", DEFAULT_SUCCESS_CODES):
|
||||
return SMSResult(True, provider_code=code or "0")
|
||||
if folded in _configured_codes("SMS_FAILURE_CODES", DEFAULT_FAILURE_CODES):
|
||||
return SMSResult(
|
||||
False, provider_code="REJECTED",
|
||||
error=f"سرویس 0098SMS ارسال را نپذیرفت (پاسخ: {code[:120] or 'خالی'}).",
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
return SMSResult(
|
||||
False, provider_code=f"HTTP_{response.status_code}",
|
||||
error=f"سرویس 0098SMS درخواست را رد کرد (HTTP {response.status_code}).",
|
||||
)
|
||||
# بدنهٔ ناشناخته نشانهٔ رد نیست؛ اما «ارسالشده» هم قابل اثبات نیست.
|
||||
return self._unconfirmed(code)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""ترجمهٔ کدهای بازگشتی سامانهٔ 0098SMS به متن فارسی و ردهبندی وضعیت.
|
||||
|
||||
دو جدول مستقل در راهنمای vendor آمده است:
|
||||
|
||||
* **کدهای ارسال/خطا** (لینک ارتباطی و وبسرویس): عدد کوتاه یا رشتهای مانند
|
||||
``Hang``/``Wrong time``. صفر (یا شناسهٔ ۹رقمی در ``SendSMSWithID``) یعنی موفق.
|
||||
* **کدهای وضعیت دلیوری** (خروجی ``smsdeliveryState``): ۵=رسیده به گوشی،
|
||||
۶=نرسیده، ۷=منقضی، ۸=نامشخص.
|
||||
|
||||
این ماژول تنها منبع حقیقت این کدهاست تا provider و پنل هر دو از یک ترجمه استفاده کنند.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# کدهای خطای ارسال (ترکیب جدول لینک ارتباطی و وبسرویس راهنما).
|
||||
# ---------------------------------------------------------------------------
|
||||
SEND_ERROR_CODES = {
|
||||
"1": "شماره گیرنده اشتباه است.",
|
||||
"2": "گیرنده تعریف نشده است.",
|
||||
"3": "فرستنده تعریف نشده است.",
|
||||
"4": "مخاطب دریافت پیامهای تبلیغاتی خود را بسته است (بلکلیست).",
|
||||
"5": "متن تنظیم نشده است.",
|
||||
"6": "نام کاربری تنظیم نشده است.",
|
||||
"7": "کلمه عبور تنظیم نشده است.",
|
||||
"8": "نام دامین تنظیم نشده است.",
|
||||
"9": "مجوز شما باطل شده است.",
|
||||
"10": "اعتبار پیامک شما کافی نیست.",
|
||||
"11": "برای این شماره لینک تعریف نشده است.",
|
||||
"12": "عدم مجوز برای اتصال لینک.",
|
||||
"13": "نام کاربری و کلمه عبور اشتباه است.",
|
||||
"14": "کاراکتر غیرمجاز در متن وجود دارد.",
|
||||
"15": "سقف ارسال روزانه پر شده است.",
|
||||
"16": "عدم مجوز شماره برای ارسال از لینک.",
|
||||
"17": "خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید.",
|
||||
"18": "اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود.",
|
||||
"19": "تنظیمات کد opt انجام نشده است.",
|
||||
"20": "فرمت کد opt صحیح نیست.",
|
||||
"21": "تنظیمات کد opt توسط ادمین تأیید نشده است.",
|
||||
"22": "اطلاعات مالک شماره ثبت و تأیید نشده است.",
|
||||
"23": "هنوز اجازهٔ ارسال به این شماره پنل داده نشده است.",
|
||||
"24": "ارسال از IP غیرمجاز انجام شده است.",
|
||||
"66": "شارژ پنل شما برای ارسال کافی نیست؛ لطفاً شارژ نمایید.",
|
||||
"1111": "شماره موبایل صحیح نیست.",
|
||||
"-22": "خطای شارژ؛ لطفاً با پشتیبانی تماس بگیرید.",
|
||||
"-23": "حساب کاربری شما مسدود است؛ لطفاً با پشتیبانی تماس بگیرید.",
|
||||
"-24": "مرحلهٔ دوم ثبتنام شما انجام نگرفته است؛ ثبتنام را کامل نمایید.",
|
||||
"-25": "عدم مجوز شماره برای ارسال از لینک.",
|
||||
"-26": "خطا در شماره پنل؛ لطفاً با پشتیبانی تماس بگیرید.",
|
||||
"-27": "اتمام تاریخ اعتبار شماره پنل؛ برای استفاده تمدید شود.",
|
||||
"-28": "تنظیمات کد opt انجام نشده است.",
|
||||
"-29": "فرمت کد opt صحیح نیست.",
|
||||
"-31": "شناسهٔ پیامک (smsid) یافت نشد.",
|
||||
"-32": "شناسهٔ پیامک (smsid) مطابقت ندارد.",
|
||||
"hang": "عدم تطابق نام کاربری و کلمه عبور؛ لطفاً با پشتیبانی تماس بگیرید.",
|
||||
"no doc": "کاراکتر غیرمجاز در متن وجود دارد.",
|
||||
"wrong timestamp": "تایماستمپ ارسالشده صحیح نیست.",
|
||||
"wrong time": "زمان در بازهٔ صحیح نیست (از همان روز تا یک سال بعد مجاز است).",
|
||||
}
|
||||
|
||||
|
||||
def describe_send_code(code):
|
||||
"""متن فارسی کد خطای ارسال؛ اگر کد ناشناخته باشد ``None``."""
|
||||
return SEND_ERROR_CODES.get(str(code).strip().casefold())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# کدهای وضعیت دلیوری (``smsdeliveryState``).
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryCode:
|
||||
status: str # مطابق SMSQueue.DeliveryStatus
|
||||
description: str
|
||||
is_final: bool # True = دیگر نیازی به استعلام مجدد نیست
|
||||
|
||||
|
||||
_DELIVERY_CODES = {
|
||||
"5": DeliveryCode("delivered", "رسیده به گوشی.", True),
|
||||
"6": DeliveryCode("not_delivered", "نرسیده به گوشی.", True),
|
||||
"7": DeliveryCode("expired", "وضعیت دریافت پیامک منقضی شده است.", True),
|
||||
"8": DeliveryCode("unknown", "وضعیت پیامک نامشخص است.", False),
|
||||
}
|
||||
|
||||
|
||||
def classify_delivery(raw_code):
|
||||
"""کد دلیوری خام را به :class:`DeliveryCode` تبدیل میکند.
|
||||
|
||||
عدد ۲ یا هر مقدار منفی/غیرشناختهشده ⇒ «نامشخص» و غیرنهایی، تا استعلام بعدی
|
||||
دوباره تلاش کند (وضعیت ممکن است هنوز از سمت اپراتور برنگشته باشد).
|
||||
"""
|
||||
code = str(raw_code).strip()
|
||||
return _DELIVERY_CODES.get(code, DeliveryCode("unknown", f"کد وضعیت ناشناخته: {code}", False))
|
||||
@@ -0,0 +1,153 @@
|
||||
"""ارسال و پیگیری پیامک 0098SMS از طریق وبسرویس (SOAP).
|
||||
|
||||
تفاوت با provider لینکی (``sms0098``): متد ``SendSMSWithID`` همزمان با ارسال
|
||||
**شناسهٔ پیامک (smsid)** را برمیگرداند و متد ``smsdeliveryState`` وضعیت تحویل به
|
||||
گوشی را میدهد. به همین دلیل تنها این provider قابلیت رهگیری «رسیده/نرسیده به
|
||||
گوشی» دارد.
|
||||
|
||||
آدرس وبسرویس، نام کاربری، رمز و شمارهٔ پنل از ``SMSProviderConfig`` خوانده
|
||||
میشوند (نه هیچ مقدار ثابتی در کد).
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import zeep
|
||||
from zeep.transports import Transport
|
||||
|
||||
from .base import SMSDeliveryResult, SMSProvider, SMSResult
|
||||
from .normalize import to_local
|
||||
from .sms0098_codes import classify_delivery, describe_send_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# نام پارامترها را خودِ WSDL سرویس تعیین میکند و **تماماً با حرف کوچک** است
|
||||
# (username/password/text/mobileno/pnlno/smsid). راهنمای vendor آنها را با
|
||||
# حرف بزرگ نوشته؛ zeep نام راهنما را نمیپذیرد و «unexpected keyword argument»
|
||||
# میدهد. پس اینجا دقیقاً همان امضای WSDL استفاده میشود.
|
||||
_SEND_OPERATION = "SendSMSWithID"
|
||||
_DELIVERY_OPERATION = "smsdeliveryState"
|
||||
|
||||
|
||||
def _as_text(value):
|
||||
"""پاسخ zeep/وبسرویس را به رشتهٔ ساده تبدیل میکند."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return str(value)
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
class ZeroZeroNinetyEightWebServiceProvider(SMSProvider):
|
||||
"""وبسرویس SOAP 0098SMS: ارسال با شناسه + استعلام دلیوری."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
self.config = config
|
||||
self.username = (getattr(config, "username", "") or "").strip()
|
||||
self.password = (getattr(config, "password", "") or "").strip()
|
||||
# شمارهٔ پنل وبسرویس؛ در نبود مقدار صریح، همان خط فرستنده است.
|
||||
self.panel_no = (getattr(config, "panel_no", "") or "").strip() or \
|
||||
(getattr(config, "sender", "") or "").strip()
|
||||
self.url = (getattr(config, "webservice_url", "") or "").strip()
|
||||
self.timeout = getattr(config, "timeout_seconds", None) or 30
|
||||
self._client = None
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
def _configuration_error(self):
|
||||
missing = [
|
||||
name for name, value in (
|
||||
("نام کاربری", self.username),
|
||||
("رمز عبور", self.password),
|
||||
("شماره پنل", self.panel_no),
|
||||
("آدرس وبسرویس", self.url),
|
||||
) if not value
|
||||
]
|
||||
return f"تنظیمات وبسرویس 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
|
||||
|
||||
def _safe_exception(self, exc):
|
||||
"""متن خطا را از لو رفتن نام کاربری/رمز پاک میکند."""
|
||||
message = str(exc)
|
||||
for secret in (self.username, self.password):
|
||||
if secret:
|
||||
message = message.replace(secret, "***")
|
||||
return message[:500]
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
if self._client is None:
|
||||
# ``trust_env=False`` پراکسی محیط (مثلاً برای سایتهای خارجی) را برای
|
||||
# تماس با سرویس داخلی ایران خنثی میکند.
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
transport = Transport(
|
||||
session=session, timeout=self.timeout, operation_timeout=self.timeout,
|
||||
)
|
||||
wsdl = self.url if self.url.lower().endswith("wsdl") else f"{self.url}?WSDL"
|
||||
self._client = zeep.Client(wsdl=wsdl, transport=transport)
|
||||
return self._client
|
||||
|
||||
# ------------------------------------------------------------------ sending
|
||||
def send_message(self, mobile, message):
|
||||
error = self._configuration_error()
|
||||
if error:
|
||||
return SMSResult(False, error=error, provider_code="CONFIG")
|
||||
|
||||
recipient = to_local(mobile)
|
||||
if not recipient:
|
||||
return SMSResult(
|
||||
False, provider_code="INVALID_NUMBER",
|
||||
error="شمارهٔ موبایل گیرنده معتبر نیست.",
|
||||
)
|
||||
|
||||
try:
|
||||
response = getattr(self.client.service, _SEND_OPERATION)(
|
||||
username=self.username, password=self.password,
|
||||
text=str(message), mobileno=recipient, pnlno=self.panel_no,
|
||||
)
|
||||
except Exception as exc: # (Connect|Timeout|zeep) — هر چه بود، ارسال تأیید نشد
|
||||
logger.exception("ارسال پیامک 0098SMS (وبسرویس) شکست خورد")
|
||||
return SMSResult(
|
||||
False, provider_code="REQUEST_ERROR",
|
||||
error=f"خطا در ارتباط با وبسرویس 0098SMS: {self._safe_exception(exc)}",
|
||||
)
|
||||
|
||||
code = _as_text(response)
|
||||
# موفقیت: یا ۰، یا خودِ شناسهٔ پیامک (عدد حداقل ۹ رقمی).
|
||||
if code == "0":
|
||||
return SMSResult(True, provider_code="0")
|
||||
if code.isdigit() and len(code) >= 9:
|
||||
return SMSResult(True, provider_id=code, provider_code=code)
|
||||
|
||||
description = describe_send_code(code)
|
||||
if description:
|
||||
return SMSResult(False, provider_code=code[:50] or "REJECTED", error=description)
|
||||
return SMSResult(
|
||||
False, provider_code=code[:50] or "UNKNOWN",
|
||||
error=f"پاسخ نامشخص وبسرویس 0098SMS: {code[:200] or 'خالی'}",
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------- delivery
|
||||
def get_delivery_status(self, provider_id):
|
||||
"""وضعیت تحویل پیامک را با شناسهٔ آن استعلام میکند.
|
||||
|
||||
در خطای ارتباط/تنظیمات ``None`` برمیگرداند تا فراخوان (دستور
|
||||
``sms_delivery``) این ردیف را رد کند و در دور بعد دوباره تلاش نماید.
|
||||
"""
|
||||
if not provider_id or self._configuration_error():
|
||||
return None
|
||||
try:
|
||||
response = getattr(self.client.service, _DELIVERY_OPERATION)(
|
||||
username=self.username, password=self.password,
|
||||
smsid=str(provider_id),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("استعلام دلیوری 0098SMS برای smsid=%s شکست خورد", provider_id)
|
||||
return None
|
||||
|
||||
code = _as_text(response)
|
||||
result = classify_delivery(code)
|
||||
return SMSDeliveryResult(
|
||||
status=result.status, provider_code=code,
|
||||
description=result.description, is_final=result.is_final,
|
||||
)
|
||||
Reference in new issue
Block a user