Merge pull request #2144 from Gy9vin/main

Обновки
This commit is contained in:
Egor
2025-12-12 06:04:37 +03:00
committed by GitHub
31 changed files with 2034 additions and 105 deletions

View File

@@ -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 # Обязательна ли подписка на канал

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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]:

BIN
app/handlers/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -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,
)

View File

@@ -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"""
🔐 <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()
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 # Фильтр будет внутри функции
)

View File

@@ -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(
"🛑 <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 и выполнение массовой блокировки
"""
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"✅ <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()
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
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -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()

View File

@@ -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", "📢 По кампании"),

View File

@@ -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"
}

View File

@@ -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": "🔐 Черный список"
}

View File

@@ -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} зараховано на ваш баланс."
}
}

View File

@@ -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":"🕐活跃:很久以前"
}
}

View File

@@ -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

View 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()

View 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()

View File

@@ -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()

View 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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB