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, )