This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

View File
Whitespace-only changes.
+30
View File
@@ -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
+69
View File
@@ -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])
+43
View File
@@ -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")
+38
View File
@@ -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
+49
View File
@@ -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))
+40
View File
@@ -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:]
+26
View File
@@ -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",
]
+153
View File
@@ -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)
+91
View File
@@ -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))
+153
View File
@@ -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,
)