support invite links/ shadow bans/ bulk preemptive bans
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user