diff --git a/.env.example b/.env.example index 73b5a2b0..123d468a 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,20 @@ ADMIN_REPORTS_ENABLED=false ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID) ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета + +# Мониторинг трафика +TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей +TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление) +TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24) +SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат) + +# Черный список +BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку +BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub +BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах) +BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка +SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению» + # Обязательная подписка на канал CHANNEL_SUB_ID= # Опционально ID твоего канала (-100) CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 00000000..40af279d Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/bot.py b/app/bot.py index 4ecac835..e95cf2ef 100644 --- a/app/bot.py +++ b/app/bot.py @@ -31,6 +31,8 @@ from app.handlers import polls as user_polls from app.handlers import simple_subscription from app.handlers.admin import ( main as admin_main, + blacklist as admin_blacklist, + bulk_ban as admin_bulk_ban, users as admin_users, subscriptions as admin_subscriptions, promocodes as admin_promocodes, @@ -176,6 +178,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_faq.register_handlers(dp) admin_payments.register_handlers(dp) admin_trials.register_handlers(dp) + admin_bulk_ban.register_bulk_ban_handlers(dp) + admin_blacklist.register_blacklist_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) user_polls.register_handlers(dp) diff --git a/app/config.py b/app/config.py index f75e7f49..d33fd847 100644 --- a/app/config.py +++ b/app/config.py @@ -155,12 +155,24 @@ class Settings(BaseSettings): REFERRAL_PROGRAM_ENABLED: bool = True REFERRAL_NOTIFICATIONS_ENABLED: bool = True REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3 - + + BLACKLIST_CHECK_ENABLED: bool = False + BLACKLIST_GITHUB_URL: Optional[str] = None + BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24 + BLACKLIST_IGNORE_ADMINS: bool = True + + # Настройки мониторинга трафика + TRAFFIC_MONITORING_ENABLED: bool = False + TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки + TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки) + SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None + AUTOPAY_WARNING_DAYS: str = "3,1" DEFAULT_AUTOPAY_ENABLED: bool = False DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3 MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000 + SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000 MONITORING_INTERVAL: int = 60 INACTIVE_USER_DELETE_MONTHS: int = 3 diff --git a/app/database/crud/user.py b/app/database/crud/user.py index 2f944df2..27fa7c1d 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -119,6 +119,25 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Opt return user +async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> Optional[User]: + result = await db.execute( + select(User) + .options( + selectinload(User.subscription), + selectinload(User.promo_group), + selectinload(User.referrer), + ) + .where(User.remnawave_uuid == remnawave_uuid) + ) + user = result.scalar_one_or_none() + + if user and user.subscription: + # Загружаем дополнительные зависимости для subscription + _ = user.subscription.is_active + + return user + + async def create_unique_referral_code(db: AsyncSession) -> str: max_attempts = 10 diff --git a/app/external/remnawave_api.py b/app/external/remnawave_api.py index 96a1003a..c9eb6e26 100644 --- a/app/external/remnawave_api.py +++ b/app/external/remnawave_api.py @@ -685,6 +685,25 @@ class RemnaWaveAPI: async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]: response = await self._make_request('GET', '/api/nodes/usage/realtime') return response['response'] + + async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]: + """ + Получает статистику использования трафика пользователем за указанный период + + Args: + user_uuid: UUID пользователя + start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z") + end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z") + + Returns: + Словарь с информацией о трафике пользователя за указанный период + """ + params = { + 'start': start_date, + 'end': end_date + } + response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params) + return response async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]: diff --git a/app/handlers/.DS_Store b/app/handlers/.DS_Store new file mode 100644 index 00000000..43d7cb4f Binary files /dev/null and b/app/handlers/.DS_Store differ diff --git a/app/handlers/admin/__init__.py b/app/handlers/admin/__init__.py index f6894672..2b5aca75 100644 --- a/app/handlers/admin/__init__.py +++ b/app/handlers/admin/__init__.py @@ -1 +1,36 @@ -# Инициализация админских обработчиков \ No newline at end of file +# Инициализация админских обработчиков +from . import ( + backup, + blacklist, + bot_configuration, + bulk_ban, + campaigns, + faq, + main, + maintenance, + messages, + monitoring, + payments, + polls, + pricing, + privacy_policy, + promo_groups, + promo_offers, + promocodes, + public_offer, + referrals, + remnawave, + reports, + rules, + servers, + statistics, + subscriptions, + support_settings, + system_logs, + tickets, + trials, + updates, + user_messages, + users, + welcome_text, +) \ No newline at end of file diff --git a/app/handlers/admin/blacklist.py b/app/handlers/admin/blacklist.py new file mode 100644 index 00000000..82d08d57 --- /dev/null +++ b/app/handlers/admin/blacklist.py @@ -0,0 +1,365 @@ +""" +Обработчики админ-панели для управления черным списком +""" +import logging +from aiogram import types +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.models import User +from app.services.blacklist_service import blacklist_service +from app.utils.decorators import admin_required, error_handler +from app.keyboards.admin import get_admin_users_keyboard + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def show_blacklist_settings( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Показывает настройки черного списка + """ + logger.info(f"Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}") + + is_enabled = blacklist_service.is_blacklist_check_enabled() + github_url = blacklist_service.get_blacklist_github_url() + blacklist_count = len(await blacklist_service.get_all_blacklisted_users()) + + status_text = "✅ Включена" if is_enabled else "❌ Отключена" + url_text = github_url if github_url else "Не задан" + + text = f""" +🔐 Настройки черного списка + +Статус: {status_text} +URL к черному списку: {url_text} +Количество записей: {blacklist_count} + +Действия: +""" + + keyboard = [ + [ + types.InlineKeyboardButton( + text="🔄 Обновить список" if is_enabled else "🔄 Обновить (откл.)", + callback_data="admin_blacklist_update" + ) + ], + [ + types.InlineKeyboardButton( + text="📋 Просмотреть список" if is_enabled else "📋 Просмотр (откл.)", + callback_data="admin_blacklist_view" + ) + ], + [ + types.InlineKeyboardButton( + text="✏️ URL к GitHub" if not github_url else "✏️ Изменить URL", + callback_data="admin_blacklist_set_url" + ) + ], + [ + types.InlineKeyboardButton( + text="✅ Включить" if not is_enabled else "❌ Отключить", + callback_data="admin_blacklist_toggle" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад к пользователям", + callback_data="admin_users" + ) + ] + ] + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard) + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_blacklist( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Переключает статус проверки черного списка + """ + # Текущая реализация использует настройки из .env + # Для полной реализации нужно будет создать сервис настроек + is_enabled = blacklist_service.is_blacklist_check_enabled() + + # В реальной реализации нужно будет изменить настройку в базе данных + # или в системе настроек, но сейчас просто покажем статус + new_status = not is_enabled + status_text = "включена" if new_status else "отключена" + + await callback.message.edit_text( + f"Статус проверки черного списка: {status_text}\n\n" + f"Для изменения статуса проверки черного списка измените значение\n" + f"BLACKLIST_CHECK_ENABLED в файле .env", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔄 Обновить статус", + callback_data="admin_blacklist_settings" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def update_blacklist( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Обновляет черный список из GitHub + """ + success, message = await blacklist_service.force_update_blacklist() + + if success: + await callback.message.edit_text( + f"✅ {message}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="📋 Просмотреть список", + callback_data="admin_blacklist_view" + ) + ], + [ + types.InlineKeyboardButton( + text="🔄 Ручное обновление", + callback_data="admin_blacklist_update" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + else: + await callback.message.edit_text( + f"❌ Ошибка обновления: {message}", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔄 Повторить", + callback_data="admin_blacklist_update" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_blacklist_users( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Показывает список пользователей в черном списке + """ + blacklist_users = await blacklist_service.get_all_blacklisted_users() + + if not blacklist_users: + text = "Черный список пуст" + else: + text = f"🔐 Черный список ({len(blacklist_users)} записей)\n\n" + + # Показываем первые 20 записей + for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1): + text += f"{i}. {tg_id} {username or ''} — {reason}\n" + + if len(blacklist_users) > 20: + text += f"\n... и еще {len(blacklist_users) - 20} записей" + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔄 Обновить", + callback_data="admin_blacklist_view" + ) + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_set_blacklist_url( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Начинает процесс установки URL к черному списку + """ + current_url = blacklist_service.get_blacklist_github_url() or "не задан" + + await callback.message.edit_text( + f"Введите новый URL к файлу черного списка на GitHub\n\n" + f"Текущий URL: {current_url}\n\n" + f"Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n" + f"Для отмены используйте команду /cancel", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + + await state.set_state("waiting_for_blacklist_url") + await callback.answer() + + +@admin_required +@error_handler +async def process_blacklist_url( + message: types.Message, + db_user: User, + state: FSMContext +): + """ + Обрабатывает введенный URL к черному списку + """ + url = message.text.strip() + + # В реальной реализации нужно сохранить URL в систему настроек + # В текущей реализации просто выводим сообщение + if url.lower() in ['/cancel', 'отмена', 'cancel']: + await message.answer( + "Настройка URL отменена", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔐 Настройки черного списка", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + await state.clear() + return + + # Проверяем, что URL выглядит корректно + if not url.startswith(('http://', 'https://')): + await message.answer( + "❌ Некорректный URL. URL должен начинаться с http:// или https://", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔐 Настройки черного списка", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + return + + # В реальной системе здесь нужно сохранить URL в базу данных настроек + # или в систему конфигурации + + await message.answer( + f"✅ URL к черному списку установлен:\n{url}\n\n" + f"Для применения изменений перезапустите бота или измените значение\n" + f"BLACKLIST_GITHUB_URL в файле .env", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [ + types.InlineKeyboardButton( + text="🔄 Обновить список", + callback_data="admin_blacklist_update" + ) + ], + [ + types.InlineKeyboardButton( + text="🔐 Настройки черного списка", + callback_data="admin_blacklist_settings" + ) + ] + ]) + ) + await state.clear() + + +def register_blacklist_handlers(dp): + """ + Регистрация обработчиков черного списка + """ + # Обработчик показа настроек черного списка + # Этот обработчик нужно будет вызывать из меню пользователей или отдельно + dp.callback_query.register( + show_blacklist_settings, + lambda c: c.data == "admin_blacklist_settings" + ) + + # Обработчики для взаимодействия с черным списком + dp.callback_query.register( + toggle_blacklist, + lambda c: c.data == "admin_blacklist_toggle" + ) + + dp.callback_query.register( + update_blacklist, + lambda c: c.data == "admin_blacklist_update" + ) + + dp.callback_query.register( + show_blacklist_users, + lambda c: c.data == "admin_blacklist_view" + ) + + dp.callback_query.register( + start_set_blacklist_url, + lambda c: c.data == "admin_blacklist_set_url" + ) + + # Обработчик сообщений для установки URL (работает только в нужном состоянии) + dp.message.register( + process_blacklist_url, + lambda m: True # Фильтр будет внутри функции + ) diff --git a/app/handlers/admin/bulk_ban.py b/app/handlers/admin/bulk_ban.py new file mode 100644 index 00000000..2f7a28fd --- /dev/null +++ b/app/handlers/admin/bulk_ban.py @@ -0,0 +1,178 @@ +""" +Обработчики команд для массовой блокировки пользователей +""" +import logging +from aiogram import types +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings +from app.database.models import User +from app.services.bulk_ban_service import bulk_ban_service +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler +from app.keyboards.admin import get_admin_users_keyboard + +logger = logging.getLogger(__name__) + + +@admin_required +@error_handler +async def start_bulk_ban_process( + callback: types.CallbackQuery, + db_user: User, + state: FSMContext +): + """ + Начало процесса массовой блокировки пользователей + """ + await callback.message.edit_text( + "🛑 Массовая блокировка пользователей\n\n" + "Введите список Telegram ID для блокировки.\n\n" + "Форматы ввода:\n" + "• По одному ID на строку\n" + "• Через запятую\n" + "• Через пробел\n\n" + "Пример:\n" + "123456789\n" + "987654321\n" + "111222333\n\n" + "Или:\n" + "123456789, 987654321, 111222333\n\n" + "Для отмены используйте команду /cancel", + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")] + ]) + ) + + await state.set_state(AdminStates.waiting_for_bulk_ban_list) + await callback.answer() + + +@admin_required +@error_handler +async def process_bulk_ban_list( + message: types.Message, + db_user: User, + state: FSMContext, + db: AsyncSession +): + """ + Обработка списка Telegram ID и выполнение массовой блокировки + """ + if not message.text: + await message.answer( + "❌ Отправьте текстовое сообщение со списком Telegram ID", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + return + + input_text = message.text.strip() + + if not input_text: + await message.answer( + "❌ Введите корректный список Telegram ID", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + return + + # Парсим ID из текста + try: + telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text) + except Exception as e: + logger.error(f"Ошибка парсинга Telegram ID: {e}") + await message.answer( + "❌ Ошибка при обработке списка ID. Проверьте формат ввода.", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + return + + if not telegram_ids: + await message.answer( + "❌ Не найдено корректных Telegram ID в списке", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + return + + if len(telegram_ids) > 1000: # Ограничение на количество ID за раз + await message.answer( + f"❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + return + + # Выполняем массовую блокировку + try: + successfully_banned, not_found, error_ids = await bulk_ban_service.ban_users_by_telegram_ids( + db=db, + admin_user_id=db_user.id, + telegram_ids=telegram_ids, + reason="Массовая блокировка администратором", + bot=message.bot, + notify_admin=True, + admin_name=db_user.full_name + ) + + # Подготавливаем сообщение с результатами + result_text = f"✅ Массовая блокировка завершена\n\n" + result_text += f"📊 Результаты:\n" + result_text += f"✅ Успешно заблокировано: {successfully_banned}\n" + result_text += f"❌ Не найдено: {not_found}\n" + result_text += f"💥 Ошибок: {len(error_ids)}\n\n" + result_text += f"📈 Всего обработано: {len(telegram_ids)}" + + if successfully_banned > 0: + result_text += f"\n🎯 Процент успеха: {round((successfully_banned/len(telegram_ids))*100, 1)}%" + + # Добавляем информацию об ошибках, если есть + if error_ids: + result_text += f"\n\n⚠️ Telegram ID с ошибками:\n" + result_text += f"{', '.join(map(str, error_ids[:10]))}" # Показываем первые 10 + if len(error_ids) > 10: + result_text += f" и еще {len(error_ids) - 10}..." + + await message.answer( + result_text, + parse_mode="HTML", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="👥 К пользователям", callback_data="admin_users")] + ]) + ) + + except Exception as e: + logger.error(f"Ошибка при выполнении массовой блокировки: {e}") + await message.answer( + "❌ Произошла ошибка при выполнении массовой блокировки", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")] + ]) + ) + + await state.clear() + + +def register_bulk_ban_handlers(dp): + """ + Регистрация обработчиков команд для массовой блокировки + """ + # Обработчик команды начала массовой блокировки + dp.callback_query.register( + start_bulk_ban_process, + lambda c: c.data == "admin_bulk_ban_start" + ) + + # Обработчик текстового сообщения с ID для блокировки + dp.message.register( + process_bulk_ban_list, + AdminStates.waiting_for_bulk_ban_list + ) diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py index d25499e6..5c145416 100644 --- a/app/handlers/admin/users.py +++ b/app/handlers/admin/users.py @@ -299,6 +299,137 @@ async def show_users_list_by_balance( await callback.answer() +@admin_required +@error_handler +async def show_users_ready_to_renew( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext, + page: int = 1 +): + """Показывает пользователей с истекшей подпиской и балансом >= порога.""" + await state.set_state(AdminStates.viewing_user_from_ready_to_renew_list) + + texts = get_texts(db_user.language) + threshold = getattr( + settings, + "SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS", + 20000, + ) + + user_service = UserService() + users_data = await user_service.get_users_ready_to_renew( + db, + min_balance_kopeks=threshold, + page=page, + limit=10, + ) + + amount_text = settings.format_price(threshold) + header = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_TITLE", + "♻️ Пользователи готовы к продлению", + ) + description = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_DESC", + "Подписка истекла, а на балансе осталось {amount} или больше.", + ).format(amount=amount_text) + + if not users_data["users"]: + empty_text = texts.t( + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY", + "Сейчас нет пользователей, которые подходят под этот фильтр.", + ) + await callback.message.edit_text( + f"{header}\n\n{description}\n\n{empty_text}", + reply_markup=get_admin_users_keyboard(db_user.language), + ) + await callback.answer() + return + + text = f"{header}\n\n{description}\n\n" + text += "Нажмите на пользователя для управления:" + + keyboard = [] + current_time = datetime.utcnow() + + for user in users_data["users"]: + subscription = user.subscription + status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "🚫" + subscription_emoji = "❌" + expired_days = "?" + + if subscription: + if subscription.is_trial: + subscription_emoji = "🎁" + elif subscription.is_active: + subscription_emoji = "💎" + else: + subscription_emoji = "⏰" + + if subscription.end_date: + delta = current_time - subscription.end_date + expired_days = delta.days + + button_text = ( + f"{status_emoji} {subscription_emoji} {user.full_name}" + f" | 💰 {settings.format_price(user.balance_kopeks)}" + f" | ⏰ {expired_days}д ист." + ) + + if len(button_text) > 60: + short_name = user.full_name + if len(short_name) > 20: + short_name = short_name[:17] + "..." + button_text = ( + f"{status_emoji} {subscription_emoji} {short_name}" + f" | 💰 {settings.format_price(user.balance_kopeks)}" + ) + + keyboard.append([ + types.InlineKeyboardButton( + text=button_text, + callback_data=f"admin_user_manage_{user.id}", + ) + ]) + + if users_data["total_pages"] > 1: + pagination_row = get_admin_pagination_keyboard( + users_data["current_page"], + users_data["total_pages"], + "admin_users_ready_to_renew_list", + "admin_users_ready_to_renew_filter", + db_user.language, + ).inline_keyboard[0] + keyboard.append(pagination_row) + + keyboard.extend([ + [ + types.InlineKeyboardButton( + text="🔍 Поиск", + callback_data="admin_users_search", + ), + types.InlineKeyboardButton( + text="📊 Статистика", + callback_data="admin_users_stats", + ), + ], + [ + types.InlineKeyboardButton( + text="⬅️ Назад", + callback_data="admin_users", + ) + ], + ]) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + @admin_required @error_handler async def show_users_list_by_traffic( @@ -859,6 +990,22 @@ async def handle_users_purchases_list_pagination( await show_users_list_by_purchases(callback, db_user, db, state, 1) +@admin_required +@error_handler +async def handle_users_ready_to_renew_pagination( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, + state: FSMContext +): + try: + page = int(callback.data.split('_')[-1]) + await show_users_ready_to_renew(callback, db_user, db, state, page) + except (ValueError, IndexError) as e: + logger.error(f"Ошибка парсинга номера страницы: {e}") + await show_users_ready_to_renew(callback, db_user, db, state, 1) + + @admin_required @error_handler async def handle_users_campaign_list_pagination( @@ -1502,6 +1649,8 @@ async def show_user_management( back_callback = "admin_users_purchases_filter" elif current_state == AdminStates.viewing_user_from_campaign_list: back_callback = "admin_users_campaign_filter" + elif current_state == AdminStates.viewing_user_from_ready_to_renew_list: + back_callback = "admin_users_ready_to_renew_filter" # Базовая клавиатура профиля kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback) @@ -4559,10 +4708,13 @@ async def admin_buy_subscription_execute( target_user.telegram_id, ) - if subscription.end_date <= current_time: + extension_base_date = current_time + if subscription.end_date and subscription.end_date > current_time: + extension_base_date = subscription.end_date + else: subscription.start_date = current_time - subscription.end_date = current_time + timedelta(days=period_days) + bonus_period + subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period subscription.status = SubscriptionStatus.ACTIVE.value subscription.updated_at = current_time @@ -4857,6 +5009,11 @@ def register_handlers(dp: Dispatcher): F.data.startswith("admin_users_purchases_list_page_") ) + dp.callback_query.register( + handle_users_ready_to_renew_pagination, + F.data.startswith("admin_users_ready_to_renew_list_page_") + ) + dp.callback_query.register( handle_users_campaign_list_pagination, F.data.startswith("admin_users_campaign_list_page_") @@ -5128,6 +5285,11 @@ def register_handlers(dp: Dispatcher): show_users_list_by_purchases, F.data == "admin_users_purchases_filter" ) + + dp.callback_query.register( + show_users_ready_to_renew, + F.data == "admin_users_ready_to_renew_filter" + ) dp.callback_query.register( show_users_list_by_campaign, diff --git a/app/handlers/balance/cryptobot.py b/app/handlers/balance/cryptobot.py index b1291c9d..bd033920 100644 --- a/app/handlers/balance/cryptobot.py +++ b/app/handlers/balance/cryptobot.py @@ -7,6 +7,7 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService from app.utils.decorators import error_handler from app.states import BalanceStates @@ -96,8 +97,26 @@ async def process_cryptobot_payment_amount( amount_kopeks: int, state: FSMContext ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Оплата невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) - + if not settings.is_cryptobot_enabled(): await message.answer("❌ Оплата криптовалютой временно недоступна") return diff --git a/app/handlers/balance/stars.py b/app/handlers/balance/stars.py index a8cac0f8..71ae3149 100644 --- a/app/handlers/balance/stars.py +++ b/app/handlers/balance/stars.py @@ -6,6 +6,7 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService from app.states import BalanceStates from app.utils.decorators import error_handler @@ -68,6 +69,24 @@ async def process_stars_payment_amount( amount_kopeks: int, state: FSMContext ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Оплата невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) if not settings.TELEGRAM_STARS_ENABLED: diff --git a/app/handlers/balance/yookassa.py b/app/handlers/balance/yookassa.py index fb6a345c..2ee88ef0 100644 --- a/app/handlers/balance/yookassa.py +++ b/app/handlers/balance/yookassa.py @@ -10,6 +10,7 @@ from app.config import settings from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.blacklist_service import blacklist_service from app.services.payment_service import PaymentService from app.utils.decorators import error_handler from app.states import BalanceStates @@ -133,8 +134,26 @@ async def process_yookassa_payment_amount( amount_kopeks: int, state: FSMContext ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Оплата невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) - + if not settings.is_yookassa_enabled(): await message.answer("❌ Оплата через YooKassa временно недоступна") return @@ -261,8 +280,26 @@ async def process_yookassa_sbp_payment_amount( amount_kopeks: int, state: FSMContext ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Оплата невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) - + if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED: await message.answer("❌ Оплата через СБП временно недоступна") return diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py index 960bd10b..f8303acb 100644 --- a/app/handlers/promocode.py +++ b/app/handlers/promocode.py @@ -7,6 +7,7 @@ from app.states import PromoCodeStates from app.database.models import User from app.keyboards.inline import get_back_keyboard from app.localization.texts import get_texts +from app.services.blacklist_service import blacklist_service from app.services.promocode_service import PromoCodeService from app.services.admin_notification_service import AdminNotificationService from app.utils.decorators import error_handler @@ -79,6 +80,24 @@ async def process_promocode( state: FSMContext, db: AsyncSession ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Активация промокода невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) code = message.text.strip() diff --git a/app/handlers/start.py b/app/handlers/start.py index 7c1c8a57..9fce80cc 100644 --- a/app/handlers/start.py +++ b/app/handlers/start.py @@ -42,6 +42,7 @@ from app.utils.promo_offer import ( from app.utils.timezone import format_local_datetime from app.database.crud.user_message import get_random_active_message from app.database.crud.subscription import decrement_subscription_server_counts +from app.services.blacklist_service import blacklist_service logger = logging.getLogger(__name__) @@ -1000,13 +1001,33 @@ async def process_referral_code_skip( async def complete_registration_from_callback( callback: types.CallbackQuery, - state: FSMContext, + state: FSMContext, db: AsyncSession ): logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}") - + + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + callback.from_user.id, + callback.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await callback.message.answer( + f"🚫 Регистрация невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + + await state.clear() + return + from sqlalchemy.orm import selectinload - + existing_user = await get_user_by_telegram_id(db, callback.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: @@ -1255,12 +1276,32 @@ async def complete_registration_from_callback( async def complete_registration( - message: types.Message, - state: FSMContext, + message: types.Message, + state: FSMContext, db: AsyncSession ): logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}") - + + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + message.from_user.id, + message.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await message.answer( + f"🚫 Регистрация невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку." + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + + await state.clear() + return + existing_user = await get_user_by_telegram_id(db, message.from_user.id) if existing_user and existing_user.status == UserStatus.ACTIVE.value: diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index b10733a5..f143b141 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -51,6 +51,7 @@ from app.services.user_cart_service import user_cart_service from app.localization.texts import get_texts from app.services.admin_notification_service import AdminNotificationService from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService +from app.services.blacklist_service import blacklist_service from app.services.subscription_checkout_service import ( clear_subscription_checkout_draft, get_subscription_checkout_draft, @@ -995,7 +996,7 @@ async def save_cart_and_redirect_to_topup( 'return_to_cart': True, 'user_id': db_user.id } - + await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( @@ -1020,7 +1021,7 @@ async def return_to_saved_cart( ): # Получаем данные корзины из Redis cart_data = await user_cart_service.get_user_cart(db_user.id) - + if not cart_data: await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True) return @@ -1347,6 +1348,25 @@ async def confirm_extend_subscription( db_user: User, db: AsyncSession ): + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + callback.from_user.id, + callback.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await callback.answer( + f"🚫 Продление подписки невозможно\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку.", + show_alert=True + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + from app.services.admin_notification_service import AdminNotificationService days = int(callback.data.split('_')[2]) @@ -1528,7 +1548,7 @@ async def confirm_extend_subscription( 'description': f"Продление подписки на {days} дней", 'consume_promo_offer': bool(promo_component["discount"] > 0), } - + await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( @@ -1811,6 +1831,25 @@ async def confirm_purchase( ): from app.services.admin_notification_service import AdminNotificationService + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + callback.from_user.id, + callback.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await callback.answer( + f"🚫 Покупка подписки невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку.", + show_alert=True + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + data = await state.get_data() texts = get_texts(db_user.language) @@ -2101,7 +2140,7 @@ async def confirm_purchase( 'return_to_cart': True, 'user_id': db_user.id } - + await user_cart_service.save_user_cart(db_user.id, cart_data) await callback.message.edit_text( @@ -2210,36 +2249,37 @@ async def confirm_purchase( if should_update_devices: existing_subscription.device_limit = selected_devices # Проверяем, что при обновлении существующей подписки есть хотя бы одна страна - selected_countries = data.get('countries', []) + selected_countries = data.get('countries') if not selected_countries: - # В случае если подписка уже существовала, не разрешаем отключать все страны - # Если подписка новая, разрешаем, но обычно через UI пользователь должен выбрать хотя бы один сервер - if existing_subscription and existing_subscription.connected_squads is not None: - # Проверим, что в данных есть информация о том, что это обновление существующей подписки - # или что-то указывает, что не нужно отключать все страны - pass # Для простоты в этом случае просто проверим, что список стран не пустой - else: - # Для новой подписки разрешаем пустой список, если не является обновлением - pass + # Иногда после возврата к оформлению из сохраненной корзины список стран не передается. + # В таком случае повторно используем текущие подключенные страны подписки. + selected_countries = existing_subscription.connected_squads or [] + if selected_countries: + data['countries'] = selected_countries # чтобы далее использовать фактический список стран - # Но для безопасности - если список стран пустой, проверим, что это разрешено - # иначе вернем ошибку - if not selected_countries: - texts = get_texts(db_user.language) - await callback.message.edit_text( - texts.t( - "COUNTRIES_MINIMUM_REQUIRED", - "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." - ), - reply_markup=get_back_keyboard(db_user.language) - ) - await callback.answer() - return + if not selected_countries: + texts = get_texts(db_user.language) + await callback.message.edit_text( + texts.t( + "COUNTRIES_MINIMUM_REQUIRED", + "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна." + ), + reply_markup=get_back_keyboard(db_user.language) + ) + await callback.answer() + return existing_subscription.connected_squads = selected_countries - existing_subscription.start_date = current_time - existing_subscription.end_date = current_time + timedelta(days=period_days) + bonus_period + # Если подписка еще активна, продлеваем от текущей даты окончания, + # иначе начинаем новый период с текущего момента + extension_base_date = current_time + if existing_subscription.end_date and existing_subscription.end_date > current_time: + extension_base_date = existing_subscription.end_date + else: + existing_subscription.start_date = current_time + + existing_subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period existing_subscription.updated_at = current_time existing_subscription.traffic_used_gb = 0.0 @@ -2266,7 +2306,7 @@ async def confirm_purchase( resolved_device_limit = default_device_limit # Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран - new_subscription_countries = data.get('countries', []) + new_subscription_countries = data.get('countries') if not new_subscription_countries: # Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну # Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну @@ -2304,11 +2344,11 @@ async def confirm_purchase( await add_user_to_servers(db, server_ids) logger.info(f"Сохранены цены серверов за весь период: {server_prices}") - + await db.refresh(db_user) - + subscription_service = SubscriptionService() - + if db_user.remnawave_uuid: remnawave_user = await subscription_service.update_remnawave_user( db, @@ -2323,7 +2363,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки", ) - + if not remnawave_user: logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}") remnawave_user = await subscription_service.create_remnawave_user( @@ -2332,7 +2372,7 @@ async def confirm_purchase( reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="покупка подписки (повторная попытка)", ) - + transaction = await create_transaction( db=db, user_id=db_user.id, @@ -2939,7 +2979,7 @@ def register_handlers(dp: Dispatcher): show_device_connection_help, F.data == "device_connection_help" ) - + # Регистрируем обработчик для простой покупки dp.callback_query.register( handle_simple_subscription_purchase, @@ -2954,12 +2994,31 @@ async def handle_simple_subscription_purchase( db: AsyncSession, ): """Обрабатывает простую покупку подписки.""" + # Проверяем, находится ли пользователь в черном списке + is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted( + callback.from_user.id, + callback.from_user.username + ) + + if is_blacklisted: + logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}") + try: + await callback.answer( + f"🚫 Простая покупка подписки невозможна\n\n" + f"Причина: {blacklist_reason}\n\n" + f"Если вы считаете, что это ошибка, обратитесь в поддержку.", + show_alert=True + ) + except Exception as e: + logger.error(f"Ошибка при отправке сообщения о блокировке: {e}") + return + texts = get_texts(db_user.language) - + if not settings.SIMPLE_SUBSCRIPTION_ENABLED: await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True) return - + # Определяем ограничение по устройствам для текущего режима simple_device_limit = resolve_simple_subscription_device_limit() @@ -2989,10 +3048,10 @@ async def handle_simple_subscription_purchase( "traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB, "squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID } - + # Сохраняем параметры в состояние await state.update_data(subscription_params=subscription_params) - + # Проверяем баланс пользователя user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) # Рассчитываем цену подписки @@ -3017,7 +3076,7 @@ async def handle_simple_subscription_purchase( if subscription_params["traffic_limit_gb"] == 0 else f"{subscription_params['traffic_limit_gb']} ГБ" ) - + if user_balance_kopeks >= price_kopeks: # Если баланс достаточный, предлагаем оплатить с баланса simple_lines = [ @@ -3040,7 +3099,7 @@ async def handle_simple_subscription_purchase( ]) message_text = "\n".join(simple_lines) - + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ [types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")], [types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")], @@ -3068,19 +3127,19 @@ async def handle_simple_subscription_purchase( ]) message_text = "\n".join(simple_lines) - + keyboard = _get_simple_subscription_payment_keyboard(db_user.language) - + await callback.message.edit_text( message_text, reply_markup=keyboard, parse_mode="HTML" ) - + await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method) await callback.answer() - + async def _calculate_simple_subscription_price( @@ -3105,14 +3164,14 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb """Создает клавиатуру с методами оплаты для простой подписки.""" texts = get_texts(language) keyboard = [] - + # Добавляем доступные методы оплаты if settings.TELEGRAM_STARS_ENABLED: keyboard.append([types.InlineKeyboardButton( text="⭐ Telegram Stars", callback_data="simple_subscription_stars" )]) - + if settings.is_yookassa_enabled(): yookassa_methods = [] if settings.YOOKASSA_SBP_ENABLED: @@ -3126,38 +3185,38 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb )) if yookassa_methods: keyboard.append(yookassa_methods) - + if settings.is_cryptobot_enabled(): keyboard.append([types.InlineKeyboardButton( text="🪙 CryptoBot", callback_data="simple_subscription_cryptobot" )]) - + if settings.is_mulenpay_enabled(): mulenpay_name = settings.get_mulenpay_display_name() keyboard.append([types.InlineKeyboardButton( text=f"💳 {mulenpay_name}", callback_data="simple_subscription_mulenpay" )]) - + if settings.is_pal24_enabled(): keyboard.append([types.InlineKeyboardButton( text="💳 PayPalych", callback_data="simple_subscription_pal24" )]) - + if settings.is_wata_enabled(): keyboard.append([types.InlineKeyboardButton( text="💳 WATA", callback_data="simple_subscription_wata" )]) - + # Кнопка назад keyboard.append([types.InlineKeyboardButton( text=texts.BACK, callback_data="subscription_purchase" )]) - + return types.InlineKeyboardMarkup(inline_keyboard=keyboard) @@ -3179,9 +3238,9 @@ async def _extend_existing_subscription( from app.services.subscription_service import SubscriptionService from app.utils.pricing_utils import calculate_months_from_days from datetime import datetime, timedelta - + texts = get_texts(db_user.language) - + # Рассчитываем цену подписки subscription_params = { "period_days": period_days, @@ -3205,7 +3264,7 @@ async def _extend_existing_subscription( price_breakdown.get("servers_price", 0), price_breakdown.get("total_discount", 0), ) - + # Проверяем баланс пользователя if db_user.balance_kopeks < price_kopeks: missing_kopeks = price_kopeks - db_user.balance_kopeks @@ -3223,7 +3282,7 @@ async def _extend_existing_subscription( balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) - + # Подготовим данные для сохранения в корзину from app.services.user_cart_service import user_cart_service cart_data = { @@ -3241,9 +3300,9 @@ async def _extend_existing_subscription( 'squad_uuid': squad_uuid, 'consume_promo_offer': False, } - + await user_cart_service.save_user_cart(db_user.id, cart_data) - + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( @@ -3255,7 +3314,7 @@ async def _extend_existing_subscription( ) await callback.answer() return - + # Списываем средства success = await subtract_user_balance( db, @@ -3264,15 +3323,15 @@ async def _extend_existing_subscription( f"Продление подписки на {period_days} дней", consume_promo_offer=False, # Простая покупка не использует промо-скидки ) - + if not success: await callback.answer("⚠ Ошибка списания средств", show_alert=True) return - + # Обновляем параметры подписки current_time = datetime.utcnow() old_end_date = current_subscription.end_date - + # Обновляем параметры в зависимости от типа текущей подписки if current_subscription.is_trial: # При продлении триальной подписки переводим её в обычную @@ -3296,7 +3355,7 @@ async def _extend_existing_subscription( if squad_uuid and squad_uuid not in current_subscription.connected_squads: # Используем += для безопасного добавления в список SQLAlchemy current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid] - + # Продлеваем подписку if current_subscription.end_date > current_time: # Если подписка ещё активна, добавляем дни к текущей дате окончания @@ -3304,15 +3363,15 @@ async def _extend_existing_subscription( else: # Если подписка уже истекла, начинаем от текущего времени new_end_date = current_time + timedelta(days=period_days) - + current_subscription.end_date = new_end_date current_subscription.updated_at = current_time - + # Сохраняем изменения await db.commit() await db.refresh(current_subscription) await db.refresh(db_user) - + # Обновляем пользователя в Remnawave subscription_service = SubscriptionService() try: @@ -3328,7 +3387,7 @@ async def _extend_existing_subscription( logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE") except Exception as e: logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}") - + # Создаём транзакцию transaction = await create_transaction( db=db, @@ -3337,7 +3396,7 @@ async def _extend_existing_subscription( amount_kopeks=price_kopeks, description=f"Продление подписки на {period_days} дней" ) - + # Отправляем уведомление админу try: notification_service = AdminNotificationService(callback.bot) @@ -3353,7 +3412,7 @@ async def _extend_existing_subscription( ) except Exception as e: logger.error(f"Ошибка отправки уведомления о продлении: {e}") - + # Отправляем сообщение пользователю success_message = ( "✅ Подписка успешно продлена!\n\n" @@ -3361,15 +3420,15 @@ async def _extend_existing_subscription( f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n" f"💰 Списано: {texts.format_price(price_kopeks)}" ) - + # Если это была триальная подписка, добавляем информацию о преобразовании if current_subscription.is_trial: success_message += "\n🎯 Триальная подписка преобразована в платную" - + await callback.message.edit_text( success_message, reply_markup=get_back_keyboard(db_user.language) ) - + logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}₽") await callback.answer() diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 964984fb..c19136c9 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -320,6 +320,18 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup: callback_data="admin_users_filters" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_BLACKLIST", "🔐 Черный список"), + callback_data="admin_blacklist_settings" + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_BULK_BAN", "🛑 Массовый бан"), + callback_data="admin_bulk_ban_start" + ) + ], [ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users") ] @@ -360,6 +372,12 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark callback_data="admin_users_purchases_filter" ) ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"), + callback_data="admin_users_ready_to_renew_filter" + ) + ], [ InlineKeyboardButton( text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"), diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index ab1b9dcd..bed45d8c 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -711,6 +711,10 @@ "ADMIN_USERS_FILTERS": "⚙️ Filters", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity", "ADMIN_USERS_FILTER_BALANCE": "💰 By balance", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Ready to renew", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Users ready to renew", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Their subscription expired and the balance still has {amount} or more.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "No users match this filter right now.", "ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign", "ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases", "ADMIN_USERS_FILTER_SPENDING": "💳 By spending", @@ -1528,5 +1532,7 @@ "POLL_EMPTY": "Poll is not available yet.", "POLL_ERROR": "Unable to process the poll. Please try again later.", "POLL_COMPLETED": "🙏 Thanks for completing the poll!", - "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance." + "POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.", + "ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban", + "ADMIN_USERS_BLACKLIST": "🔐 Blacklist" } diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 5a24611e..ebbc345d 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -712,6 +712,10 @@ "ADMIN_USERS_FILTERS": "⚙️ Фильтры", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности", "ADMIN_USERS_FILTER_BALANCE": "💰 По балансу", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готовы к продлению", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.", "ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании", "ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок", "ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат", @@ -1540,5 +1544,7 @@ "POLL_EMPTY": "Опрос пока недоступен.", "POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.", "POLL_COMPLETED": "🙏 Спасибо за участие в опросе!", - "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс." + "POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.", + "ADMIN_USERS_BULK_BAN": "🛑 Массовый бан", + "ADMIN_USERS_BLACKLIST": "🔐 Черный список" } diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index 2a419cc2..c70a5f1d 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -710,8 +710,12 @@ "ADMIN_USERS_ALL": "👥 Всі користувачі", "ADMIN_USERS_FILTERS": "⚙️ Фільтри", "ADMIN_USERS_FILTER_ACTIVITY": "🕒 За активністю", - "ADMIN_USERS_FILTER_BALANCE": "💰 За балансом", - "ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією", + "ADMIN_USERS_FILTER_BALANCE": "💰 За балансом", + "ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готові до продовження", + "ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Користувачі, готові до продовження", + "ADMIN_USERS_FILTER_RENEW_READY_DESC": "Підписка вже закінчилась, а на балансі залишилось {amount} або більше.", + "ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Наразі немає користувачів, які підходять під цей фільтр.", + "ADMIN_USERS_FILTER_CAMPAIGN": "📢 За кампанією", "ADMIN_USERS_FILTER_PURCHASES": "🛒 За кількістю покупок", "ADMIN_USERS_FILTER_SPENDING": "💳 За сумою витрат", "ADMIN_USERS_FILTER_TRAFFIC": "📶 За трафіком", @@ -1532,4 +1536,4 @@ "POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.", "POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!", "POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс." -} \ No newline at end of file +} diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 235a9b97..5d3a798e 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -710,6 +710,10 @@ "ADMIN_USERS_FILTERS":"⚙️筛选器", "ADMIN_USERS_FILTER_ACTIVITY":"🕒按活跃度", "ADMIN_USERS_FILTER_BALANCE":"💰按余额", +"ADMIN_USERS_FILTER_RENEW_READY":"♻️准备续费", +"ADMIN_USERS_FILTER_RENEW_READY_TITLE":"♻️准备续费的用户", +"ADMIN_USERS_FILTER_RENEW_READY_DESC":"订阅已到期,但余额仍不少于{amount}。", +"ADMIN_USERS_FILTER_RENEW_READY_EMPTY":"目前没有符合条件的用户。", "ADMIN_USERS_FILTER_CAMPAIGN":"📢按活动", "ADMIN_USERS_FILTER_PURCHASES":"🛒按购买次数", "ADMIN_USERS_FILTER_SPENDING":"💳按消费金额", @@ -1860,4 +1864,4 @@ "POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。", "DEVICE_GUIDE_WINDOWS":"💻Windows", "REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前" -} \ No newline at end of file +} diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py index f12e0664..dc9ab2d3 100644 --- a/app/services/admin_notification_service.py +++ b/app/services/admin_notification_service.py @@ -1451,6 +1451,50 @@ class AdminNotificationService: return str(value) return str(value) + async def send_bulk_ban_notification( + self, + admin_user_id: int, + successfully_banned: int, + not_found: int, + errors: int, + admin_name: str = "Администратор" + ) -> bool: + """Отправляет уведомление о массовой блокировке пользователей""" + if not self._is_enabled(): + return False + + try: + message_lines = [ + "🛑 МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ", + "", + f"👮 Администратор: {admin_name}", + f"🆔 ID администратора: {admin_user_id}", + "", + "📊 Результаты:", + f"✅ Успешно заблокировано: {successfully_banned}", + f"❌ Не найдено: {not_found}", + f"💥 Ошибок: {errors}" + ] + + total_processed = successfully_banned + not_found + errors + if total_processed > 0: + success_rate = (successfully_banned / total_processed) * 100 + message_lines.append(f"📈 Успешность: {success_rate:.1f}%") + + message_lines.extend( + [ + "", + f"⏰ {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}", + ] + ) + + message = "\n".join(message_lines) + return await self._send_message(message) + + except Exception as e: + logger.error(f"Ошибка отправки уведомления о массовой блокировке: {e}") + return False + async def send_ticket_event_notification( self, text: str, @@ -1468,3 +1512,49 @@ class AdminNotificationService: if not (self._is_enabled() and runtime_enabled): return False return await self._send_message(text, reply_markup=keyboard, ticket_event=True) + + async def send_suspicious_traffic_notification( + self, + message: str, + bot: Bot, + topic_id: Optional[int] = None + ) -> bool: + """ + Отправляет уведомление о подозрительной активности трафика + + Args: + message: текст уведомления + bot: экземпляр бота для отправки сообщения + topic_id: ID топика для отправки уведомления (если не указан, использует стандартный) + """ + if not self.chat_id: + logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен") + return False + + # Используем специальный топик для подозрительной активности, если он задан + notification_topic_id = topic_id or self.topic_id + + try: + message_kwargs = { + 'chat_id': self.chat_id, + 'text': message, + 'parse_mode': 'HTML', + 'disable_web_page_preview': True + } + + if notification_topic_id: + message_kwargs['message_thread_id'] = notification_topic_id + + await bot.send_message(**message_kwargs) + logger.info(f"Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}") + return True + + except TelegramForbiddenError: + logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}") + return False + except TelegramBadRequest as e: + logger.error(f"Ошибка отправки уведомления о подозрительной активности: {e}") + return False + except Exception as e: + logger.error(f"Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}") + return False diff --git a/app/services/blacklist_service.py b/app/services/blacklist_service.py new file mode 100644 index 00000000..f21ec5dd --- /dev/null +++ b/app/services/blacklist_service.py @@ -0,0 +1,230 @@ +""" +Сервис для работы с черным списком пользователей +Проверяет пользователей по списку из GitHub репозитория +""" +import asyncio +import logging +import re +from typing import List, Dict, Optional, Tuple +from datetime import datetime, timedelta +import aiohttp +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class BlacklistService: + """ + Сервис для проверки пользователей по черному списку + """ + + def __init__(self): + self.blacklist_data = [] # Список в формате [(telegram_id, username, reason), ...] + self.last_update = None + # Используем интервал из настроек, по умолчанию 24 часа + interval_hours = self.get_blacklist_update_interval_hours() + self.update_interval = timedelta(hours=interval_hours) + self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений + + def is_blacklist_check_enabled(self) -> bool: + """Проверяет, включена ли проверка черного списка""" + return getattr(settings, 'BLACKLIST_CHECK_ENABLED', False) + + def get_blacklist_github_url(self) -> Optional[str]: + """Получает URL к файлу черного списка на GitHub""" + return getattr(settings, 'BLACKLIST_GITHUB_URL', None) + + def get_blacklist_update_interval_hours(self) -> int: + """Получает интервал обновления черного списка в часах""" + return getattr(settings, 'BLACKLIST_UPDATE_INTERVAL_HOURS', 24) + + def should_ignore_admins(self) -> bool: + """Проверяет, нужно ли игнорировать администраторов при проверке черного списка""" + return getattr(settings, 'BLACKLIST_IGNORE_ADMINS', True) + + def is_admin(self, telegram_id: int) -> bool: + """Проверяет, является ли пользователь администратором""" + return settings.is_admin(telegram_id) + + async def update_blacklist(self) -> bool: + """ + Обновляет черный список из GitHub репозитория + """ + async with self.lock: + github_url = self.get_blacklist_github_url() + if not github_url: + logger.warning("URL к черному списку не задан в настройках") + return False + + try: + # Заменяем github.com на raw.githubusercontent.com для получения raw содержимого + if "github.com" in github_url: + raw_url = github_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") + else: + raw_url = github_url + + # Получаем содержимое файла + async with aiohttp.ClientSession() as session: + async with session.get(raw_url) as response: + if response.status != 200: + logger.error(f"Ошибка при получении черного списка: статус {response.status}") + return False + + content = await response.text() + + # Разбираем содержимое файла + blacklist_data = [] + lines = content.splitlines() + + for line_num, line in enumerate(lines, 1): + line = line.strip() + if not line or line.startswith('#'): + continue # Пропускаем пустые строки и комментарии + + # В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок' + # только первая часть до пробела - это Telegram ID, всё остальное комментарий + parts = line.split() + if not parts: + continue + + try: + telegram_id = int(parts[0]) # Первое число - это Telegram ID + # Всё остальное - просто комментарий, не используем его для логики + # Но можем использовать первую часть после ID как username для отображения + username = "" + if len(parts) > 1: + # Берем вторую часть как username (если начинается с @) + if parts[1].startswith('@'): + username = parts[1] + + # По умолчанию используем "Занесен в черный список", если нет другой информации + reason = "Занесен в черный список" + + # Если есть запятая в строке, можем использовать часть после нее как причину + full_line_after_id = line[len(str(telegram_id)):].strip() + if ',' in full_line_after_id: + # Извлекаем причину после запятой + after_comma = full_line_after_id.split(',', 1)[1].strip() + reason = after_comma + + blacklist_data.append((telegram_id, username, reason)) + except ValueError: + # Если не удается преобразовать в число, это не ID + logger.warning(f"Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}") + + self.blacklist_data = blacklist_data + self.last_update = datetime.utcnow() + logger.info(f"Черный список успешно обновлен. Найдено {len(blacklist_data)} записей") + return True + + except ValueError as e: + logger.error(f"Ошибка при парсинге ID из черного списка: {e}") + return False + except Exception as e: + logger.error(f"Ошибка при обновлении черного списка: {e}") + return False + + async def is_user_blacklisted(self, telegram_id: int, username: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """ + Проверяет, находится ли пользователь в черном списке + + Args: + telegram_id: Telegram ID пользователя + username: Username пользователя (опционально) + + Returns: + Кортеж (в черном списке, причина) + """ + if not self.is_blacklist_check_enabled(): + return False, None + + # Проверяем, является ли пользователь администратором и нужно ли его игнорировать + if self.should_ignore_admins() and self.is_admin(telegram_id): + logger.info(f"Пользователь {telegram_id} является администратором, игнорируем проверку черного списка") + return False, None + + # Если черный список пуст или устарел, обновляем его + interval_hours = self.get_blacklist_update_interval_hours() + required_interval = timedelta(hours=interval_hours) + if not self.blacklist_data or (self.last_update and + datetime.utcnow() - self.last_update > required_interval): + await self.update_blacklist() + + # Проверяем по Telegram ID + for bl_id, bl_username, bl_reason in self.blacklist_data: + if bl_id == telegram_id: + logger.info(f"Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}") + return True, bl_reason + + # Проверяем по username, если он передан + if username: + for bl_id, bl_username, bl_reason in self.blacklist_data: + if bl_username and (bl_username == username or bl_username == f"@{username}"): + logger.info(f"Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}") + return True, bl_reason + + return False, None + + async def get_all_blacklisted_users(self) -> List[Tuple[int, str, str]]: + """ + Возвращает весь черный список + """ + interval_hours = self.get_blacklist_update_interval_hours() + required_interval = timedelta(hours=interval_hours) + if not self.blacklist_data or (self.last_update and + datetime.utcnow() - self.last_update > required_interval): + await self.update_blacklist() + + return self.blacklist_data.copy() + + async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[Tuple[int, str, str]]: + """ + Возвращает информацию о пользователе из черного списка по Telegram ID + + Args: + telegram_id: Telegram ID пользователя + + Returns: + Кортеж (telegram_id, username, reason) или None, если не найден + """ + for bl_id, bl_username, bl_reason in self.blacklist_data: + if bl_id == telegram_id: + return (bl_id, bl_username, bl_reason) + return None + + async def get_user_by_username(self, username: str) -> Optional[Tuple[int, str, str]]: + """ + Возвращает информацию о пользователе из черного списка по username + + Args: + username: Username пользователя + + Returns: + Кортеж (telegram_id, username, reason) или None, если не найден + """ + # Проверяем как с @, так и без + username_with_at = f"@{username}" if not username.startswith('@') else username + username_without_at = username.lstrip('@') + + for bl_id, bl_username, bl_reason in self.blacklist_data: + if bl_username == username_with_at or bl_username.lstrip('@') == username_without_at: + return (bl_id, bl_username, bl_reason) + return None + + async def force_update_blacklist(self) -> Tuple[bool, str]: + """ + Принудительно обновляет черный список + + Returns: + Кортеж (успешно, сообщение) + """ + success = await self.update_blacklist() + if success: + return True, f"Черный список обновлен успешно. Записей: {len(self.blacklist_data)}" + else: + return False, "Ошибка обновления черного списка" + + +# Глобальный экземпляр сервиса +blacklist_service = BlacklistService() \ No newline at end of file diff --git a/app/services/bulk_ban_service.py b/app/services/bulk_ban_service.py new file mode 100644 index 00000000..e07f0386 --- /dev/null +++ b/app/services/bulk_ban_service.py @@ -0,0 +1,177 @@ +""" +Модуль для массовой блокировки пользователей по списку Telegram ID +""" + +import logging +from typing import List, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from aiogram import Bot + +from app.database.crud.user import get_user_by_telegram_id +from app.services.user_service import UserService +from app.services.admin_notification_service import AdminNotificationService +from app.config import settings +from app.database.models import UserStatus + + +logger = logging.getLogger(__name__) + + +class BulkBanService: + """ + Сервис для массовой блокировки пользователей по списку Telegram ID + """ + + def __init__(self): + self.user_service = UserService() + + async def ban_users_by_telegram_ids( + self, + db: AsyncSession, + admin_user_id: int, + telegram_ids: List[int], + reason: str = "Заблокирован администратором по списку", + bot: Bot = None, + notify_admin: bool = True, + admin_name: str = "Администратор" + ) -> Tuple[int, int, List[int]]: + """ + Массовая блокировка пользователей по Telegram ID + + Args: + db: Асинхронная сессия базы данных + admin_user_id: ID администратора, который осуществляет блокировку + telegram_ids: Список Telegram ID для блокировки + reason: Причина блокировки + bot: Бот для отправки уведомлений + notify_admin: Отправлять ли уведомления администратору + admin_name: Имя администратора для логирования + + Returns: + Кортеж из (успешно заблокированных, не найденных, список ID с ошибками) + """ + successfully_banned = 0 + not_found_users = [] + error_ids = [] + + for telegram_id in telegram_ids: + try: + # Получаем пользователя по Telegram ID + user = await get_user_by_telegram_id(db, telegram_id) + + if not user: + logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден") + not_found_users.append(telegram_id) + continue + + # Проверяем, что пользователь не заблокирован уже + if user.status == UserStatus.BLOCKED.value: + logger.info(f"Пользователь {telegram_id} уже заблокирован") + continue + + # Блокируем пользователя + ban_success = await self.user_service.block_user( + db, user.id, admin_user_id, reason + ) + + if ban_success: + successfully_banned += 1 + logger.info(f"Пользователь {telegram_id} успешно заблокирован") + + # Отправляем уведомление пользователю, если возможно + if bot: + try: + await bot.send_message( + chat_id=telegram_id, + text=( + f"🚫 Ваш аккаунт заблокирован\n\n" + f"Причина: {reason}\n\n" + f"Если вы считаете, что блокировка произошла ошибочно, " + f"обратитесь в поддержку." + ), + parse_mode="HTML" + ) + except Exception as e: + logger.warning(f"Не удалось отправить уведомление пользователю {telegram_id}: {e}") + else: + logger.error(f"Не удалось заблокировать пользователя {telegram_id}") + error_ids.append(telegram_id) + + except Exception as e: + logger.error(f"Ошибка при блокировке пользователя {telegram_id}: {e}") + error_ids.append(telegram_id) + + # Отправляем уведомление администратору + if notify_admin and bot: + try: + admin_notification_service = AdminNotificationService(bot) + await admin_notification_service.send_bulk_ban_notification( + admin_user_id, + successfully_banned, + len(not_found_users), + len(error_ids), + admin_name + ) + except Exception as e: + logger.error(f"Ошибка при отправке уведомления администратору: {e}") + + logger.info( + f"Массовая блокировка завершена: успешно={successfully_banned}, " + f"не найдено={len(not_found_users)}, ошибки={len(error_ids)}" + ) + + return successfully_banned, len(not_found_users), error_ids + + async def parse_telegram_ids_from_text(self, text: str) -> List[int]: + """ + Парсит Telegram ID из текста. Поддерживает различные форматы: + - по одному ID на строку + - через запятую + - через пробелы + - с @username (если username соответствует формату ID) + """ + if not text: + return [] + + # Удаляем лишние пробелы и разбиваем по переносам строк + lines = text.strip().split('\n') + ids = [] + + for line in lines: + # Убираем комментарии и лишние пробелы + line = line.strip() + if not line or line.startswith('#'): + continue + + # Разбиваем строку по запятым или пробелам + tokens = line.replace(',', ' ').split() + + for token in tokens: + token = token.strip() + + # Убираем символ @ если присутствует + if token.startswith('@'): + token = token[1:] + + # Проверяем, является ли токен числом (Telegram ID) + try: + telegram_id = int(token) + if telegram_id > 0: # Telegram ID должны быть положительными + ids.append(telegram_id) + except ValueError: + # Пропускаем, если не является числом + continue + + # Убираем дубликаты, сохранив порядок + unique_ids = [] + seen = set() + for tid in ids: + if tid not in seen: + unique_ids.append(tid) + seen.add(tid) + + return unique_ids + + +# Создаем глобальный экземпляр сервиса +bulk_ban_service = BulkBanService() \ No newline at end of file diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 712bc90d..272a0d83 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -1110,8 +1110,14 @@ class MiniAppSubscriptionPurchaseService: subscription.traffic_limit_gb = pricing.selection.traffic_value subscription.device_limit = pricing.selection.devices subscription.connected_squads = pricing.selection.servers - subscription.start_date = now - subscription.end_date = now + timedelta(days=pricing.selection.period.days) + bonus_period + + extension_base_date = now + if subscription.end_date and subscription.end_date > now: + extension_base_date = subscription.end_date + else: + subscription.start_date = now + + subscription.end_date = extension_base_date + timedelta(days=pricing.selection.period.days) + bonus_period subscription.updated_at = now subscription.traffic_used_gb = 0.0 @@ -1229,4 +1235,3 @@ class SubscriptionPurchaseService: purchase_service = MiniAppSubscriptionPurchaseService() - diff --git a/app/services/traffic_monitoring_service.py b/app/services/traffic_monitoring_service.py new file mode 100644 index 00000000..09e27262 --- /dev/null +++ b/app/services/traffic_monitoring_service.py @@ -0,0 +1,332 @@ +""" +Сервис для мониторинга трафика пользователей +Проверяет, не превышает ли пользователь заданный порог трафика за сутки +""" +import logging +import asyncio +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple +from decimal import Decimal + +import aiohttp + +from app.config import settings +from app.services.admin_notification_service import AdminNotificationService +from app.services.remnawave_service import RemnaWaveService +from app.database.crud.user import get_user_by_remnawave_uuid +from app.database.models import User +from sqlalchemy.ext.asyncio import AsyncSession + + +logger = logging.getLogger(__name__) + + +class TrafficMonitoringService: + """ + Сервис для мониторинга трафика пользователей + """ + + def __init__(self): + self.remnawave_service = RemnaWaveService() + self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных проверок + + def is_traffic_monitoring_enabled(self) -> bool: + """Проверяет, включен ли мониторинг трафика""" + return getattr(settings, 'TRAFFIC_MONITORING_ENABLED', False) + + def get_traffic_threshold_gb(self) -> float: + """Получает порог трафика в ГБ за сутки""" + return getattr(settings, 'TRAFFIC_THRESHOLD_GB_PER_DAY', 10.0) + + def get_monitoring_interval_hours(self) -> int: + """Получает интервал мониторинга в часах""" + return getattr(settings, 'TRAFFIC_MONITORING_INTERVAL_HOURS', 24) + + def get_suspicious_notifications_topic_id(self) -> Optional[int]: + """Получает ID топика для уведомлений о подозрительной активности""" + return getattr(settings, 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID', None) + + async def get_user_daily_traffic(self, user_uuid: str) -> Dict: + """ + Получает статистику трафика пользователя за последние 24 часа + + Args: + user_uuid: UUID пользователя в Remnawave + + Returns: + Словарь с информацией о трафике + """ + try: + # Получаем время начала и конца суток (сегодня) + now = datetime.utcnow() + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999) + + # Форматируем даты в ISO формат + start_date = start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z") + end_date = end_of_day.strftime("%Y-%m-%dT%H:%M:%S.999Z") + + # Получаем API клиент и вызываем метод получения статистики + async with self.remnawave_service.get_api_client() as api: + traffic_data = await api.get_user_stats_usage(user_uuid, start_date, end_date) + + # Обрабатываем ответ API + if traffic_data and 'response' in traffic_data: + response = traffic_data['response'] + + # Вычисляем общий трафик + total_gb = 0 + nodes_info = [] + + if isinstance(response, list): + for item in response: + node_name = item.get('nodeName', 'Unknown') + total_bytes = item.get('total', 0) + total_gb_item = round(total_bytes / (1024**3), 2) # Конвертируем в ГБ + total_gb += total_gb_item + + nodes_info.append({ + 'node': node_name, + 'gb': total_gb_item + }) + else: + # Если response - это уже результат обработки (как в примере) + total_gb = response.get('total_gb', 0) + nodes_info = response.get('nodes', []) + + return { + 'total_gb': total_gb, + 'nodes': nodes_info, + 'date_range': { + 'start': start_date, + 'end': end_date + } + } + else: + logger.warning(f"Нет данных о трафике для пользователя {user_uuid}") + return { + 'total_gb': 0, + 'nodes': [], + 'date_range': { + 'start': start_date, + 'end': end_date + } + } + + except Exception as e: + logger.error(f"Ошибка при получении статистики трафика для {user_uuid}: {e}") + return { + 'total_gb': 0, + 'nodes': [], + 'date_range': { + 'start': None, + 'end': None + } + } + + async def check_user_traffic_threshold( + self, + db: AsyncSession, + user_uuid: str, + user_telegram_id: int = None + ) -> Tuple[bool, Dict]: + """ + Проверяет, превышает ли трафик пользователя заданный порог + + Args: + db: Сессия базы данных + user_uuid: UUID пользователя в Remnawave + user_telegram_id: Telegram ID пользователя (для логирования) + + Returns: + Кортеж (превышен ли порог, информация о трафике) + """ + if not self.is_traffic_monitoring_enabled(): + return False, {} + + # Получаем статистику трафика + traffic_info = await self.get_user_daily_traffic(user_uuid) + total_gb = traffic_info.get('total_gb', 0) + + # Получаем порог для сравнения + threshold_gb = self.get_traffic_threshold_gb() + + # Проверяем, превышает ли трафик порог + is_exceeded = total_gb > threshold_gb + + # Логируем проверку + user_id_info = f"telegram_id={user_telegram_id}" if user_telegram_id else f"uuid={user_uuid}" + status = "ПРЕВЫШЕНИЕ" if is_exceeded else "норма" + logger.info( + f"📊 Проверка трафика для {user_id_info}: {total_gb} ГБ, " + f"порог: {threshold_gb} ГБ, статус: {status}" + ) + + return is_exceeded, traffic_info + + async def process_suspicious_traffic( + self, + db: AsyncSession, + user_uuid: str, + traffic_info: Dict, + bot + ): + """ + Обрабатывает подозрительный трафик - отправляет уведомление администраторам + """ + try: + # Получаем информацию о пользователе из базы данных + user = await get_user_by_remnawave_uuid(db, user_uuid) + if not user: + logger.warning(f"Пользователь с UUID {user_uuid} не найден в базе данных") + return + + # Формируем сообщение для администраторов + total_gb = traffic_info.get('total_gb', 0) + threshold_gb = self.get_traffic_threshold_gb() + + message = ( + f"⚠️ Подозрительная активность трафика\n\n" + f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n" + f"🔑 UUID: {user_uuid}\n" + f"📊 Трафик за сутки: {total_gb} ГБ\n" + f"📈 Порог: {threshold_gb} ГБ\n" + f"🚨 Превышение: {total_gb - threshold_gb:.2f} ГБ\n\n" + ) + + # Добавляем информацию по нодам, если есть + nodes = traffic_info.get('nodes', []) + if nodes: + message += "Разбивка по нодам:\n" + for node_info in nodes[:5]: # Показываем первые 5 нод + message += f" • {node_info.get('node', 'Unknown')}: {node_info.get('gb', 0)} ГБ\n" + if len(nodes) > 5: + message += f" • и ещё {len(nodes) - 5} нод(ы)\n" + + message += f"\n⏰ Время проверки: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S UTC')}" + + # Создаем AdminNotificationService с ботом + admin_notification_service = AdminNotificationService(bot) + + # Отправляем уведомление администраторам + topic_id = self.get_suspicious_notifications_topic_id() + + await admin_notification_service.send_suspicious_traffic_notification( + message, + bot, + topic_id + ) + + logger.info( + f"✅ Уведомление о подозрительном трафике отправлено для пользователя {user.telegram_id}" + ) + + except Exception as e: + logger.error(f"❌ Ошибка при обработке подозрительного трафика для {user_uuid}: {e}") + + async def check_all_users_traffic(self, db: AsyncSession, bot): + """ + Проверяет трафик всех пользователей с активной подпиской + """ + if not self.is_traffic_monitoring_enabled(): + logger.info("Мониторинг трафика отключен, пропускаем проверку всех пользователей") + return + + try: + from app.database.crud.user import get_users_with_active_subscriptions + + # Получаем всех пользователей с активной подпиской + users = await get_users_with_active_subscriptions(db) + + logger.info(f"Начинаем проверку трафика для {len(users)} пользователей") + + # Проверяем трафик для каждого пользователя + for user in users: + if user.remnawave_uuid: # Проверяем только пользователей с UUID + is_exceeded, traffic_info = await self.check_user_traffic_threshold( + db, + user.remnawave_uuid, + user.telegram_id + ) + + if is_exceeded: + await self.process_suspicious_traffic( + db, + user.remnawave_uuid, + traffic_info, + bot + ) + + logger.info("Завершена проверка трафика всех пользователей") + + except Exception as e: + logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}") + + +class TrafficMonitoringScheduler: + """ + Класс для планирования периодических проверок трафика + """ + def __init__(self, traffic_service: TrafficMonitoringService): + self.traffic_service = traffic_service + self.check_task = None + self.is_running = False + + async def start_monitoring(self, db: AsyncSession, bot): + """ + Запускает периодическую проверку трафика + """ + if self.is_running: + logger.warning("Мониторинг трафика уже запущен") + return + + if not self.traffic_service.is_traffic_monitoring_enabled(): + logger.info("Мониторинг трафика отключен в настройках") + return + + self.is_running = True + interval_hours = self.traffic_service.get_monitoring_interval_hours() + interval_seconds = interval_hours * 3600 + + logger.info(f"Запуск мониторинга трафика с интервалом {interval_hours} часов") + + # Запускаем задачу с интервалом + self.check_task = asyncio.create_task(self._periodic_check(db, bot, interval_seconds)) + + async def stop_monitoring(self): + """ + Останавливает периодическую проверку трафика + """ + if self.check_task: + self.check_task.cancel() + try: + await self.check_task + except asyncio.CancelledError: + pass + self.is_running = False + logger.info("Мониторинг трафика остановлен") + + async def _periodic_check(self, db: AsyncSession, bot, interval_seconds: int): + """ + Выполняет периодическую проверку трафика + """ + while self.is_running: + try: + logger.info("Запуск периодической проверки трафика") + await self.traffic_service.check_all_users_traffic(db, bot) + + # Ждем указанный интервал перед следующей проверкой + await asyncio.sleep(interval_seconds) + + except asyncio.CancelledError: + logger.info("Задача периодической проверки трафика отменена") + break + except Exception as e: + logger.error(f"Ошибка в периодической проверке трафика: {e}") + # Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова + await asyncio.sleep(interval_seconds) + + +# Глобальные экземпляры сервисов +traffic_monitoring_service = TrafficMonitoringService() +traffic_monitoring_scheduler = TrafficMonitoringScheduler(traffic_monitoring_service) \ No newline at end of file diff --git a/app/services/user_service.py b/app/services/user_service.py index a0866100..0f723631 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Optional, List, Dict, Any, Tuple from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import delete, select, update, func +from sqlalchemy.orm import selectinload from aiogram import Bot, types from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from app.database.crud.user import ( @@ -218,6 +219,60 @@ class UserService: "has_prev": False } + async def get_users_ready_to_renew( + self, + db: AsyncSession, + min_balance_kopeks: int, + page: int = 1, + limit: int = 20, + ) -> Dict[str, Any]: + """Возвращает пользователей с истекшей подпиской и достаточным балансом.""" + try: + offset = (page - 1) * limit + now = datetime.utcnow() + + base_filters = [ + User.balance_kopeks >= min_balance_kopeks, + Subscription.end_date.isnot(None), + Subscription.end_date <= now, + ] + + query = ( + select(User) + .options(selectinload(User.subscription)) + .join(Subscription, Subscription.user_id == User.id) + .where(*base_filters) + .order_by(User.balance_kopeks.desc(), Subscription.end_date.asc()) + .offset(offset) + .limit(limit) + ) + result = await db.execute(query) + users = result.scalars().all() + + count_query = ( + select(func.count(User.id)) + .join(Subscription, Subscription.user_id == User.id) + .where(*base_filters) + ) + total_count = (await db.execute(count_query)).scalar() or 0 + total_pages = (total_count + limit - 1) // limit if total_count else 0 + + return { + "users": users, + "current_page": page, + "total_pages": total_pages, + "total_count": total_count, + } + + except Exception as e: + logger.error(f"Ошибка получения пользователей для продления: {e}") + return { + "users": [], + "current_page": 1, + "total_pages": 1, + "total_count": 0, + } + async def get_user_spending_stats_map( self, db: AsyncSession, diff --git a/app/states.py b/app/states.py index 897297d3..42122686 100644 --- a/app/states.py +++ b/app/states.py @@ -38,6 +38,7 @@ class PromoCodeStates(StatesGroup): class AdminStates(StatesGroup): waiting_for_user_search = State() + waiting_for_bulk_ban_list = State() sending_user_message = State() editing_user_balance = State() extending_subscription = State() @@ -141,6 +142,7 @@ class AdminStates(StatesGroup): viewing_user_from_spending_list = State() viewing_user_from_purchases_list = State() viewing_user_from_campaign_list = State() + viewing_user_from_ready_to_renew_list = State() class SupportStates(StatesGroup): waiting_for_message = State() diff --git a/docker-compose.yml b/docker-compose.yml index c62611f2..71c766bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] interval: 30s @@ -27,7 +27,7 @@ services: volumes: - redis_data:/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s @@ -74,7 +74,7 @@ services: ports: - "${WEB_API_PORT:-8080}:8080" networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] interval: 60s @@ -89,9 +89,7 @@ volumes: driver: local networks: - bot_network: - driver: bridge - ipam: - config: - - subnet: 172.20.0.0/16 - gateway: 172.20.0.1 + remnawave-network: + name: remnawave-network + driver: bridge + external: true diff --git a/vpn_logo.png b/vpn_logo.png deleted file mode 100644 index ff499bf7..00000000 Binary files a/vpn_logo.png and /dev/null differ