mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
214 lines
7.9 KiB
Python
214 lines
7.9 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import importlib.util
|
||
from typing import Any
|
||
|
||
from app.core.config import settings
|
||
|
||
|
||
class SmsDeliveryError(Exception):
|
||
pass
|
||
|
||
|
||
def _otp_dev_mode_enabled() -> bool:
|
||
return bool(getattr(settings, "OTP_DEV_MODE", False))
|
||
|
||
|
||
def _module_available(module_name: str) -> bool:
|
||
return importlib.util.find_spec(module_name) is not None
|
||
|
||
|
||
def _normalize_phone_to_int(phone: str) -> int:
|
||
digits = "".join(ch for ch in str(phone or "") if ch.isdigit())
|
||
if not digits:
|
||
raise SmsDeliveryError("Некорректный номер телефона")
|
||
try:
|
||
return int(digits)
|
||
except ValueError as exc:
|
||
raise SmsDeliveryError("Некорректный номер телефона") from exc
|
||
|
||
|
||
def _build_otp_message(*, code: str, purpose: str, track_number: str | None) -> str:
|
||
template = str(settings.OTP_SMS_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}"
|
||
try:
|
||
rendered = template.format(code=code, purpose=purpose, track_number=track_number or "")
|
||
except Exception:
|
||
rendered = f"Ваш код подтверждения: {code}"
|
||
return rendered
|
||
|
||
|
||
def _mock_sms_send(*, phone: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]:
|
||
print(f"[OTP MOCK] purpose={purpose} phone={phone} track={track_number or '-'} code={code}")
|
||
return {
|
||
"provider": "mock_sms",
|
||
"status": "accepted",
|
||
"message": "SMS provider response mocked",
|
||
"sent": False,
|
||
"mocked": True,
|
||
}
|
||
|
||
|
||
async def _send_sms_aero_async(*, phone: int, message: str) -> dict[str, Any]:
|
||
try:
|
||
import smsaero
|
||
except Exception as exc: # pragma: no cover - runtime dependency branch
|
||
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
|
||
|
||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||
if not email or not api_key:
|
||
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
|
||
|
||
api = smsaero.SmsAero(email, api_key)
|
||
try:
|
||
result = await api.send_sms(phone, message)
|
||
except Exception as exc: # pragma: no cover - network/runtime branch
|
||
raise SmsDeliveryError(f"Ошибка отправки SMS через SMS Aero: {exc}") from exc
|
||
finally:
|
||
await api.close_session()
|
||
return {
|
||
"provider": "smsaero",
|
||
"status": "accepted",
|
||
"message": "SMS отправлено",
|
||
"sent": True,
|
||
"response": result,
|
||
}
|
||
|
||
|
||
def _send_sms_aero(*, phone: str, message: str) -> dict[str, Any]:
|
||
phone_int = _normalize_phone_to_int(phone)
|
||
return asyncio.run(_send_sms_aero_async(phone=phone_int, message=message))
|
||
|
||
|
||
async def _get_sms_aero_balance_async() -> dict[str, Any]:
|
||
try:
|
||
import smsaero
|
||
except Exception as exc: # pragma: no cover - runtime dependency branch
|
||
raise SmsDeliveryError("Библиотека smsaero-api-async не установлена") from exc
|
||
|
||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||
if not email or not api_key:
|
||
raise SmsDeliveryError("Не заданы SMSAERO_EMAIL и/или SMSAERO_API_KEY")
|
||
|
||
api = smsaero.SmsAero(email, api_key)
|
||
try:
|
||
result = await api.balance()
|
||
except Exception as exc: # pragma: no cover - network/runtime branch
|
||
raise SmsDeliveryError(f"Ошибка получения баланса SMS Aero: {exc}") from exc
|
||
finally:
|
||
await api.close_session()
|
||
return dict(result or {})
|
||
|
||
|
||
def _get_sms_aero_balance() -> tuple[float | None, dict[str, Any] | None, str | None]:
|
||
try:
|
||
raw = _get_sms_aero_balance_async()
|
||
data = asyncio.run(raw)
|
||
amount = data.get("balance")
|
||
number = float(amount)
|
||
return number, data, None
|
||
except Exception as exc:
|
||
return None, None, str(exc)
|
||
|
||
|
||
def sms_provider_health() -> dict[str, Any]:
|
||
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
||
if _otp_dev_mode_enabled():
|
||
return {
|
||
"provider": provider or "dummy",
|
||
"effective_provider": "mock_sms",
|
||
"status": "ok",
|
||
"mode": "mock",
|
||
"dev_mode": True,
|
||
"can_send": True,
|
||
"balance_available": False,
|
||
"balance_amount": None,
|
||
"balance_currency": "RUB",
|
||
"checks": {"otp_dev_mode": True},
|
||
"issues": ["OTP_DEV_MODE включен: реальная SMS-рассылка отключена"],
|
||
}
|
||
if provider in {"", "dummy", "mock", "console"}:
|
||
return {
|
||
"provider": "dummy",
|
||
"status": "ok",
|
||
"mode": "mock",
|
||
"dev_mode": False,
|
||
"can_send": True,
|
||
"balance_available": False,
|
||
"balance_amount": None,
|
||
"balance_currency": "RUB",
|
||
"checks": {"mock_mode": True},
|
||
"issues": [],
|
||
}
|
||
|
||
if provider in {"smsaero", "sms_aero"}:
|
||
email = str(settings.SMSAERO_EMAIL or "").strip()
|
||
api_key = str(settings.SMSAERO_API_KEY or "").strip()
|
||
installed = _module_available("smsaero")
|
||
checks = {
|
||
"smsaero_installed": bool(installed),
|
||
"email_configured": bool(email),
|
||
"api_key_configured": bool(api_key),
|
||
}
|
||
issues: list[str] = []
|
||
if not checks["smsaero_installed"]:
|
||
issues.append("Не установлена библиотека smsaero-api-async")
|
||
if not checks["email_configured"]:
|
||
issues.append("Не задан SMSAERO_EMAIL")
|
||
if not checks["api_key_configured"]:
|
||
issues.append("Не задан SMSAERO_API_KEY")
|
||
can_send = all(checks.values())
|
||
balance_available = False
|
||
balance_amount: float | None = None
|
||
balance_raw: dict[str, Any] | None = None
|
||
if can_send:
|
||
amount, raw_balance, balance_error = _get_sms_aero_balance()
|
||
if amount is None:
|
||
issues.append(str(balance_error or "Не удалось получить баланс SMS Aero"))
|
||
else:
|
||
balance_available = True
|
||
balance_amount = amount
|
||
balance_raw = raw_balance
|
||
return {
|
||
"provider": "smsaero",
|
||
"status": "ok" if can_send and balance_available else "degraded",
|
||
"mode": "real",
|
||
"dev_mode": False,
|
||
"can_send": can_send,
|
||
"balance_available": balance_available,
|
||
"balance_amount": balance_amount,
|
||
"balance_currency": "RUB",
|
||
"balance_raw": balance_raw,
|
||
"checks": checks,
|
||
"issues": issues,
|
||
}
|
||
|
||
return {
|
||
"provider": provider,
|
||
"status": "error",
|
||
"mode": "unknown",
|
||
"dev_mode": False,
|
||
"can_send": False,
|
||
"balance_available": False,
|
||
"balance_amount": None,
|
||
"balance_currency": "RUB",
|
||
"checks": {"provider_supported": False},
|
||
"issues": [f"Неизвестный SMS_PROVIDER: {provider}"],
|
||
}
|
||
|
||
|
||
def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]:
|
||
if _otp_dev_mode_enabled():
|
||
payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||
payload["dev_mode"] = True
|
||
return payload
|
||
|
||
provider = str(settings.SMS_PROVIDER or "dummy").strip().lower()
|
||
if provider in {"", "dummy", "mock", "console"}:
|
||
return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||
if provider in {"smsaero", "sms_aero"}:
|
||
message = _build_otp_message(code=code, purpose=purpose, track_number=track_number)
|
||
return _send_sms_aero(phone=phone, message=message)
|
||
raise SmsDeliveryError(f"Неизвестный SMS_PROVIDER: {provider}")
|