Files
remnawave-bedolaga-telegram…/app/services/blocked_users_service.py
2026-02-02 03:18:46 +03:00

450 lines
19 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 asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaignRegistration,
ButtonClickLog,
CabinetRefreshToken,
CloudPaymentsPayment,
ContestAttempt,
CryptoBotPayment,
DiscountOffer,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PollResponse,
PromoCodeUse,
ReferralContestEvent,
ReferralEarning,
SentNotification,
Subscription,
SubscriptionConversion,
SubscriptionEvent,
SubscriptionServer,
Ticket,
TicketMessage,
TicketNotification,
Transaction,
User,
UserPromoGroup,
UserStatus,
WataPayment,
WheelSpin,
WithdrawalRequest,
YooKassaPayment,
)
from app.services.remnawave_service import RemnaWaveService
logger = logging.getLogger(__name__)
class BlockCheckStatus(Enum):
"""Статус проверки блокировки пользователя."""
BLOCKED = 'blocked'
ACTIVE = 'active'
NO_TELEGRAM_ID = 'no_telegram_id'
ERROR = 'error'
class BlockedUserAction(Enum):
"""Действия над заблокированными пользователями."""
DELETE_FROM_DB = 'delete_from_db'
DELETE_FROM_REMNAWAVE = 'delete_from_remnawave'
DELETE_BOTH = 'delete_both'
MARK_AS_BLOCKED = 'mark_as_blocked'
@dataclass
class BlockCheckResult:
"""Результат проверки одного пользователя."""
user_id: int
telegram_id: int | None
username: str | None
full_name: str
status: BlockCheckStatus
error_message: str | None = None
remnawave_uuid: str | None = None
@dataclass
class BlockedUsersScanResult:
"""Результат сканирования пользователей на блокировку."""
total_checked: int = 0
blocked_users: list[BlockCheckResult] = field(default_factory=list)
active_users: int = 0
errors: int = 0
skipped_no_telegram: int = 0
scan_duration_seconds: float = 0.0
@property
def blocked_count(self) -> int:
return len(self.blocked_users)
@dataclass
class CleanupResult:
"""Результат очистки заблокированных пользователей."""
deleted_from_db: int = 0
deleted_from_remnawave: int = 0
marked_as_blocked: int = 0
errors: list[str] = field(default_factory=list)
class BlockedUsersService:
"""Сервис проверки и очистки заблокированных пользователей."""
# Задержка между проверками для избежания rate limit
CHECK_DELAY_SECONDS: float = 0.05
# Максимальное количество параллельных проверок
MAX_CONCURRENT_CHECKS: int = 10
# Задержка между API запросами к Remnawave (rate limit protection)
API_DELAY_SECONDS: float = 0.15
def __init__(self, bot: Bot):
self.bot = bot
self.remnawave_service = RemnaWaveService()
async def check_user_blocked(self, telegram_id: int) -> BlockCheckStatus:
"""
Проверяет, заблокировал ли пользователь бота.
Отправляет ChatAction.TYPING - это не создает видимого сообщения,
но позволяет определить блокировку.
"""
try:
await self.bot.send_chat_action(chat_id=telegram_id, action='typing')
return BlockCheckStatus.ACTIVE
except TelegramForbiddenError:
# Пользователь заблокировал бота
return BlockCheckStatus.BLOCKED
except TelegramBadRequest as e:
error_lower = str(e).lower()
if 'chat not found' in error_lower or 'user not found' in error_lower:
# Пользователь удалил аккаунт или никогда не начинал диалог
return BlockCheckStatus.BLOCKED
logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except TelegramAPIError as e:
logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except Exception as e:
logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
async def _check_single_user(self, user: User) -> BlockCheckResult:
"""Проверяет одного пользователя."""
if not user.telegram_id:
return BlockCheckResult(
user_id=user.id,
telegram_id=None,
username=user.username,
full_name=user.full_name,
status=BlockCheckStatus.NO_TELEGRAM_ID,
remnawave_uuid=user.remnawave_uuid,
)
status = await self.check_user_blocked(user.telegram_id)
return BlockCheckResult(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
full_name=user.full_name,
status=status,
remnawave_uuid=user.remnawave_uuid,
)
async def scan_all_users(
self,
db: AsyncSession,
*,
only_active: bool = True,
batch_size: int = 100,
progress_callback: Callable | None = None,
) -> BlockedUsersScanResult:
"""
Сканирует всех пользователей на предмет блокировки бота.
Args:
db: Сессия БД
only_active: Проверять только активных пользователей
batch_size: Размер батча для загрузки из БД
progress_callback: Callback для отчета о прогрессе (checked, total)
Returns:
Результат сканирования
"""
start_time = datetime.now(tz=UTC)
result = BlockedUsersScanResult()
# Формируем запрос
query = select(User).options(selectinload(User.subscription))
if only_active:
query = query.where(User.status == UserStatus.ACTIVE.value)
query = query.where(User.telegram_id.isnot(None))
# Получаем всех пользователей
users_result = await db.execute(query)
all_users = users_result.scalars().all()
total_users = len(all_users)
logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота')
# Проверяем пользователей батчами с ограничением параллелизма
semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS)
async def check_with_semaphore(user: User) -> BlockCheckResult:
async with semaphore:
check_result = await self._check_single_user(user)
await asyncio.sleep(self.CHECK_DELAY_SECONDS)
return check_result
checked = 0
for i in range(0, total_users, batch_size):
batch = all_users[i : i + batch_size]
tasks = [check_with_semaphore(user) for user in batch]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for check_result in batch_results:
if isinstance(check_result, Exception):
result.errors += 1
logger.error(f'Ошибка при проверке пользователя: {check_result}')
continue
result.total_checked += 1
if check_result.status == BlockCheckStatus.BLOCKED:
result.blocked_users.append(check_result)
elif check_result.status == BlockCheckStatus.ACTIVE:
result.active_users += 1
elif check_result.status == BlockCheckStatus.NO_TELEGRAM_ID:
result.skipped_no_telegram += 1
else:
result.errors += 1
checked += len(batch)
if progress_callback:
await progress_callback(checked, total_users)
result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds()
logger.info(
f'Сканирование завершено: {result.blocked_count} заблокированных '
f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с'
)
return result
async def delete_user_from_remnawave(self, remnawave_uuid: str) -> bool:
"""Удаляет пользователя из панели Remnawave."""
if not remnawave_uuid:
return False
try:
if not self.remnawave_service.is_configured:
logger.warning('Remnawave API не настроен')
return False
async with self.remnawave_service.get_api_client() as api:
await api.delete_user(remnawave_uuid)
logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave')
return True
except Exception as e:
error_msg = str(e).lower()
if 'not found' in error_msg or '404' in error_msg:
logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave')
return True
logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}')
return False
async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool:
"""
Полностью удаляет пользователя из БД со всеми связанными данными.
"""
try:
# Получаем пользователя
user_result = await db.execute(
select(User).options(selectinload(User.subscription)).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
logger.warning(f'Пользователь {user_id} не найден в БД')
return False
user_display = user.telegram_id or user.email or f'#{user.id}'
# Удаляем связанные записи (порядок важен из-за foreign keys)
# 1. Платежные системы (до транзакций, т.к. ссылаются на них)
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user.id))
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user.id))
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user.id))
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user.id))
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user.id))
await db.execute(delete(WataPayment).where(WataPayment.user_id == user.id))
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user.id))
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user.id))
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user.id))
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user.id))
# 2. Транзакции (после платежей)
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
# 3. Подписки
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user.id))
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user.id))
await db.execute(delete(SubscriptionEvent).where(SubscriptionEvent.user_id == user.id))
# 4. Тикеты (сначала зависимые)
await db.execute(delete(TicketNotification).where(TicketNotification.user_id == user.id))
await db.execute(delete(TicketMessage).where(TicketMessage.user_id == user.id))
await db.execute(delete(Ticket).where(Ticket.user_id == user.id))
# 5. Остальные связи
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id))
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
await db.execute(delete(DiscountOffer).where(DiscountOffer.user_id == user.id))
await db.execute(delete(SentNotification).where(SentNotification.user_id == user.id))
await db.execute(delete(PollResponse).where(PollResponse.user_id == user.id))
await db.execute(delete(ContestAttempt).where(ContestAttempt.user_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referrer_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referral_id == user.id))
await db.execute(
delete(AdvertisingCampaignRegistration).where(AdvertisingCampaignRegistration.user_id == user.id)
)
await db.execute(delete(UserPromoGroup).where(UserPromoGroup.user_id == user.id))
await db.execute(delete(CabinetRefreshToken).where(CabinetRefreshToken.user_id == user.id))
await db.execute(delete(ButtonClickLog).where(ButtonClickLog.user_id == user.id))
await db.execute(delete(WheelSpin).where(WheelSpin.user_id == user.id))
# Обнуляем referred_by_id у рефералов этого пользователя
referrals_query = select(User).where(User.referred_by_id == user.id)
referrals_result = await db.execute(referrals_query)
for referral in referrals_result.scalars().all():
referral.referred_by_id = None
# Удаляем пользователя
await db.delete(user)
await db.commit()
logger.info(f'Пользователь {user_display} полностью удален из БД')
return True
except Exception as e:
logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}')
await db.rollback()
return False
async def mark_user_as_blocked(self, db: AsyncSession, user_id: int) -> bool:
"""Помечает пользователя как заблокированного в БД."""
try:
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
return False
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.now(tz=UTC)
await db.commit()
logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный')
return True
except Exception as e:
logger.error(f'Ошибка пометки пользователя {user_id}: {e}')
await db.rollback()
return False
async def cleanup_blocked_users(
self,
db: AsyncSession,
blocked_users: list[BlockCheckResult],
action: BlockedUserAction,
*,
progress_callback: Callable | None = None,
) -> CleanupResult:
"""
Выполняет очистку заблокированных пользователей.
Args:
db: Сессия БД
blocked_users: Список заблокированных пользователей
action: Действие для выполнения
progress_callback: Callback для отчета о прогрессе
Returns:
Результат очистки
"""
result = CleanupResult()
total = len(blocked_users)
for i, user_result in enumerate(blocked_users):
try:
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
if user_result.remnawave_uuid:
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
if success:
result.deleted_from_remnawave += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
# Задержка для избежания rate limit
await asyncio.sleep(self.API_DELAY_SECONDS)
if action in (BlockedUserAction.DELETE_FROM_DB, BlockedUserAction.DELETE_BOTH):
success = await self.delete_user_from_db(db, user_result.user_id)
if success:
result.deleted_from_db += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из БД')
if action == BlockedUserAction.MARK_AS_BLOCKED:
success = await self.mark_user_as_blocked(db, user_result.user_id)
if success:
result.marked_as_blocked += 1
else:
result.errors.append(f'Ошибка пометки {user_result.telegram_id}')
if progress_callback:
await progress_callback(i + 1, total)
except Exception as e:
error_msg = f'Ошибка обработки {user_result.telegram_id}: {e}'
result.errors.append(error_msg)
logger.error(error_msg)
return result