mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-26 22:31:44 +00:00
450 lines
19 KiB
Python
450 lines
19 KiB
Python
"""
|
||
Сервис для проверки пользователей, заблокировавших бота.
|
||
|
||
Проверяет возможность отправки сообщений пользователям и позволяет
|
||
очистить БД и панель 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
|