Files
khatibi/integrations/sms/sms0098.py
T
2026-10-10 14:21:04 +03:30

154 lines
7.8 KiB
Python

"""ارسال پیامک از طریق سرویس لینکی 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)