2025-10-24 16:19:58 +03:00
|
|
|
|
"""
|
|
|
|
|
|
SMS Service для отправки кодов верификации (SigmaSMS)
|
|
|
|
|
|
"""
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
import random
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from typing import Optional
|
|
|
|
|
|
from ..config import settings
|
|
|
|
|
|
from .redis_service import redis_service
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SMSService:
|
|
|
|
|
|
"""Сервис для работы с SMS через SigmaSMS API"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.api_url = settings.sms_api_url
|
|
|
|
|
|
self.login = settings.sms_login
|
|
|
|
|
|
self.password = settings.sms_password
|
|
|
|
|
|
self.token = settings.sms_token
|
|
|
|
|
|
self.sender = settings.sms_sender
|
|
|
|
|
|
self.enabled = settings.sms_enabled
|
|
|
|
|
|
|
|
|
|
|
|
async def _get_token(self) -> Optional[str]:
|
|
|
|
|
|
"""Получить JWT токен для API"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
|
response = await client.post(
|
|
|
|
|
|
f"{self.api_url}login",
|
|
|
|
|
|
json={
|
|
|
|
|
|
"username": self.login,
|
|
|
|
|
|
"password": self.password
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=10.0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
return data.get("token")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Failed to get SMS token: {response.status_code}")
|
|
|
|
|
|
return self.token # Используем токен из .env как fallback
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error getting SMS token: {e}")
|
|
|
|
|
|
return self.token
|
|
|
|
|
|
|
|
|
|
|
|
def generate_code(self) -> str:
|
|
|
|
|
|
"""Генерировать 6-значный код"""
|
|
|
|
|
|
return str(random.randint(100000, 999999))
|
|
|
|
|
|
|
|
|
|
|
|
async def send_sms(self, phone: str, message: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Отправить SMS
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
phone: Номер телефона (формат: +79001234567)
|
|
|
|
|
|
message: Текст сообщения
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
bool: True если отправлено успешно
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.enabled:
|
|
|
|
|
|
logger.warning("SMS отправка отключена в конфигурации")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2025-10-24 20:27:10 +03:00
|
|
|
|
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
|
|
|
|
|
|
if settings.debug or settings.app_env == "development":
|
|
|
|
|
|
logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
|
|
|
|
|
|
logger.info(f"📱 Message would be: {message}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2025-10-24 16:19:58 +03:00
|
|
|
|
try:
|
|
|
|
|
|
# Получаем актуальный токен
|
|
|
|
|
|
token = await self._get_token()
|
|
|
|
|
|
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
logger.error("No SMS token available")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Очищаем номер телефона
|
|
|
|
|
|
clean_phone = phone.replace("+", "").replace("-", "").replace(" ", "")
|
|
|
|
|
|
|
|
|
|
|
|
# Отправляем SMS
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
|
response = await client.post(
|
|
|
|
|
|
f"{self.api_url}sendings",
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"Authorization": f"Bearer {token}"
|
|
|
|
|
|
},
|
|
|
|
|
|
json={
|
|
|
|
|
|
"recipient": clean_phone,
|
|
|
|
|
|
"type": "sms",
|
|
|
|
|
|
"payload": {
|
|
|
|
|
|
"sender": self.sender,
|
|
|
|
|
|
"text": message
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
timeout=15.0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if response.status_code in [200, 201]:
|
|
|
|
|
|
logger.info(f"✅ SMS sent to {phone}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"Failed to send SMS: {response.status_code} - {response.text}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"Error sending SMS: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
async def send_verification_code(self, phone: str) -> Optional[str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Отправить код верификации на телефон
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
phone: Номер телефона
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
str: Код верификации (для отладки) или None при ошибке
|
|
|
|
|
|
"""
|
|
|
|
|
|
# Проверка rate limiting (не больше 1 SMS в минуту на номер)
|
|
|
|
|
|
rate_limit_key = f"sms_rate:{phone}"
|
|
|
|
|
|
|
|
|
|
|
|
if await redis_service.exists(rate_limit_key):
|
|
|
|
|
|
ttl = await redis_service.client.ttl(f"{settings.redis_prefix}{rate_limit_key}")
|
|
|
|
|
|
logger.warning(f"Rate limit for {phone}, retry in {ttl} seconds")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Генерируем код
|
|
|
|
|
|
code = self.generate_code()
|
|
|
|
|
|
|
|
|
|
|
|
# Сохраняем код в Redis на 10 минут
|
|
|
|
|
|
verification_key = f"sms_verify:{phone}"
|
|
|
|
|
|
await redis_service.set(verification_key, code, expire=600) # 10 минут
|
|
|
|
|
|
|
|
|
|
|
|
# Устанавливаем rate limit на 60 секунд
|
|
|
|
|
|
await redis_service.set(rate_limit_key, "1", expire=60)
|
|
|
|
|
|
|
|
|
|
|
|
# Формируем сообщение
|
|
|
|
|
|
message = f"Ваш код подтверждения: {code}. Действителен 10 минут."
|
|
|
|
|
|
|
|
|
|
|
|
# Отправляем SMS
|
|
|
|
|
|
success = await self.send_sms(phone, message)
|
|
|
|
|
|
|
|
|
|
|
|
if success:
|
|
|
|
|
|
logger.info(f"Verification code sent to {phone}")
|
|
|
|
|
|
return code # Возвращаем для отладки
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Удаляем код если не удалось отправить
|
|
|
|
|
|
await redis_service.delete(verification_key)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def verify_code(self, phone: str, code: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Проверить код верификации
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
phone: Номер телефона
|
|
|
|
|
|
code: Код для проверки
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
bool: True если код верный
|
|
|
|
|
|
"""
|
|
|
|
|
|
verification_key = f"sms_verify:{phone}"
|
|
|
|
|
|
stored_code = await redis_service.get(verification_key)
|
|
|
|
|
|
|
|
|
|
|
|
if not stored_code:
|
|
|
|
|
|
logger.warning(f"No verification code found for {phone}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if stored_code == code:
|
|
|
|
|
|
# Удаляем код после успешной проверки
|
|
|
|
|
|
await redis_service.delete(verification_key)
|
|
|
|
|
|
logger.info(f"✅ Code verified for {phone}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"Invalid code for {phone}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Глобальный экземпляр
|
|
|
|
|
|
sms_service = SMSService()
|
|
|
|
|
|
|