Files
remnawave-bedolaga-telegram…/app/handlers/admin/blocked_users.py
2026-02-02 03:01:56 +03:00

729 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Хендлеры админ-панели для управления заблокированными пользователями.
Позволяет сканировать пользователей, выявлять тех, кто заблокировал бота,
и выполнять очистку БД и панели Remnawave.
"""
import logging
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.blocked_users_service import (
BlockCheckResult,
BlockedUserAction,
BlockedUsersService,
)
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
# =============================================================================
# Enums для текстов и callback_data
# =============================================================================
class BlockedUsersText(Enum):
"""Тексты для сообщений модуля заблокированных пользователей."""
MENU_TITLE = '🔒 <b>Проверка заблокированных пользователей</b>'
MENU_DESCRIPTION = (
'\n\nЗдесь вы можете проверить, какие пользователи заблокировали бота, '
'и очистить их из базы данных и панели Remnawave.\n\n'
'<b>Как это работает:</b>\n'
'1. Сканирование отправляет тестовый запрос каждому пользователю\n'
'2. Если пользователь заблокировал бота - получаем ошибку\n'
'3. Можно удалить таких пользователей из БД и/или Remnawave'
)
SCAN_STARTED = '🔄 <b>Сканирование запущено...</b>\n\nЭто может занять несколько минут.'
SCAN_PROGRESS = '🔄 <b>Сканирование:</b> {checked}/{total} ({percent}%)'
SCAN_COMPLETE = (
'✅ <b>Сканирование завершено</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Проверено: {total_checked}\n'
'• Заблокировали бота: {blocked_count}\n'
'• Активных: {active_users}\n'
'• Ошибок: {errors}\n'
'• Без Telegram ID: {skipped}\n\n'
'⏱ Время сканирования: {duration:.1f}с'
)
SCAN_NO_BLOCKED = '✅ <b>Отлично!</b>\n\nНе найдено пользователей, заблокировавших бота.'
BLOCKED_LIST_TITLE = '🔒 <b>Заблокированные пользователи</b> ({count})\n\n'
BLOCKED_USER_ROW = '{name} (ID: <code>{telegram_id}</code>)\n'
CLEANUP_CONFIRM_TITLE = '⚠️ <b>Подтверждение действия</b>\n\n'
CLEANUP_CONFIRM_DELETE_DB = (
'Вы собираетесь <b>удалить из БД</b> {count} пользователей.\n'
'Это действие необратимо!\n\n'
'Будут удалены:\n'
'• Профили пользователей\n'
'• Подписки\n'
'• Транзакции\n'
'• Реферальные данные'
)
CLEANUP_CONFIRM_DELETE_REMNAWAVE = (
'Вы собираетесь <b>удалить из Remnawave</b> {count} пользователей.\nИх VPN доступ будет полностью отключен.'
)
CLEANUP_CONFIRM_DELETE_BOTH = (
'Вы собираетесь <b>полностью удалить</b> {count} пользователей:\n'
'• Из базы данных бота\n'
'• Из панели Remnawave\n\n'
'Это действие необратимо!'
)
CLEANUP_CONFIRM_MARK = (
'Вы собираетесь <b>пометить как заблокированных</b> {count} пользователей.\n'
'Они останутся в БД, но будут помечены статусом "blocked".'
)
CLEANUP_PROGRESS = '🗑 <b>Очистка:</b> {processed}/{total}'
CLEANUP_COMPLETE = (
'✅ <b>Очистка завершена</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Удалено из БД: {deleted_db}\n'
'• Удалено из Remnawave: {deleted_remnawave}\n'
'• Помечено как заблокированные: {marked}\n'
'• Ошибок: {errors}'
)
BUTTON_START_SCAN = '🔍 Начать сканирование'
BUTTON_VIEW_BLOCKED = '👥 Список заблокированных ({count})'
BUTTON_DELETE_DB = '🗑 Удалить из БД'
BUTTON_DELETE_REMNAWAVE = '🌐 Удалить из Remnawave'
BUTTON_DELETE_BOTH = '💀 Удалить везде'
BUTTON_MARK_BLOCKED = '🚫 Пометить как заблокированных'
BUTTON_CONFIRM = '✅ Подтвердить'
BUTTON_CANCEL = '❌ Отмена'
BUTTON_BACK = '⬅️ Назад'
BUTTON_BACK_TO_USERS = '⬅️ К пользователям'
class BlockedUsersCallback(Enum):
"""Callback data для кнопок модуля."""
MENU = 'admin_blocked_users'
START_SCAN = 'admin_blocked_scan'
VIEW_LIST = 'admin_blocked_list'
VIEW_LIST_PAGE = 'admin_blocked_list_page_'
ACTION_DELETE_DB = 'admin_blocked_action_db'
ACTION_DELETE_REMNAWAVE = 'admin_blocked_action_rw'
ACTION_DELETE_BOTH = 'admin_blocked_action_both'
ACTION_MARK = 'admin_blocked_action_mark'
CONFIRM_PREFIX = 'admin_blocked_confirm_'
CANCEL = 'admin_blocked_cancel'
# =============================================================================
# FSM States
# =============================================================================
class BlockedUsersStates(StatesGroup):
"""Состояния FSM для модуля заблокированных пользователей."""
scanning = State()
viewing_results = State()
confirming_action = State()
processing_cleanup = State()
# =============================================================================
# Keyboards
# =============================================================================
def get_blocked_users_menu_keyboard(
scan_result: dict[str, Any] | None = None,
) -> InlineKeyboardMarkup:
"""Клавиатура главного меню модуля."""
buttons = [
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_START_SCAN.value,
callback_data=BlockedUsersCallback.START_SCAN.value,
)
]
]
blocked_count = scan_result.get('blocked_count', 0) if scan_result else 0
if blocked_count > 0:
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_VIEW_BLOCKED.value.format(count=blocked_count),
callback_data=BlockedUsersCallback.VIEW_LIST.value,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK_TO_USERS.value,
callback_data='admin_users',
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_blocked_list_keyboard(
page: int = 1,
total_pages: int = 1,
has_blocked: bool = True,
) -> InlineKeyboardMarkup:
"""Клавиатура списка заблокированных пользователей."""
buttons = []
# Пагинация
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page - 1}',
)
)
nav_row.append(
InlineKeyboardButton(
text=f'{page}/{total_pages}',
callback_data='noop',
)
)
if page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page + 1}',
)
)
buttons.append(nav_row)
# Действия
if has_blocked:
buttons.extend(
[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_DB.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_DB.value,
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_REMNAWAVE.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_BOTH.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_BOTH.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_MARK_BLOCKED.value,
callback_data=BlockedUsersCallback.ACTION_MARK.value,
),
],
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK.value,
callback_data=BlockedUsersCallback.MENU.value,
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_keyboard(action: BlockedUserAction) -> InlineKeyboardMarkup:
"""Клавиатура подтверждения действия."""
action_map = {
BlockedUserAction.DELETE_FROM_DB: 'db',
BlockedUserAction.DELETE_FROM_REMNAWAVE: 'rw',
BlockedUserAction.DELETE_BOTH: 'both',
BlockedUserAction.MARK_AS_BLOCKED: 'mark',
}
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CONFIRM.value,
callback_data=f'{BlockedUsersCallback.CONFIRM_PREFIX.value}{action_map[action]}',
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CANCEL.value,
callback_data=BlockedUsersCallback.CANCEL.value,
),
]
]
)
# =============================================================================
# Handlers
# =============================================================================
@admin_required
@error_handler
async def show_blocked_users_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Показывает главное меню модуля заблокированных пользователей."""
data = await state.get_data()
scan_result = data.get('blocked_users_scan_result')
text = BlockedUsersText.MENU_TITLE.value + BlockedUsersText.MENU_DESCRIPTION.value
if scan_result:
text += (
f'\n\n📊 <b>Последнее сканирование:</b>\n'
f'• Заблокированных: {scan_result.get("blocked_count", 0)}\n'
f'• Активных: {scan_result.get("active_users", 0)}'
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result),
)
await callback.answer()
@admin_required
@error_handler
async def start_scan(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Запускает сканирование пользователей."""
await state.set_state(BlockedUsersStates.scanning)
# Отправляем начальное сообщение
await callback.message.edit_text(
BlockedUsersText.SCAN_STARTED.value,
parse_mode=ParseMode.HTML,
)
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(checked: int, total: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
# Обновляем сообщение не чаще раза в 3 секунды
if (now - last_update_time).total_seconds() >= 3:
last_update_time = now
percent = int(checked / total * 100) if total > 0 else 0
try:
await callback.message.edit_text(
BlockedUsersText.SCAN_PROGRESS.value.format(
checked=checked,
total=total,
percent=percent,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass # Игнорируем ошибки обновления сообщения
# Выполняем сканирование
result = await service.scan_all_users(
db,
only_active=True,
progress_callback=progress_callback,
)
# Сериализуем результат в dict для Redis и keyboard
scan_result_dict = {
'total_checked': result.total_checked,
'blocked_count': result.blocked_count,
'active_users': result.active_users,
'errors': result.errors,
'skipped_no_telegram': result.skipped_no_telegram,
'scan_duration_seconds': result.scan_duration_seconds,
}
# Сохраняем результат в state
await state.update_data(
blocked_users_scan_result=scan_result_dict,
blocked_users_list=[
{
'user_id': u.user_id,
'telegram_id': u.telegram_id,
'username': u.username,
'full_name': u.full_name,
'remnawave_uuid': u.remnawave_uuid,
}
for u in result.blocked_users
],
)
await state.set_state(BlockedUsersStates.viewing_results)
# Формируем итоговое сообщение
if result.blocked_count == 0:
text = BlockedUsersText.SCAN_NO_BLOCKED.value
else:
text = BlockedUsersText.SCAN_COMPLETE.value.format(
total_checked=result.total_checked,
blocked_count=result.blocked_count,
active_users=result.active_users,
errors=result.errors,
skipped=result.skipped_no_telegram,
duration=result.scan_duration_seconds,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result_dict),
)
await callback.answer()
@admin_required
@error_handler
async def show_blocked_list(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
page: int = 1,
) -> None:
"""Показывает список заблокированных пользователей."""
data = await state.get_data()
blocked_list: list[dict[str, Any]] = data.get('blocked_users_list', [])
if not blocked_list:
await callback.answer('Нет заблокированных пользователей', show_alert=True)
return
# Пагинация
per_page = 15
total_pages = (len(blocked_list) + per_page - 1) // per_page
page = max(1, min(page, total_pages))
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_users = blocked_list[start_idx:end_idx]
text = BlockedUsersText.BLOCKED_LIST_TITLE.value.format(count=len(blocked_list))
for user_data in page_users:
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
telegram_id = user_data.get('telegram_id', '?')
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
name=name,
telegram_id=telegram_id,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_list_keyboard(page, total_pages, bool(blocked_list)),
)
await callback.answer()
@admin_required
@error_handler
async def handle_blocked_list_pagination(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает пагинацию списка заблокированных."""
try:
page = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
page = 1
await show_blocked_list(callback, db_user, state, page)
@admin_required
@error_handler
async def show_action_confirm(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
action: BlockedUserAction,
) -> None:
"""Показывает подтверждение действия."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
count = len(blocked_list)
if count == 0:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.confirming_action)
await state.update_data(pending_action=action.value)
text = BlockedUsersText.CLEANUP_CONFIRM_TITLE.value
if action == BlockedUserAction.DELETE_FROM_DB:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_DB.value.format(count=count)
elif action == BlockedUserAction.DELETE_FROM_REMNAWAVE:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_REMNAWAVE.value.format(count=count)
elif action == BlockedUserAction.DELETE_BOTH:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_BOTH.value.format(count=count)
elif action == BlockedUserAction.MARK_AS_BLOCKED:
text += BlockedUsersText.CLEANUP_CONFIRM_MARK.value.format(count=count)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_confirm_keyboard(action),
)
await callback.answer()
@admin_required
@error_handler
async def handle_action_delete_db(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из БД."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_DB)
@admin_required
@error_handler
async def handle_action_delete_remnawave(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из Remnawave."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_REMNAWAVE)
@admin_required
@error_handler
async def handle_action_delete_both(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор полного удаления."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_BOTH)
@admin_required
@error_handler
async def handle_action_mark(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор пометки как заблокированных."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.MARK_AS_BLOCKED)
@admin_required
@error_handler
async def handle_confirm_action(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Выполняет подтвержденное действие."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
# Определяем действие из callback_data
action_code = callback.data.replace(BlockedUsersCallback.CONFIRM_PREFIX.value, '')
action_map = {
'db': BlockedUserAction.DELETE_FROM_DB,
'rw': BlockedUserAction.DELETE_FROM_REMNAWAVE,
'both': BlockedUserAction.DELETE_BOTH,
'mark': BlockedUserAction.MARK_AS_BLOCKED,
}
action = action_map.get(action_code)
if not action:
await callback.answer('Неизвестное действие', show_alert=True)
return
if not blocked_list:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.processing_cleanup)
# Преобразуем обратно в BlockCheckResult
blocked_results = [
BlockCheckResult(
user_id=u['user_id'],
telegram_id=u['telegram_id'],
username=u['username'],
full_name=u['full_name'],
status=None, # type: ignore
remnawave_uuid=u['remnawave_uuid'],
)
for u in blocked_list
]
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(processed: int, total_count: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
if (now - last_update_time).total_seconds() >= 2:
last_update_time = now
try:
await callback.message.edit_text(
BlockedUsersText.CLEANUP_PROGRESS.value.format(
processed=processed,
total=total_count,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass
# Выполняем очистку
result = await service.cleanup_blocked_users(
db,
blocked_results,
action,
progress_callback=progress_callback,
)
# Очищаем сохраненные данные
await state.update_data(
blocked_users_scan_result=None,
blocked_users_list=[],
pending_action=None,
)
await state.set_state(None)
# Показываем результат
text = BlockedUsersText.CLEANUP_COMPLETE.value.format(
deleted_db=result.deleted_from_db,
deleted_remnawave=result.deleted_from_remnawave,
marked=result.marked_as_blocked,
errors=len(result.errors),
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(),
)
logger.info(
f'Очистка заблокированных пользователей завершена: '
f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, '
f'marked={result.marked_as_blocked}, errors={len(result.errors)}'
)
await callback.answer()
@admin_required
@error_handler
async def handle_cancel(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Отменяет текущее действие и возвращает в меню."""
await state.update_data(pending_action=None)
await state.set_state(BlockedUsersStates.viewing_results)
await show_blocked_users_menu(callback, db_user, state)
# =============================================================================
# Registration
# =============================================================================
def register_handlers(dp: Dispatcher) -> None:
"""Регистрирует хендлеры модуля заблокированных пользователей."""
# Главное меню
dp.callback_query.register(
show_blocked_users_menu,
F.data == BlockedUsersCallback.MENU.value,
)
# Сканирование
dp.callback_query.register(
start_scan,
F.data == BlockedUsersCallback.START_SCAN.value,
)
# Список заблокированных
dp.callback_query.register(
show_blocked_list,
F.data == BlockedUsersCallback.VIEW_LIST.value,
)
# Пагинация списка
dp.callback_query.register(
handle_blocked_list_pagination,
F.data.startswith(BlockedUsersCallback.VIEW_LIST_PAGE.value),
)
# Выбор действий
dp.callback_query.register(
handle_action_delete_db,
F.data == BlockedUsersCallback.ACTION_DELETE_DB.value,
)
dp.callback_query.register(
handle_action_delete_remnawave,
F.data == BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
)
dp.callback_query.register(
handle_action_delete_both,
F.data == BlockedUsersCallback.ACTION_DELETE_BOTH.value,
)
dp.callback_query.register(
handle_action_mark,
F.data == BlockedUsersCallback.ACTION_MARK.value,
)
# Подтверждение действий
dp.callback_query.register(
handle_confirm_action,
F.data.startswith(BlockedUsersCallback.CONFIRM_PREFIX.value),
)
# Отмена
dp.callback_query.register(
handle_cancel,
F.data == BlockedUsersCallback.CANCEL.value,
)