173 lines
8.6 KiB
Python
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,
|
|
)
|