support invite links/ shadow bans/ bulk preemptive bans

This commit is contained in:
Vladless
2025-07-25 05:40:45 +03:00
parent 424cb50942
commit 171634522e
7 changed files with 285 additions and 14 deletions
+60 -1
View File
@@ -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"
"Пример:\n<code>123456789\n987654321</code>"
)
@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"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
await state.clear()
+9 -2
View File
@@ -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()
+39
View File
@@ -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()
+106 -7
View File
@@ -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="✏️ Введите причину <b>постоянной блокировки</b> (или <code>-</code>, чтобы пропустить):",
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"✅ Пользователь <code>{tg_id}</code> забанен навсегда."
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="✏️ Введите причину блокировки (или <code>-</code>, чтобы пропустить):",
text="✏️ Введите причину <b>временной блокировки</b> (или <code>-</code>, чтобы пропустить):",
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"✅ Пользователь <code>{tg_id}</code> забанен "
f"{'навсегда' if not until else f'до {until:%Y-%m-%d %H:%M}'}."
" Нажмите кнопку ниже для возврата в профиль."
f"✅ Пользователь <code>{tg_id}</code> временно забанен до <b>{until:%Y-%m-%d %H:%M}</b> по 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"👻 Пользователь <code>{callback_data.tg_id}</code> получил теневой бан.",
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,
+10 -4
View File
@@ -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)
+4
View File
@@ -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:
+57
View File
@@ -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)