363 lines
15 KiB
Python
363 lines
15 KiB
Python
import logging
|
|
from typing import Callable, Dict, Any, Awaitable, Optional
|
|
from aiogram import BaseMiddleware, Bot, types
|
|
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
|
from aiogram.enums import ChatMemberStatus
|
|
|
|
from app.config import settings
|
|
from app.database.database import get_db
|
|
from app.database.crud.campaign import get_campaign_by_start_parameter
|
|
from app.database.crud.subscription import deactivate_subscription
|
|
from app.database.crud.user import get_user_by_telegram_id
|
|
from app.database.models import SubscriptionStatus
|
|
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.utils.check_reg_process import is_registration_process
|
|
from app.services.subscription_service import SubscriptionService
|
|
from app.services.admin_notification_service import AdminNotificationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ChannelCheckerMiddleware(BaseMiddleware):
|
|
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)
|
|
|
|
|
|
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
|
|
# работу панели управления даже при отсутствии подписки. Важно делать
|
|
# это до обращения к состоянию, чтобы не выполнять лишние операции.
|
|
if settings.is_admin(telegram_id):
|
|
logger.debug(
|
|
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
|
|
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:
|
|
return await handler(event, data)
|
|
elif member.status in self.BAD_MEMBER_STATUS:
|
|
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
|
|
|
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
|
await self._deactivate_trial_subscription(telegram_id)
|
|
|
|
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
|
|
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
else:
|
|
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {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(f"❌ Бот заблокирован в канале {channel_id}: {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(f"❌ Канал {channel_id} не найден: {e}")
|
|
elif "user not found" in str(e).lower():
|
|
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
|
else:
|
|
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
|
await self._capture_start_payload(state, event, bot)
|
|
return await self._deny_message(event, bot, channel_link, channel_id)
|
|
except Exception as e:
|
|
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
|
return await handler(event, data)
|
|
|
|
@staticmethod
|
|
def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]:
|
|
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: Optional[FSMContext],
|
|
event: TelegramObject,
|
|
bot: Optional[Bot] = None,
|
|
) -> None:
|
|
if not state:
|
|
return
|
|
|
|
message: Optional[Message] = None
|
|
if isinstance(event, Message):
|
|
message = event
|
|
elif isinstance(event, CallbackQuery):
|
|
message = event.message
|
|
elif isinstance(event, 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]
|
|
|
|
data = await state.get_data() or {}
|
|
if data.get("pending_start_payload") != payload:
|
|
data["pending_start_payload"] = payload
|
|
await state.set_data(data)
|
|
logger.debug("💾 Сохранен start payload %s для последующей обработки", 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:
|
|
data = await state.get_data() or {}
|
|
except Exception as error:
|
|
logger.error(
|
|
"❌ Не удалось получить данные состояния для уведомления по кампании %s: %s",
|
|
payload,
|
|
error,
|
|
)
|
|
return
|
|
|
|
if data.get("campaign_notification_sent"):
|
|
return
|
|
|
|
async for db in get_db():
|
|
try:
|
|
campaign = await get_campaign_by_start_parameter(
|
|
db,
|
|
payload,
|
|
only_active=True,
|
|
)
|
|
if not campaign:
|
|
break
|
|
|
|
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)
|
|
break
|
|
except Exception as error:
|
|
logger.error(
|
|
"❌ Ошибка отправки уведомления о переходе по кампании %s: %s",
|
|
payload,
|
|
error,
|
|
)
|
|
finally:
|
|
break
|
|
|
|
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
|
logger.debug(
|
|
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
|
|
telegram_id,
|
|
)
|
|
return
|
|
|
|
async for db in get_db():
|
|
try:
|
|
user = await get_user_by_telegram_id(db, telegram_id)
|
|
if not user or not user.subscription:
|
|
logger.debug(
|
|
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
|
|
telegram_id,
|
|
)
|
|
break
|
|
|
|
subscription = user.subscription
|
|
if (not subscription.is_trial or
|
|
subscription.status != SubscriptionStatus.ACTIVE.value):
|
|
logger.debug(
|
|
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
|
telegram_id,
|
|
subscription.is_trial,
|
|
subscription.status,
|
|
)
|
|
break
|
|
|
|
await deactivate_subscription(db, subscription)
|
|
logger.info(
|
|
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
|
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 %s: %s",
|
|
user.remnawave_uuid,
|
|
api_error,
|
|
)
|
|
except Exception as db_error:
|
|
logger.error(
|
|
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
|
|
telegram_id,
|
|
db_error,
|
|
)
|
|
finally:
|
|
break
|
|
|
|
@staticmethod
|
|
async def _deny_message(
|
|
event: TelegramObject,
|
|
bot: Bot,
|
|
channel_link: Optional[str],
|
|
channel_id: Optional[str],
|
|
):
|
|
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)
|
|
elif 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(f"❌ Ошибка при отправке сообщения о подписке: {e}")
|