fix sms and ticket user frindly

This commit is contained in:
javad committed 2026-09-14 15:48:13 +03:30
1 parent 1ee83374fd
commit b8b3aba855
30 files changed
+2220 -119

No files matched your search

+12
View File
@@ -7,6 +7,14 @@ 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 معتبر تنظیم نشده است.")
@@ -16,3 +24,7 @@ class SMSProvider(ABC):
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
+2 -2
View File
@@ -1,7 +1,7 @@
"""Backward-compatible public facade for the SMS integration."""
from .base import SMSProvider,SMSResult,webotp_message
from .base import SMSDeliveryResult,SMSProvider,SMSResult,webotp_message
from .behin import BehinSMSProvider
from .console import ConsoleSMSProvider
from .factory import get_sms_provider,reset_sms_provider
from .http import HTTPSMSProvider
__all__=["SMSProvider","SMSResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
__all__=["SMSProvider","SMSResult","SMSDeliveryResult","webotp_message","BehinSMSProvider","ConsoleSMSProvider","HTTPSMSProvider","get_sms_provider","reset_sms_provider"]
+154 -18
View File
@@ -1,36 +1,172 @@
import logging
import re
import requests
import zeep
from django.conf import settings
from zeep.transports import Transport
from apps.accounts.models import normalize_mobile
from .base import SMSProvider, SMSResult
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):
"""Adapter for the 0098SMS HTTP gateway."""
"""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.api_url = settings.SMS0098_API_URL
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):
missing = [name for name, value in (("SMS0098_USERNAME", self.username), ("SMS0098_PASSWORD", self.password), ("SMS0098_FROM", self.sender)) if not value]
if missing:
return SMSResult(False, error=f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}")
error = self._configuration_error()
if error:
return SMSResult(False, error=error, provider_code="CONFIG")
try:
recipient = int(normalize_mobile(str(mobile)).removeprefix("+98"))
response = requests.get(self.api_url, params={"USERNAME": self.username, "PASSWORD": self.password, "FROM": self.sender, "TO": recipient, "TEXT": str(message)}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
response.raise_for_status()
# This legacy endpoint may append an HTML page containing the
# original query string. Persist only its first scalar result so
# credentials can never leak into notification logs.
provider_id = response.text.strip().splitlines()[0][:100]
return SMSResult(True, provider_id=provider_id)
except (ValueError, requests.RequestException) as exc:
logger.exception("0098SMS delivery failed")
return SMSResult(False, error=str(exc)[:1000])
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,
)