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