From 5c2bde726cc7dec663b5a8a87658c35dab7e378b Mon Sep 17 00:00:00 2001 From: Capybara-z Date: Sun, 14 Dec 2025 22:42:51 +0300 Subject: [PATCH] Add revoke subs Remnawave --- handlers/admin/users/keyboard.py | 4 + handlers/admin/users/users_keys.py | 189 +++++++++++++++++++++++++++++ panels/remnawave_ext.py | 68 +++++++++++ 3 files changed, 261 insertions(+) create mode 100644 panels/remnawave_ext.py diff --git a/handlers/admin/users/keyboard.py b/handlers/admin/users/keyboard.py index 207a6e1e..31c002ec 100644 --- a/handlers/admin/users/keyboard.py +++ b/handlers/admin/users/keyboard.py @@ -260,6 +260,10 @@ def build_key_edit_kb(key_details: dict, email: str, is_configurable: bool = Fal text="🔄 Перевыпустить", callback_data=AdminUserEditorCallback(action="users_update_key", data=email, tg_id=key_details["tg_id"]).pack(), ) + builder.button( + text="🔁 Пересоздать", + callback_data=AdminUserEditorCallback(action="users_recreate_key", data=email, tg_id=key_details["tg_id"]).pack(), + ) builder.button( text="📦 Тариф", callback_data=AdminUserEditorCallback(action="users_renew", data=email, tg_id=key_details["tg_id"]).pack(), diff --git a/handlers/admin/users/users_keys.py b/handlers/admin/users/users_keys.py index ad849315..d8b4e480 100644 --- a/handlers/admin/users/users_keys.py +++ b/handlers/admin/users/users_keys.py @@ -640,6 +640,195 @@ async def admin_reissue_country(callback_query: CallbackQuery, session: AsyncSes await callback_query.message.answer(f"❗ Ошибка: {e}") +@router.callback_query( + AdminUserEditorCallback.filter(F.action == "users_recreate_key"), + IsAdminFilter(), +) +async def handle_recreate_key_start( + callback_query: CallbackQuery, + callback_data: AdminUserEditorCallback, + session: AsyncSession, +): + tg_id = callback_data.tg_id + email = callback_data.data + + result = await session.execute(select(Key).where(Key.email == email)) + key_obj: Key | None = result.scalar_one_or_none() + + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Ключ не найден.", + reply_markup=build_editor_kb(tg_id), + ) + return + + tariff_name = "—" + if key_obj.tariff_id: + tariff = await get_tariff_by_id(session, key_obj.tariff_id) + if tariff: + tariff_name = tariff.get("name", "—") + + text = ( + "🔁 Пересоздание ссылки подписки\n\n" + f"📦 Тариф: {tariff_name}\n\n" + "⚠️ Будет сгенерирована новая ссылка подписки.\n" + "Старая ссылка перестанет работать.\n\n" + "✅ Все данные подписки сохранятся." + ) + + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="✅ Пересоздать", + callback_data=f"confirm_recreate|{tg_id}|{email}", + ) + ) + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(), + ) + ) + + await callback_query.message.edit_text(text=text, reply_markup=builder.as_markup()) + + +@router.callback_query(F.data.startswith("confirm_recreate|"), IsAdminFilter()) +async def handle_recreate_key_confirm( + callback_query: CallbackQuery, + session: AsyncSession, +): + _, tg_id, old_email = callback_query.data.split("|") + tg_id = int(tg_id) + + try: + result = await session.execute(select(Key).where(Key.email == old_email)) + key_obj: Key | None = result.scalar_one_or_none() + + if not key_obj: + await callback_query.message.edit_text( + text="🚫 Ключ не найден.", + reply_markup=build_editor_kb(tg_id), + ) + return + + await callback_query.message.edit_text("⏳ Пересоздание ссылки подписки...") + + client_id = key_obj.client_id + cluster_id = key_obj.server_id + old_link = key_obj.remnawave_link or key_obj.key + + servers = await get_servers(session) + cluster = servers.get(cluster_id) + + if not cluster: + for _, server_list in servers.items(): + for server_info in server_list: + if server_info.get("server_name", "").lower() == cluster_id.lower(): + cluster = [server_info] + break + if cluster: + break + + if not cluster: + await callback_query.message.edit_text( + text=f"❗ Кластер {cluster_id} не найден.", + reply_markup=build_editor_kb(tg_id), + ) + return + + remnawave_servers = [s for s in cluster if s.get("panel_type", "3x-ui").lower() == "remnawave"] + + if not remnawave_servers: + await callback_query.message.edit_text( + text="❗ Revoke доступен только для Remnawave. Для 3x-ui используйте перевыпуск.", + reply_markup=build_editor_kb(tg_id), + ) + return + + api_url = remnawave_servers[0].get("api_url") + + from panels.remnawave_ext import revoke_user_subscription + + user_data = await revoke_user_subscription(api_url, client_id) + + if not user_data: + await callback_query.message.edit_text( + text="❗ Не удалось выполнить revoke. Проверьте логи.", + reply_markup=build_editor_kb(tg_id), + ) + return + + new_link = user_data.get("subscriptionUrl") + + if not new_link: + await callback_query.message.edit_text( + text="❗ Revoke выполнен, но новая ссылка не получена.", + reply_markup=build_editor_kb(tg_id), + ) + return + + await session.execute( + update(Key) + .where(Key.email == old_email) + .values( + key=new_link, + remnawave_link=new_link, + ) + ) + await session.commit() + + try: + user_text = ( + "🔄 Ваша подписка была перевыпущена\n\n" + f"🔗 Новая ссылка подписки:\n{new_link}\n\n" + "Старая ссылка больше не работает." + ) + user_kb = InlineKeyboardBuilder() + user_kb.row(InlineKeyboardButton(text="📱 Мои подписки", callback_data="view_keys")) + user_kb.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")) + + await callback_query.bot.send_message( + chat_id=tg_id, + text=user_text, + reply_markup=user_kb.as_markup(), + ) + notification_sent = True + except Exception as e: + logger.warning(f"Не удалось отправить уведомление клиенту {tg_id}: {e}") + notification_sent = False + + text = ( + "✅ Ссылка подписки пересоздана\n\n" + f"🔗 Старая ссылка:\n{old_link}\n\n" + f"🔗 Новая ссылка:\n{new_link}\n\n" + ) + if notification_sent: + text += "📨 Клиент уведомлён о новой ссылке." + else: + text += "⚠️ Не удалось уведомить клиента." + + builder = InlineKeyboardBuilder() + builder.row( + InlineKeyboardButton( + text="🔙 Назад", + callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=old_email).pack(), + ) + ) + + await callback_query.message.edit_text( + text=text, + reply_markup=builder.as_markup(), + ) + + except Exception as e: + logger.error(f"Ошибка при revoke ключа {old_email}: {e}") + await callback_query.message.edit_text( + text=f"❗ Ошибка при пересоздании: {e}", + reply_markup=build_editor_kb(tg_id), + ) + + @router.callback_query( AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter(), diff --git a/panels/remnawave_ext.py b/panels/remnawave_ext.py new file mode 100644 index 00000000..852a9d96 --- /dev/null +++ b/panels/remnawave_ext.py @@ -0,0 +1,68 @@ +import aiohttp + +from config import ( + REMNAWAVE_ACCESS_TOKEN, + REMNAWAVE_LOGIN, + REMNAWAVE_PASSWORD, + REMNAWAVE_TOKEN_LOGIN_ENABLED, +) +from logger import logger + +PANEL_REMNA = "[Remnawave]" + + +async def revoke_user_subscription( + api_url: str, + user_uuid: str, + short_uuid: str | None = None, +) -> dict | None: + base_url = api_url.rstrip("/") + + async with aiohttp.ClientSession() as session: + if REMNAWAVE_TOKEN_LOGIN_ENABLED and REMNAWAVE_ACCESS_TOKEN: + token = REMNAWAVE_ACCESS_TOKEN + else: + login_url = f"{base_url}/auth/login" + login_data = {"username": REMNAWAVE_LOGIN, "password": REMNAWAVE_PASSWORD} + + try: + async with session.post(login_url, json=login_data) as resp: + if resp.status != 200 and resp.status != 201: + logger.error(f"{PANEL_REMNA} Авторизация не удалась: {resp.status}") + return None + + auth_response = await resp.json() + token = auth_response.get("response", {}).get("accessToken") + if not token: + logger.error(f"{PANEL_REMNA} Не получен accessToken") + return None + except Exception as e: + logger.error(f"{PANEL_REMNA} Ошибка авторизации: {e}") + return None + + revoke_url = f"{base_url}/users/{user_uuid}/actions/revoke" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + revoke_data = {} + if short_uuid: + revoke_data["shortUuid"] = short_uuid + + try: + async with session.post(revoke_url, json=revoke_data, headers=headers) as resp: + if resp.status != 200 and resp.status != 201: + error_text = await resp.text() + logger.error(f"{PANEL_REMNA} Revoke не удался: {resp.status} - {error_text}") + return None + + result = await resp.json() + user_data = result.get("response", {}) + + logger.info(f"{PANEL_REMNA} Подписка {user_uuid} успешно отозвана") + return user_data + + except Exception as e: + logger.error(f"{PANEL_REMNA} Ошибка revoke: {e}") + return None