Files
customerTicket/apps/integrations/sms/sms0098.py
T
2026-09-14 15:48:13 +03:30

173 lines
8.6 KiB
Python

import logging
import re
import zeep
from django.conf import settings
from zeep.transports import Transport
from apps.accounts.models import normalize_mobile
from .base import SMSDeliveryResult, SMSProvider, SMSResult
logger = logging.getLogger(__name__)
# Source: the vendor's "کدهای خطا وب سرویس" table. Keeping the mapping here
# makes worker logs and the staff UI report the same exact reason.
SEND_RESULT_MESSAGES = {
"2": "ارسال با موفقیت انجام شد.",
"-3": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"10": "نام کاربری یا کلمه عبور اشتباه است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"11": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"-17": "متن پیامک خالی است.",
"-18": "خطای شارژ رخ داده است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-19": "شارژ پنل برای ارسال کافی نیست؛ لطفاً پنل را شارژ کنید.",
"-22": "شماره موبایل گیرنده صحیح نیست.",
"66": "نام کاربری و کلمه عبور با هم تطابق ندارند؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"1111": "در متن پیامک کاراکتر غیرمجاز وجود دارد.",
"hang": "حساب کاربری مسدود است؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"no doc": "مرحله دوم ثبت‌نام انجام نشده است؛ لطفاً ثبت‌نام را کامل کنید.",
"-23": "شماره پنل مجوز ارسال از لینک را ندارد.",
"-24": "در شماره پنل خطا وجود دارد؛ لطفاً با پشتیبانی 0098SMS تماس بگیرید.",
"-25": "اعتبار شماره پنل به پایان رسیده است و باید تمدید شود.",
"-26": "تنظیمات کد OTP انجام نشده است.",
"-27": "فرمت کد OTP صحیح نیست.",
"-28": "تنظیمات کد OTP توسط ادمین تأیید نشده است.",
"wrong timestamp": "مقدار timestamp ارسال‌شده صحیح نیست.",
"wrong time": "زمان ارسال خارج از بازه مجاز است.",
"-29": "فرمت کد OTP صحیح نیست.",
"-31": "شناسه پیامک (smsid) یافت نشد.",
"-32": "شناسه پیامک (smsid) مطابقت ندارد.",
"13": "ارسال از IP غیرمجاز انجام شده است.",
}
DELIVERY_RESULTS = {
"4": ("BLOCKED", "مخاطب دریافت پیامک‌های تبلیغاتی را مسدود کرده است.", True),
"5": ("DELIVERED", "پیامک به گوشی گیرنده رسیده است.", True),
# 0098SMS can initially return 6 or 8 and publish the definitive result
# much later. Keep polling these states instead of freezing a stale result.
"6": ("UNDELIVERED", "هنوز تحویل پیامک به گوشی تأیید نشده است.", False),
"7": ("EXPIRED", "مهلت دریافت پیامک منقضی شده است.", True),
"8": ("UNKNOWN", "وضعیت تحویل هنوز توسط اپراتور مشخص نشده است.", False),
"9": ("ERROR", "اطلاعات مالک شماره پیامک ثبت یا تأیید نشده است.", True),
"12": ("ERROR", "هنوز اجازه ارسال با این شماره پنل داده نشده است.", True),
"13": ("ERROR", "ارسال از IP غیرمجاز انجام شده است.", True),
}
def _scalar(value):
if isinstance(value, (list, tuple)) and value:
value = value[0]
return str(value if value is not None else "").strip()
class ZeroZeroNinetyEightSMSProvider(SMSProvider):
"""SOAP adapter for 0098SMS (0098vip-compatible web service)."""
def __init__(self):
self.username = settings.SMS0098_USERNAME
self.password = settings.SMS0098_PASSWORD
self.sender = settings.SMS0098_FROM
self.wsdl_url = settings.SMS0098_WSDL_URL
self._client = None
def _configuration_error(self):
missing = [name for name, value in (
("SMS0098_USERNAME", self.username),
("SMS0098_PASSWORD", self.password),
("SMS0098_FROM", self.sender),
) if not value]
return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else ""
@property
def client(self):
if self._client is None:
timeout = settings.INTEGRATION_HTTP_TIMEOUT_SECONDS
self._client = zeep.Client(
wsdl=self.wsdl_url,
transport=Transport(timeout=timeout, operation_timeout=timeout),
)
return self._client
def _safe_exception(self, exc):
message = str(exc)
for secret in (self.username, self.password):
if secret:
message = message.replace(secret, "***")
return message[:500]
def _call(self, operation, parameters):
method = getattr(self.client.service, operation)
# The live 0098sms.com WSDL exposes separate scalar arguments. This
# deliberately follows the WSDL signature instead of the outdated
# dict-shaped invocation printed in the vendor PDF sample.
return method(**parameters)
def _parse_send_result(self, response):
raw = _scalar(response)
normalized = raw.casefold()
if re.fullmatch(r"\d{9,}", raw):
return SMSResult(True, provider_id=raw, provider_code=raw)
# 2 is the SOAP success code. 0 is retained only for compatibility
# with the vendor's older link endpoint.
if raw in {"0", "2"}:
return SMSResult(True, provider_code=raw)
reason = SEND_RESULT_MESSAGES.get(normalized)
if reason:
return SMSResult(False, error=f"0098SMS [کد {raw}]: {reason}", provider_code=raw)
shown = raw[:120] or "پاسخ خالی"
return SMSResult(False, error=f"0098SMS پاسخ ناشناخته برگرداند: {shown}", provider_code=raw[:50])
def send_message(self, mobile, message):
error = self._configuration_error()
if error:
return SMSResult(False, error=error, provider_code="CONFIG")
try:
recipient = normalize_mobile(str(mobile)).removeprefix("+98")
if not recipient.startswith("0"):
recipient = f"0{recipient}"
response = self._call("SendSMSWithID", {
"username": self.username,
"password": self.password,
"mobileno": recipient,
"pnlno": str(self.sender),
"text": str(message),
})
return self._parse_send_result(response)
except Exception as exc:
logger.exception("0098SMS send failed")
return SMSResult(
False,
error=f"خطا در ارتباط با وب‌سرویس 0098SMS: {self._safe_exception(exc)}",
provider_code="CONNECTION",
)
def get_delivery_status(self, provider_id):
error = self._configuration_error()
if error:
return SMSDeliveryResult("ERROR", "CONFIG", error)
if not re.fullmatch(r"\d{9,}", str(provider_id or "")):
return SMSDeliveryResult("ERROR", "INVALID_ID", "شناسه رهگیری 0098SMS معتبر نیست.")
try:
response = self._call("smsdeliveryState", {
"username": self.username,
"password": self.password,
"smsid": str(provider_id),
})
code = _scalar(response)
mapped = DELIVERY_RESULTS.get(code)
if mapped:
status, description, is_final = mapped
return SMSDeliveryResult(status, code, f"0098SMS [کد {code}]: {description}", is_final=is_final)
reason = SEND_RESULT_MESSAGES.get(code.casefold())
if reason:
return SMSDeliveryResult("ERROR", code, f"0098SMS [کد {code}]: {reason}")
return SMSDeliveryResult("UNKNOWN", code[:50], f"0098SMS وضعیت دلیوری ناشناخته برگرداند: {code[:120] or 'پاسخ خالی'}")
except Exception as exc:
logger.exception("0098SMS delivery status lookup failed")
return SMSDeliveryResult(
"CHECK_FAILED",
"CONNECTION",
f"خطا در استعلام دلیوری 0098SMS: {self._safe_exception(exc)}",
is_final=False,
)