Files
2026-10-10 14:21:04 +03:30

154 lines
7.0 KiB
Python

"""ارسال و پیگیری پیامک 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,
)