"""ارسال پیامک از طریق سرویس لینکی 0098SMS (HTTP GET). سرویس 0098SMS برای ارسال، یک درخواست GET به ``sendsmslink.aspx`` با پارامترهای ``USERNAME/PASSWORD/FROM/TO/TEXT`` می‌پذیرد و در بدنهٔ پاسخ یک کد کوتاه (مثلاً ``0``) برمی‌گرداند. این سرویس SOAP/WSDL ندارد؛ اگر آدرس WSDL در تنظیمات گذاشته شود، ``zeep`` صفحهٔ HTML می‌گیرد و خطای «Invalid XML content received» می‌دهد. مقادیر اتصال (URL، نام کاربری، رمز و شمارهٔ خط) از ``SMSProviderConfig`` خوانده می‌شوند؛ یعنی همان‌هایی که ادمین در Django Admin یا ``/panel/sms/settings`` وارد می‌کند. """ import logging import requests from django.conf import settings from .base import SMSProvider, SMSResult from .normalize import to_local logger = logging.getLogger(__name__) # آدرس پیش‌فرض سرویس لینکی؛ اگر ادمین آدرس دیگری وارد کند، همان استفاده می‌شود. LINK_SEND_URL = "http://www.0098sms.com/sendsmslink.aspx" # نام پارامترها قرارداد خود vendor است؛ حروف بزرگ عمدی‌اند و نباید تغییر کنند. USERNAME_PARAM = "USERNAME" PASSWORD_PARAM = "PASSWORD" SENDER_PARAM = "FROM" RECIPIENT_PARAM = "TO" TEXT_PARAM = "TEXT" # کدهای پیش‌فرض پاسخ. با تنظیمات ``SMS_SUCCESS_CODES``/``SMS_FAILURE_CODES`` # (رشتهٔ جدا‌شده با کاما یا لیست) قابل تغییرند، بدون دست‌زدن به کد. DEFAULT_SUCCESS_CODES = ("0",) DEFAULT_FAILURE_CODES = ("error",) def _configured_codes(name, default): raw = getattr(settings, name, default) if isinstance(raw, str): raw = raw.split(",") return {str(value).strip().casefold() for value in raw if str(value).strip()} def _response_code(response): """کد برگشتی را از بدنهٔ پاسخ جدا می‌کند (BOM و فاصله‌های اضافی حذف می‌شوند).""" return (response.text or "").strip().lstrip("\ufeff").strip() class ZeroZeroNinetyEightSMSProvider(SMSProvider): """سرویس لینکی 0098SMS: یک درخواست GET و یک کد کوتاه در پاسخ.""" def __init__(self, config=None): self.config = config self.username = getattr(config, "username", "") or "" self.password = getattr(config, "password", "") or "" self.sender = getattr(config, "sender", "") or "" self.url = (getattr(config, "api_url", "") or "").strip() or LINK_SEND_URL self.timeout = getattr(config, "timeout_seconds", None) or 30 def _configuration_error(self): missing = [ name for name, value in ( ("نام کاربری", self.username), ("رمز عبور", self.password), ("شماره خط فرستنده", self.sender), ) if not value ] return f"تنظیمات 0098SMS ناقص است: {', '.join(missing)}" if missing else "" def _safe_exception(self, exc): """متن خطا را از لو رفتن نام کاربری/رمز در لاگ و UI پاک می‌کند.""" message = str(exc) for secret in (self.username, self.password): if secret: message = message.replace(secret, "***") return message[:500] def _unconfirmed(self, detail): """پاسخی که «ارسال‌شده» را تأیید نمی‌کند ولی «رد‌شده» هم نیست. وضعیت را ناموفق برمی‌گردانیم تا صف با backoff دوباره تلاش کند؛ در برابر جایگزینِ «موفق فرض‌کردن»، این انتخاب صادقانه‌تر است و سبب گم‌شدن یادآوری نمی‌شود. (vendor ممکن است پیامک را فرستاده و پاسخش گم شده باشد.) """ logger.warning("پاسخ نامشخص سرویس 0098SMS: %s", detail) return SMSResult( False, provider_code="UNCONFIRMED", error=f"وضعیت ارسال 0098SMS تأیید نشد (پاسخ: {str(detail)[:120] or 'خالی'}).", ) def send_message(self, mobile, message): error = self._configuration_error() if error: return SMSResult(False, error=error, provider_code="CONFIG") # این سرویس شماره را به شکل محلی «09xxxxxxxxx» می‌خواهد (نه +98 و نه 98). recipient = to_local(mobile) # تضمین صفر ابتدایی: عدد هرگز به int تبدیل نمی‌شود (int صفر ابتدایی را حذف می‌کند) # و همیشه به‌صورت رشتهٔ «09xxxxxxxxx» فرستاده می‌شود. if recipient and not recipient.startswith("0"): recipient = "0" + recipient.lstrip("0") if not recipient: # بدون این گارد، شمارهٔ نامعتبر به vendor می‌رفت (هزینهٔ ارسال + خطای گنگ). return SMSResult( False, provider_code="INVALID_NUMBER", error="شمارهٔ موبایل گیرنده معتبر نیست.", ) params = { USERNAME_PARAM: self.username, PASSWORD_PARAM: self.password, SENDER_PARAM: str(self.sender), RECIPIENT_PARAM: recipient, TEXT_PARAM: str(message), } try: # سرویس 0098 داخل ایران میزبانی می‌شود؛ روی ماشین‌هایی که # ``HTTP_PROXY`` برای دسترسی به سایت‌های خارجی تنظیم شده، عبور از # پراکسی باعث timeout در تماس با این سرویس می‌شود. مقدار ``None`` # برای هر scheme، پراکسی محیط را برای همین درخواست بی‌اثر می‌کند. response = requests.get( self.url, params=params, timeout=self.timeout, proxies={"http": None, "https": None}, ) except (requests.Timeout, requests.ConnectionError) as exc: # مهلت تمام‌شده/قطعی ارتباط: ممکن است پیامک رفته باشد؛ قطعی «ناموفق» نیست. return self._unconfirmed(self._safe_exception(exc)) except requests.RequestException as exc: return SMSResult( False, provider_code="CONNECTION", error=f"خطا در ارتباط با سرویس 0098SMS: {self._safe_exception(exc)}", ) if response.status_code >= 500: # خطای سمت vendor؛ ممکن است پیش از خطا ارسال شده باشد. return self._unconfirmed(f"HTTP {response.status_code}") code = _response_code(response) folded = code.casefold() if folded in _configured_codes("SMS_SUCCESS_CODES", DEFAULT_SUCCESS_CODES): return SMSResult(True, provider_code=code or "0") if folded in _configured_codes("SMS_FAILURE_CODES", DEFAULT_FAILURE_CODES): return SMSResult( False, provider_code="REJECTED", error=f"سرویس 0098SMS ارسال را نپذیرفت (پاسخ: {code[:120] or 'خالی'}).", ) if response.status_code >= 400: return SMSResult( False, provider_code=f"HTTP_{response.status_code}", error=f"سرویس 0098SMS درخواست را رد کرد (HTTP {response.status_code}).", ) # بدنهٔ ناشناخته نشانهٔ رد نیست؛ اما «ارسال‌شده» هم قابل اثبات نیست. return self._unconfirmed(code)