154 lines
7.8 KiB
Python
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)
|