mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Черный список, мониторинг суточно графика по регламенту
This commit is contained in:
13
.env.example
13
.env.example
@@ -19,6 +19,19 @@ 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/username/repository/main/blacklist.txt # URL к файлу черного списка на GitHub
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
|
||||
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -155,7 +155,18 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
19
app/external/remnawave_api.py
vendored
19
app/external/remnawave_api.py
vendored
@@ -656,6 +656,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]:
|
||||
|
||||
@@ -1 +1,36 @@
|
||||
# Инициализация админских обработчиков
|
||||
# Инициализация админских обработчиков
|
||||
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,
|
||||
)
|
||||
363
app/handlers/admin/blacklist.py
Normal file
363
app/handlers/admin/blacklist.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Обработчики админ-панели для управления черным списком
|
||||
"""
|
||||
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
|
||||
):
|
||||
"""
|
||||
Показывает настройки черного списка
|
||||
"""
|
||||
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"""
|
||||
🔐 <b>Настройки черного списка</b>
|
||||
|
||||
Статус: {status_text}
|
||||
URL к черному списку: <code>{url_text}</code>
|
||||
Количество записей: {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"<code>BLACKLIST_CHECK_ENABLED</code> в файле <code>.env</code>",
|
||||
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"🔐 <b>Черный список ({len(blacklist_users)} записей)</b>\n\n"
|
||||
|
||||
# Показываем первые 20 записей
|
||||
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
|
||||
text += f"{i}. <code>{tg_id}</code> {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<code>{url}</code>\n\n"
|
||||
f"Для применения изменений перезапустите бота или измените значение\n"
|
||||
f"<code>BLACKLIST_GITHUB_URL</code> в файле <code>.env</code>",
|
||||
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()
|
||||
|
||||
|
||||
async 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 # Фильтр будет внутри функции
|
||||
)
|
||||
169
app/handlers/admin/bulk_ban.py
Normal file
169
app/handlers/admin/bulk_ban.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Обработчики команд для массовой блокировки пользователей
|
||||
"""
|
||||
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(
|
||||
"🛑 <b>Массовая блокировка пользователей</b>\n\n"
|
||||
"Введите список Telegram ID для блокировки.\n\n"
|
||||
"<b>Форматы ввода:</b>\n"
|
||||
"• По одному ID на строку\n"
|
||||
"• Через запятую\n"
|
||||
"• Через пробел\n\n"
|
||||
"Пример:\n"
|
||||
"<code>123456789\n"
|
||||
"987654321\n"
|
||||
"111222333</code>\n\n"
|
||||
"Или:\n"
|
||||
"<code>123456789, 987654321, 111222333</code>\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 и выполнение массовой блокировки
|
||||
"""
|
||||
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"✅ <b>Массовая блокировка завершена</b>\n\n"
|
||||
result_text += f"📊 <b>Результаты:</b>\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⚠️ <b>Telegram ID с ошибками:</b>\n"
|
||||
result_text += f"<code>{', '.join(map(str, error_ids[:10]))}</code>" # Показываем первые 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()
|
||||
|
||||
|
||||
async 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,
|
||||
lambda m: m.text and AdminStates.waiting_for_bulk_ban_list
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
]
|
||||
|
||||
@@ -1528,5 +1528,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"
|
||||
}
|
||||
|
||||
@@ -1540,5 +1540,7 @@
|
||||
"POLL_EMPTY": "Опрос пока недоступен.",
|
||||
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
"🛑 <b>МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ</b>",
|
||||
"",
|
||||
f"👮 <b>Администратор:</b> {admin_name}",
|
||||
f"🆔 <b>ID администратора:</b> {admin_user_id}",
|
||||
"",
|
||||
"📊 <b>Результаты:</b>",
|
||||
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"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
|
||||
]
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
230
app/services/blacklist_service.py
Normal file
230
app/services/blacklist_service.py
Normal file
@@ -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()
|
||||
177
app/services/bulk_ban_service.py
Normal file
177
app/services/bulk_ban_service.py
Normal file
@@ -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"🚫 <b>Ваш аккаунт заблокирован</b>\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()
|
||||
332
app/services/traffic_monitoring_service.py
Normal file
332
app/services/traffic_monitoring_service.py
Normal file
@@ -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"⚠️ <b>Подозрительная активность трафика</b>\n\n"
|
||||
f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n"
|
||||
f"🔑 UUID: {user_uuid}\n"
|
||||
f"📊 Трафик за сутки: <b>{total_gb} ГБ</b>\n"
|
||||
f"📈 Порог: <b>{threshold_gb} ГБ</b>\n"
|
||||
f"🚨 Превышение: <b>{total_gb - threshold_gb:.2f} ГБ</b>\n\n"
|
||||
)
|
||||
|
||||
# Добавляем информацию по нодам, если есть
|
||||
nodes = traffic_info.get('nodes', [])
|
||||
if nodes:
|
||||
message += "<b>Разбивка по нодам:</b>\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)
|
||||
Reference in New Issue
Block a user