eb18994b7d
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC) - Migrate 30+ datetime.now() without UTC to datetime.now(UTC) - Convert all 170 DateTime columns to DateTime(timezone=True) - Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety - Remove 70+ .replace(tzinfo=None) workarounds - Fix utcfromtimestamp → fromtimestamp(..., tz=UTC) - Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics) - Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo) - Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics) - Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring) - Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC) - Rename panel_datetime_to_naive_utc → panel_datetime_to_utc - Remove DTZ003 from ruff ignore list
503 lines
23 KiB
Python
503 lines
23 KiB
Python
from collections.abc import Awaitable, Callable
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import redis.asyncio as aioredis
|
|
import structlog
|
|
from aiogram import BaseMiddleware, Bot, types
|
|
from aiogram.enums import ChatMemberStatus
|
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
|
|
|
from app.config import settings
|
|
from app.database.crud.campaign import get_campaign_by_start_parameter
|
|
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
|
from app.database.crud.user import get_user_by_telegram_id
|
|
from app.database.database import AsyncSessionLocal
|
|
from app.database.models import SubscriptionStatus, UserStatus
|
|
from app.keyboards.inline import get_channel_sub_keyboard
|
|
from app.localization.loader import DEFAULT_LANGUAGE
|
|
from app.localization.texts import get_texts
|
|
from app.services.admin_notification_service import AdminNotificationService
|
|
from app.services.subscription_service import SubscriptionService
|
|
from app.utils.check_reg_process import is_registration_process
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
|
|
REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:'
|
|
REDIS_PAYLOAD_TTL = 3600 # 1 час
|
|
|
|
|
|
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
|
|
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
|
|
try:
|
|
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
|
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
|
|
await redis_client.aclose()
|
|
logger.info(
|
|
"💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.error('❌ [Redis fallback] Ошибка сохранения payload для', telegram_id=telegram_id, e=e)
|
|
return False
|
|
|
|
|
|
async def get_pending_payload_from_redis(telegram_id: int) -> str | None:
|
|
"""Получает pending_start_payload из Redis (резервный механизм)."""
|
|
try:
|
|
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
|
payload = await redis_client.get(key)
|
|
await redis_client.aclose()
|
|
if payload:
|
|
return payload.decode('utf-8') if isinstance(payload, bytes) else payload
|
|
return None
|
|
except Exception as e:
|
|
logger.debug('❌ [Redis fallback] Ошибка получения payload для', telegram_id=telegram_id, e=e)
|
|
return None
|
|
|
|
|
|
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
|
|
"""Удаляет pending_start_payload из Redis."""
|
|
try:
|
|
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
|
await redis_client.delete(key)
|
|
await redis_client.aclose()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ChannelCheckerMiddleware(BaseMiddleware):
|
|
"""
|
|
Middleware для проверки подписки на канал.
|
|
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED)
|
|
self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
|
|
logger.info('🔧 ChannelCheckerMiddleware инициализирован')
|
|
|
|
async def __call__(
|
|
self,
|
|
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
|
event: TelegramObject,
|
|
data: dict[str, Any],
|
|
) -> Any:
|
|
telegram_id = None
|
|
if isinstance(event, (Message, CallbackQuery)):
|
|
telegram_id = event.from_user.id
|
|
elif isinstance(event, Update):
|
|
if event.message:
|
|
telegram_id = event.message.from_user.id
|
|
elif event.callback_query:
|
|
telegram_id = event.callback_query.from_user.id
|
|
|
|
if telegram_id is None:
|
|
logger.debug('❌ telegram_id не найден, пропускаем')
|
|
return await handler(event, data)
|
|
|
|
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
|
if isinstance(event, CallbackQuery) and event.data in (
|
|
'webhook:close',
|
|
'ban_notify:delete',
|
|
'noop',
|
|
'current_page',
|
|
):
|
|
return await handler(event, data)
|
|
|
|
# Админам разрешаем пропускать проверку подписки
|
|
if settings.is_admin(telegram_id):
|
|
logger.debug(
|
|
'✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id
|
|
)
|
|
return await handler(event, data)
|
|
|
|
state: FSMContext = data.get('state')
|
|
current_state = None
|
|
|
|
if state:
|
|
current_state = await state.get_state()
|
|
|
|
is_reg_process = is_registration_process(event, current_state)
|
|
|
|
if is_reg_process:
|
|
logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку')
|
|
return await handler(event, data)
|
|
|
|
bot: Bot = data['bot']
|
|
|
|
channel_id = settings.CHANNEL_SUB_ID
|
|
|
|
if not channel_id:
|
|
logger.warning('⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку')
|
|
return await handler(event, data)
|
|
|
|
is_required = settings.CHANNEL_IS_REQUIRED_SUB
|
|
|
|
if not is_required:
|
|
logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку')
|
|
return await handler(event, data)
|
|
|
|
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
|
|
|
|
if not channel_link:
|
|
logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта')
|
|
|
|
try:
|
|
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
|
|
|
if member.status in self.GOOD_MEMBER_STATUS:
|
|
# Реактивируем подписку если была отключена из-за отписки от канала
|
|
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
|
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
|
return await handler(event, data)
|
|
if member.status in self.BAD_MEMBER_STATUS:
|
|
logger.info(
|
|
'❌ Пользователь не подписан на канал (статус: )', telegram_id=telegram_id, status=member.status
|
|
)
|
|
|
|
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
|
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
|
|
|
|
await self._capture_start_payload(state, event, bot)
|
|
|
|
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
|
|
await event.answer(
|
|
'❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.', show_alert=True
|
|
)
|
|
return None
|
|
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
logger.warning('⚠️ Неожиданный статус пользователя', telegram_id=telegram_id, status=member.status)
|
|
await self._capture_start_payload(state, event, bot)
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
|
|
except TelegramForbiddenError as e:
|
|
logger.error('❌ Бот заблокирован в канале', channel_id=channel_id, error=e)
|
|
await self._capture_start_payload(state, event, bot)
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
except TelegramBadRequest as e:
|
|
if 'chat not found' in str(e).lower():
|
|
logger.error('❌ Канал не найден', channel_id=channel_id, error=e)
|
|
elif 'user not found' in str(e).lower():
|
|
logger.error('❌ Пользователь не найден', telegram_id=telegram_id, error=e)
|
|
else:
|
|
logger.error('❌ Ошибка запроса к каналу', channel_id=channel_id, error=e)
|
|
await self._capture_start_payload(state, event, bot)
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
except TelegramNetworkError as e:
|
|
logger.warning('⚠️ Таймаут при проверке подписки на канал', error=e)
|
|
return await handler(event, data)
|
|
except Exception as e:
|
|
logger.error('❌ Неожиданная ошибка при проверке подписки', error=e)
|
|
return await handler(event, data)
|
|
|
|
@staticmethod
|
|
def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None:
|
|
link = (channel_link or '').strip()
|
|
|
|
if link.startswith('@'): # raw username
|
|
return f'https://t.me/{link.lstrip("@")}'
|
|
|
|
if link and not link.lower().startswith(('http://', 'https://', 'tg://')):
|
|
return f'https://{link}'
|
|
|
|
if link:
|
|
return link
|
|
|
|
if channel_id and str(channel_id).startswith('@'):
|
|
return f'https://t.me/{str(channel_id).lstrip("@")}'
|
|
|
|
return None
|
|
|
|
async def _capture_start_payload(
|
|
self,
|
|
state: FSMContext | None,
|
|
event: TelegramObject,
|
|
bot: Bot | None = None,
|
|
) -> None:
|
|
telegram_id = None
|
|
if isinstance(event, (Message, CallbackQuery)):
|
|
telegram_id = event.from_user.id if event.from_user else None
|
|
|
|
message: Message | None = None
|
|
if isinstance(event, Message):
|
|
message = event
|
|
elif isinstance(event, (CallbackQuery, Update)):
|
|
message = event.message
|
|
|
|
if not message or not message.text:
|
|
return
|
|
|
|
text = message.text.strip()
|
|
if not text.startswith('/start'):
|
|
return
|
|
|
|
parts = text.split(maxsplit=1)
|
|
if len(parts) < 2 or not parts[1]:
|
|
return
|
|
|
|
payload = parts[1]
|
|
|
|
# Сохраняем в FSM state
|
|
if state:
|
|
state_data = await state.get_data() or {}
|
|
if state_data.get('pending_start_payload') != payload:
|
|
state_data['pending_start_payload'] = payload
|
|
await state.set_data(state_data)
|
|
logger.info(
|
|
"💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id
|
|
)
|
|
else:
|
|
logger.warning('⚠️ _capture_start_payload: state=None для пользователя', telegram_id=telegram_id)
|
|
|
|
# Также сохраняем в Redis как резерв (на случай потери FSM state)
|
|
if telegram_id:
|
|
await save_pending_payload_to_redis(telegram_id, payload)
|
|
|
|
if bot and message.from_user:
|
|
await self._try_send_campaign_visit_notification(
|
|
bot,
|
|
message.from_user,
|
|
state,
|
|
payload,
|
|
)
|
|
|
|
async def _try_send_campaign_visit_notification(
|
|
self,
|
|
bot: Bot,
|
|
telegram_user: types.User,
|
|
state: FSMContext,
|
|
payload: str,
|
|
) -> None:
|
|
try:
|
|
state_data = await state.get_data() or {}
|
|
except Exception as error:
|
|
logger.error(
|
|
'❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error
|
|
)
|
|
return
|
|
|
|
if state_data.get('campaign_notification_sent'):
|
|
return
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
campaign = await get_campaign_by_start_parameter(
|
|
db,
|
|
payload,
|
|
only_active=True,
|
|
)
|
|
if not campaign:
|
|
return
|
|
|
|
user = await get_user_by_telegram_id(db, telegram_user.id)
|
|
|
|
notification_service = AdminNotificationService(bot)
|
|
sent = await notification_service.send_campaign_link_visit_notification(
|
|
db,
|
|
telegram_user,
|
|
campaign,
|
|
user,
|
|
)
|
|
if sent:
|
|
await state.update_data(campaign_notification_sent=True)
|
|
await db.commit()
|
|
except Exception as error:
|
|
logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=error)
|
|
await db.rollback()
|
|
|
|
async def _deactivate_subscription_on_unsubscribe(
|
|
self, telegram_id: int, bot: Bot, channel_link: str | None
|
|
) -> None:
|
|
"""Деактивация подписки при отписке от канала."""
|
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
|
return
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
user = await get_user_by_telegram_id(db, telegram_id)
|
|
if not user or not user.subscription:
|
|
return
|
|
|
|
subscription = user.subscription
|
|
|
|
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
|
return
|
|
|
|
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
|
pass
|
|
elif not subscription.is_trial:
|
|
return
|
|
|
|
await deactivate_subscription(db, subscription)
|
|
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
|
logger.info(
|
|
'🚫 подписка пользователя отключена после отписки от канала',
|
|
sub_type=sub_type,
|
|
telegram_id=telegram_id,
|
|
)
|
|
|
|
if user.remnawave_uuid:
|
|
service = SubscriptionService()
|
|
try:
|
|
await service.disable_remnawave_user(user.remnawave_uuid)
|
|
except Exception as api_error:
|
|
logger.error(
|
|
'❌ Не удалось отключить пользователя RemnaWave',
|
|
remnawave_uuid=user.remnawave_uuid,
|
|
api_error=api_error,
|
|
)
|
|
|
|
# Уведомляем пользователя о деактивации
|
|
try:
|
|
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
|
notification_text = texts.t(
|
|
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
|
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
|
|
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
|
|
)
|
|
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
|
|
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
|
|
except Exception as notify_error:
|
|
logger.error(
|
|
'❌ Не удалось отправить уведомление о деактивации пользователю',
|
|
telegram_id=telegram_id,
|
|
notify_error=notify_error,
|
|
)
|
|
await db.commit()
|
|
except Exception as db_error:
|
|
logger.error(
|
|
'❌ Ошибка деактивации подписки пользователя после отписки',
|
|
telegram_id=telegram_id,
|
|
db_error=db_error,
|
|
)
|
|
await db.rollback()
|
|
|
|
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
|
"""Реактивация подписки после повторной подписки на канал."""
|
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
|
return
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
user = await get_user_by_telegram_id(db, telegram_id)
|
|
if not user or not user.subscription:
|
|
return
|
|
|
|
# НЕ реактивируем подписку заблокированным пользователям
|
|
if user.status == UserStatus.BLOCKED.value:
|
|
logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id)
|
|
return
|
|
|
|
subscription = user.subscription
|
|
|
|
# Реактивируем только DISABLED подписки
|
|
if subscription.status != SubscriptionStatus.DISABLED.value:
|
|
return
|
|
|
|
# Проверяем что подписка ещё не истекла
|
|
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
|
return
|
|
|
|
# Реактивируем в БД
|
|
await reactivate_subscription(db, subscription)
|
|
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
|
logger.info(
|
|
'✅ подписка пользователя реактивирована после подписки на канал',
|
|
sub_type=sub_type,
|
|
telegram_id=telegram_id,
|
|
)
|
|
|
|
# Включаем в RemnaWave
|
|
if user.remnawave_uuid:
|
|
service = SubscriptionService()
|
|
try:
|
|
await service.enable_remnawave_user(user.remnawave_uuid)
|
|
except Exception as api_error:
|
|
logger.error(
|
|
'❌ Не удалось включить пользователя RemnaWave',
|
|
remnawave_uuid=user.remnawave_uuid,
|
|
api_error=api_error,
|
|
)
|
|
|
|
# Уведомляем пользователя о реактивации
|
|
try:
|
|
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
|
notification_text = texts.t(
|
|
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
|
|
'✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.',
|
|
)
|
|
await bot.send_message(telegram_id, notification_text)
|
|
except Exception as notify_error:
|
|
logger.warning(
|
|
'Не удалось отправить уведомление о реактивации пользователю',
|
|
telegram_id=telegram_id,
|
|
notify_error=notify_error,
|
|
)
|
|
await db.commit()
|
|
except Exception as db_error:
|
|
logger.error('❌ Ошибка реактивации подписки пользователя', telegram_id=telegram_id, db_error=db_error)
|
|
await db.rollback()
|
|
|
|
@staticmethod
|
|
async def _deny_message(
|
|
event: TelegramObject,
|
|
bot: Bot,
|
|
channel_link: str | None,
|
|
channel_id: str | None,
|
|
):
|
|
logger.debug('🚫 Отправляем сообщение о необходимости подписки')
|
|
|
|
user = None
|
|
if isinstance(event, (Message, CallbackQuery)):
|
|
user = getattr(event, 'from_user', None)
|
|
elif isinstance(event, Update):
|
|
if event.message and event.message.from_user:
|
|
user = event.message.from_user
|
|
elif event.callback_query and event.callback_query.from_user:
|
|
user = event.callback_query.from_user
|
|
|
|
language = DEFAULT_LANGUAGE
|
|
if user and user.language_code:
|
|
language = user.language_code.split('-')[0]
|
|
|
|
texts = get_texts(language)
|
|
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
|
|
text = texts.t(
|
|
'CHANNEL_REQUIRED_TEXT',
|
|
'🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
|
|
)
|
|
|
|
if not channel_link and channel_id:
|
|
channel_hint = None
|
|
|
|
if str(channel_id).startswith('@'): # username-based channel id
|
|
channel_hint = f'@{str(channel_id).lstrip("@")}'
|
|
|
|
if channel_hint:
|
|
text = f'{text}\n\n{channel_hint}'
|
|
|
|
try:
|
|
if isinstance(event, Message):
|
|
return await event.answer(text, reply_markup=channel_sub_kb)
|
|
if isinstance(event, CallbackQuery):
|
|
try:
|
|
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
|
except TelegramBadRequest as e:
|
|
if 'message is not modified' in str(e).lower():
|
|
logger.debug('ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование')
|
|
return await event.answer(text, show_alert=True)
|
|
raise
|
|
elif isinstance(event, Update) and event.message:
|
|
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
|
except Exception as e:
|
|
logger.error('❌ Ошибка при отправке сообщения о подписке', error=e)
|