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)