From 171634522ea06a7b9b43f5aa459428134afbd934 Mon Sep 17 00:00:00 2001 From: Vladless Date: Fri, 25 Jul 2025 05:40:45 +0300 Subject: [PATCH] support invite links/ shadow bans/ bulk preemptive bans --- handlers/admin/bans/bans_handler.py | 61 +++++++++++++- handlers/admin/bans/keyboard.py | 11 ++- handlers/admin/users/keyboard.py | 39 +++++++++ handlers/admin/users/users_handler.py | 113 ++++++++++++++++++++++++-- middlewares/__init__.py | 14 +++- middlewares/ban_checker.py | 4 + middlewares/direct_start_blocker.py | 57 +++++++++++++ 7 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 middlewares/direct_start_blocker.py diff --git a/handlers/admin/bans/bans_handler.py b/handlers/admin/bans/bans_handler.py index 39e6ea67..215f96ff 100644 --- a/handlers/admin/bans/bans_handler.py +++ b/handlers/admin/bans/bans_handler.py @@ -1,10 +1,14 @@ import csv import io +from datetime import datetime, timezone +from aiogram.fsm.context import FSMContext +from sqlalchemy.dialects.postgresql import insert as pg_insert from aiogram import F, Router -from aiogram.types import BufferedInputFile, CallbackQuery +from aiogram.types import BufferedInputFile, CallbackQuery, Message from sqlalchemy import delete, text from sqlalchemy.ext.asyncio import AsyncSession +from aiogram.fsm.state import State, StatesGroup from database import delete_user_data from database.models import ManualBan @@ -18,6 +22,10 @@ from .keyboard import build_bans_kb router = Router() +class PreemptiveBanStates(StatesGroup): + waiting_for_preemptive_ids = State() + + @router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter()) async def handle_bans(callback_query: CallbackQuery): text_ = ( @@ -133,3 +141,54 @@ async def handle_delete_manual_banned(callback_query: CallbackQuery, session: As except Exception as e: logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}") await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.") + + +@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter()) +async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext): + await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids) + await callback.message.edit_text( + "📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n" + "Пример:\n123456789\n987654321" + ) + + +@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter()) +async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession): + lines = message.text.strip().splitlines() + tg_ids = set() + + for line in lines: + line = line.strip() + if line.isdigit(): + tg_ids.add(int(line)) + + if not tg_ids: + await message.answer("❌ Не найдено ни одного корректного Telegram ID.") + return + + now = datetime.now(timezone.utc) + + stmt = pg_insert(ManualBan).values([ + { + "tg_id": tg_id, + "reason": "shadow", + "banned_by": message.from_user.id, + "until": None, + "banned_at": now, + } + for tg_id in tg_ids + ]).on_conflict_do_update( + index_elements=[ManualBan.tg_id], + set_={ + "reason": "shadow", + "until": None, + "banned_by": message.from_user.id, + "banned_at": now, + }, + ) + + await session.execute(stmt) + await session.commit() + + await message.answer(f"✅ Успешно добавлено в теневой бан: {len(tg_ids)} пользователей.") + await state.clear() diff --git a/handlers/admin/bans/keyboard.py b/handlers/admin/bans/keyboard.py index 861ddf9d..ab6a9d75 100644 --- a/handlers/admin/bans/keyboard.py +++ b/handlers/admin/bans/keyboard.py @@ -15,15 +15,22 @@ def build_bans_kb(): text="📛 Забаненные вручную", callback_data=AdminPanelCallback(action="manual_bans_export").pack(), ) + builder.button( + text="Забанить заранее", + callback_data=AdminPanelCallback(action="bans_preemptive").pack(), + ) builder.button( text="🗑️ Удалить забанивших", callback_data=AdminPanelCallback(action="bans_delete_banned").pack(), ) builder.button( - text="🗑️ Очистить вручную забаненных", + text="🗑️ Очистить забаненных", callback_data=AdminPanelCallback(action="bans_delete_manual").pack(), ) - builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()) + builder.button( + text="🔙 Назад", + callback_data=AdminPanelCallback(action="management").pack(), + ) builder.adjust(1) return builder.as_markup() diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py index 98040035..3f1f79e6 100644 --- a/handlers/admin/users/keyboard.py +++ b/handlers/admin/users/keyboard.py @@ -342,3 +342,42 @@ async def build_cluster_selection_kb(session, tg_id: int, email: str, action: st ) builder.adjust(1) return builder.as_markup() + + +def build_user_ban_type_kb(tg_id: int) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.row( + InlineKeyboardButton( + text="⛔ Навсегда", + callback_data=AdminUserEditorCallback( + action="users_ban_forever", tg_id=tg_id + ).pack(), + ), + InlineKeyboardButton( + text="⏳ По сроку", + callback_data=AdminUserEditorCallback( + action="users_ban_temporary", tg_id=tg_id + ).pack(), + ), + ) + + builder.row( + InlineKeyboardButton( + text="👻 Теневой бан", + callback_data=AdminUserEditorCallback( + action="users_ban_shadow", tg_id=tg_id + ).pack(), + ) + ) + + builder.row( + InlineKeyboardButton( + text="⬅️ Назад", + callback_data=AdminUserEditorCallback( + action="users_editor", tg_id=tg_id, edit=True + ).pack(), + ) + ) + + return builder.as_markup() \ No newline at end of file diff --git a/handlers/admin/users/users_handler.py b/handlers/admin/users/users_handler.py index b59fbb7c..0866e1b8 100644 --- a/handlers/admin/users/users_handler.py +++ b/handlers/admin/users/users_handler.py @@ -66,6 +66,7 @@ from .keyboard import ( build_users_balance_kb, build_users_key_expiry_kb, build_users_key_show_kb, + build_user_ban_type_kb ) @@ -94,6 +95,7 @@ class RenewTariffState(StatesGroup): class BanUserStates(StatesGroup): waiting_for_reason = State() waiting_for_ban_duration = State() + waiting_for_forever_reason = State() @router.callback_query(AdminUserEditorCallback.filter(F.action == "users_hwid_menu"), IsAdminFilter()) @@ -1567,6 +1569,73 @@ async def handle_reset_traffic( @router.callback_query(AdminUserEditorCallback.filter(F.action == "users_ban"), IsAdminFilter()) async def handle_user_ban(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext): + await state.clear() + await state.update_data(tg_id=callback_data.tg_id) + + await callback.message.edit_text( + text="🚫 Выберите тип блокировки пользователя:", + reply_markup=build_user_ban_type_kb(callback_data.tg_id), + ) + + +@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_ban_forever"), IsAdminFilter()) +async def handle_ban_forever_start(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext): + await state.set_state(BanUserStates.waiting_for_forever_reason) + await state.update_data(tg_id=callback_data.tg_id) + + kb = InlineKeyboardBuilder() + kb.row(build_editor_btn("⬅️ Назад", tg_id=callback_data.tg_id, edit=True)) + + await callback.message.edit_text( + text="✏️ Введите причину постоянной блокировки (или -, чтобы пропустить):", + reply_markup=kb.as_markup(), + ) + + +@router.message(BanUserStates.waiting_for_forever_reason, IsAdminFilter()) +async def handle_ban_forever_reason_input(message: Message, state: FSMContext, session: AsyncSession): + reason = message.text.strip() + if reason == "-": + reason = None + + user_data = await state.get_data() + tg_id = user_data.get("tg_id") + + stmt = ( + pg_insert(ManualBan) + .values( + tg_id=tg_id, + reason=reason, + banned_by=message.from_user.id, + until=None, + banned_at=datetime.now(timezone.utc), + ) + .on_conflict_do_update( + index_elements=[ManualBan.tg_id], + set_={ + "reason": reason, + "until": None, + "banned_by": message.from_user.id, + "banned_at": datetime.now(timezone.utc), + }, + ) + ) + + await session.execute(stmt) + await session.commit() + await state.clear() + + await message.answer( + text=( + f"✅ Пользователь {tg_id} забанен навсегда." + f"{f'\n📄 Причина: {reason}' if reason else ''}" + ), + reply_markup=build_editor_kb(tg_id, edit=True), + ) + + +@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_ban_temporary"), IsAdminFilter()) +async def handle_ban_temporary(callback: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext): await state.set_state(BanUserStates.waiting_for_reason) await state.update_data(tg_id=callback_data.tg_id) @@ -1574,7 +1643,7 @@ async def handle_user_ban(callback: CallbackQuery, callback_data: AdminUserEdito kb.row(build_editor_btn("⬅️ Назад", tg_id=callback_data.tg_id, edit=True)) await callback.message.edit_text( - text="✏️ Введите причину блокировки (или -, чтобы пропустить):", + text="✏️ Введите причину временной блокировки (или -, чтобы пропустить):", reply_markup=kb.as_markup(), ) @@ -1606,10 +1675,11 @@ async def handle_ban_duration_input(message: Message, state: FSMContext, session try: days = int(message.text.strip()) + if days < 1: + await message.answer("❗ Укажите срок минимум в 1 день.") + return - until = None - if days > 0: - until = datetime.now(timezone.utc) + timedelta(days=days) + until = datetime.now(timezone.utc) + timedelta(days=days) stmt = ( pg_insert(ManualBan) @@ -1635,9 +1705,8 @@ async def handle_ban_duration_input(message: Message, state: FSMContext, session await session.commit() text = ( - f"✅ Пользователь {tg_id} забанен " - f"{'навсегда' if not until else f'до {until:%Y-%m-%d %H:%M}'}." - " Нажмите кнопку ниже для возврата в профиль." + f"✅ Пользователь {tg_id} временно забанен до {until:%Y-%m-%d %H:%M} по UTC." + f"{f'\n📄 Причина: {reason}' if reason else ''}" ) await message.answer(text=text, reply_markup=build_editor_kb(tg_id, edit=True)) @@ -1648,6 +1717,36 @@ async def handle_ban_duration_input(message: Message, state: FSMContext, session await state.clear() +@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_ban_shadow"), IsAdminFilter()) +async def handle_ban_shadow(callback: CallbackQuery, callback_data: AdminUserEditorCallback, session: AsyncSession): + stmt = ( + pg_insert(ManualBan) + .values( + tg_id=callback_data.tg_id, + reason="shadow", + banned_by=callback.from_user.id, + until=None, + banned_at=datetime.now(timezone.utc), + ) + .on_conflict_do_update( + index_elements=[ManualBan.tg_id], + set_={ + "reason": "shadow", + "until": None, + "banned_by": callback.from_user.id, + "banned_at": datetime.now(timezone.utc), + }, + ) + ) + await session.execute(stmt) + await session.commit() + + await callback.message.edit_text( + text=f"👻 Пользователь {callback_data.tg_id} получил теневой бан.", + reply_markup=build_editor_kb(callback_data.tg_id, edit=True), + ) + + @router.callback_query(AdminUserEditorCallback.filter(F.action == "users_unban"), IsAdminFilter()) async def handle_user_unban( callback: CallbackQuery, diff --git a/middlewares/__init__.py b/middlewares/__init__.py index 354f7c94..1375514e 100644 --- a/middlewares/__init__.py +++ b/middlewares/__init__.py @@ -13,6 +13,7 @@ from .maintenance import MaintenanceModeMiddleware from .session import SessionMiddleware from .throttling import ThrottlingMiddleware from .user import UserMiddleware +from .direct_start_blocker import DirectStartBlockerMiddleware def register_middleware( @@ -23,6 +24,10 @@ def register_middleware( sessionmaker=None, ) -> None: """Регистрирует middleware в диспетчере.""" + direct_start_blocker = DirectStartBlockerMiddleware() + dispatcher.message.outer_middleware(direct_start_blocker) + dispatcher.callback_query.outer_middleware(direct_start_blocker) + if middlewares is None: available_middlewares = { "session": (SessionMiddleware(sessionmaker) if sessionmaker else SessionMiddleware()), @@ -33,13 +38,13 @@ def register_middleware( "user": UserMiddleware(), } - if sessionmaker: - dispatcher.update.outer_middleware(SubscriptionMiddleware()) - dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker)) - exclude_set = set(exclude or []) middlewares = [middleware for name, middleware in available_middlewares.items() if name not in exclude_set] + if sessionmaker: + dispatcher.update.outer_middleware(SubscriptionMiddleware()) + dispatcher.update.outer_middleware(BanCheckerMiddleware(sessionmaker)) + handlers = [ dispatcher.message, dispatcher.callback_query, @@ -52,3 +57,4 @@ def register_middleware( for handler in handlers: handler.outer_middleware(middleware) + diff --git a/middlewares/ban_checker.py b/middlewares/ban_checker.py index 86899bd7..2bbf00de 100644 --- a/middlewares/ban_checker.py +++ b/middlewares/ban_checker.py @@ -57,6 +57,10 @@ class BanCheckerMiddleware(BaseMiddleware): reason = ban.reason or "не указана" until = ban.until + if reason == "shadow": + logger.info(f"[BanChecker] Теневой бан: пользователь {tg_id} — действия игнорируются.") + return + logger.warning(f"[BanChecker] Пользователь {tg_id} заблокирован (до: {until}, причина: {reason})") if until: diff --git a/middlewares/direct_start_blocker.py b/middlewares/direct_start_blocker.py new file mode 100644 index 00000000..493b66c1 --- /dev/null +++ b/middlewares/direct_start_blocker.py @@ -0,0 +1,57 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import Message, Update + +from config import DISABLE_DIRECT_START +from database import check_user_exists, async_session_maker +from logger import logger + + +class DirectStartBlockerMiddleware(BaseMiddleware): + def __init__(self) -> None: + self.allowed_prefixes = ("gift_", "referral_", "coupons_", "utm", "partner_") + + async def __call__( + self, + handler: Callable[[Update, dict[str, Any]], Awaitable[Any]], + event: Update, + data: dict[str, Any], + ) -> Any: + if not DISABLE_DIRECT_START: + return await handler(event, data) + + if not isinstance(event, Message): + return await handler(event, data) + + message: Message = event + if not message.text: + return await handler(event, data) + + tg_id = message.from_user.id + text = message.text.strip() + + async with async_session_maker() as session: + user_exists = await check_user_exists(session, tg_id) + + if user_exists: + logger.debug(f"[DirectStartBlocker] Пользователь {tg_id} уже есть в базе — пропущен") + return await handler(event, data) + + parts = text.split(maxsplit=1) + + if parts[0] != "/start": + return await handler(event, data) + + if len(parts) == 1: + logger.info(f"[DirectStartBlocker] Прямой старт запрещён для нового пользователя {tg_id}") + return + + start_param = parts[1].strip() + if not start_param or not start_param.startswith(self.allowed_prefixes): + logger.info(f"[DirectStartBlocker] Отклонена неизвестная ссылка от {tg_id}: {start_param!r}") + return + + logger.debug(f"[DirectStartBlocker] Разрешённая ссылка от {tg_id}: {start_param!r}") + return await handler(event, data)