From 9f10fed3e730c2eefcf8dbf70f689d2523f7563c Mon Sep 17 00:00:00 2001 From: Vladless Date: Sun, 30 Nov 2025 18:37:18 +0300 Subject: [PATCH] added support for external squads --- api/schemas/users.py | 4 ++-- database/models.py | 1 + handlers/admin/tariffs/keyboard.py | 6 ++++++ handlers/admin/tariffs/tariff_manage.py | 13 +++++++++++++ handlers/admin/tariffs/tariff_utils.py | 2 ++ handlers/keys/operations/creation.py | 5 +++++ handlers/keys/operations/renewal.py | 20 +++++++++++++++++++- 7 files changed, 48 insertions(+), 3 deletions(-) diff --git a/api/schemas/users.py b/api/schemas/users.py index 6638a4ed..f7d3588c 100644 --- a/api/schemas/users.py +++ b/api/schemas/users.py @@ -10,8 +10,8 @@ class UserBase(BaseModel): last_name: str | None = None language_code: str | None = None is_bot: bool | None = False - balance: float = 0.0 - trial: int = 0 + balance: float | None = 0.0 + trial: int | None = 0 source_code: str | None = None diff --git a/database/models.py b/database/models.py index 600fe5ba..c07a4db0 100644 --- a/database/models.py +++ b/database/models.py @@ -101,6 +101,7 @@ class Tariff(DictLikeMixin, Base): subgroup_title = Column(String, nullable=True) sort_order = Column(Integer, nullable=True) vless = Column(Boolean, default=False) + external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True) configurable = Column(Boolean, nullable=False, server_default="false") diff --git a/handlers/admin/tariffs/keyboard.py b/handlers/admin/tariffs/keyboard.py index 1a2e6d98..2420754d 100644 --- a/handlers/admin/tariffs/keyboard.py +++ b/handlers/admin/tariffs/keyboard.py @@ -290,6 +290,12 @@ def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup: callback_data=f"edit_field|{tariff_id}|vless", ) ], + [ + InlineKeyboardButton( + text="Внешний сквад", + callback_data=f"edit_field|{tariff_id}|external_squad", + ) + ], [InlineKeyboardButton(text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}")], [ InlineKeyboardButton( diff --git a/handlers/admin/tariffs/tariff_manage.py b/handlers/admin/tariffs/tariff_manage.py index 86db2fb2..75e09cf4 100644 --- a/handlers/admin/tariffs/tariff_manage.py +++ b/handlers/admin/tariffs/tariff_manage.py @@ -481,6 +481,7 @@ async def ask_new_value(callback: CallbackQuery, state: FSMContext): "traffic_limit": "лимит трафика в ГБ (0 — безлимит)", "device_limit": "лимит устройств (0 — безлимит)", "vless": "VLESS (да/нет)", + "external_squad": "внешний сквад (0 — убрать)", } await callback.message.edit_text( @@ -535,6 +536,18 @@ async def apply_edit(message: Message, state: FSMContext, session: AsyncSession) ) return + if field == "external_squad": + if value in ("", "0", "-"): + value = None + setattr(tariff, field, value) + tariff.updated_at = datetime.utcnow() + await session.commit() + await state.clear() + + text, markup = render_tariff_card(tariff) + await message.answer(text=text, reply_markup=markup) + return + if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]: try: num = int(value) diff --git a/handlers/admin/tariffs/tariff_utils.py b/handlers/admin/tariffs/tariff_utils.py index 65d1a081..7190242f 100644 --- a/handlers/admin/tariffs/tariff_utils.py +++ b/handlers/admin/tariffs/tariff_utils.py @@ -41,6 +41,7 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]: vless_text = "Да" if getattr(tariff, "vless", False) else "Нет" configurable = bool(getattr(tariff, "configurable", False)) configurable_text = "Включен" if configurable else "Выключен" + external_squad_text = getattr(tariff, "external_squad", None) or "Не задан" text = ( f"📄 Тариф: {tariff.name}\n\n" @@ -51,6 +52,7 @@ def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]: f"📱 Устройств: {device_text}\n" f"🔗 VLESS: {vless_text}\n" f"⚙️ Конфигуратор: {configurable_text}\n" + f"Внешний сквад: {external_squad_text}\n" f"🔢 Позиция: {sort_order}\n" f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}" ) diff --git a/handlers/keys/operations/creation.py b/handlers/keys/operations/creation.py index d9ee599a..fdc0e921 100644 --- a/handlers/keys/operations/creation.py +++ b/handlers/keys/operations/creation.py @@ -62,6 +62,7 @@ async def create_key_on_cluster( traffic_limit_bytes_value = 0 device_limit_value = 0 + external_squad_uuid = None if plan is not None: tariff = await get_tariff_by_id(session, plan) @@ -85,6 +86,7 @@ async def create_key_on_cluster( subgroup_title = tariff.get("subgroup_title") need_vless_key = bool(tariff.get("vless")) + external_squad_uuid = tariff.get("external_squad") or None else: traffic_limit_bytes_value = int(traffic_limit_bytes or 0) device_limit_value = int(hwid_limit or 0) @@ -168,6 +170,9 @@ async def create_key_on_cluster( user_data["hwidDeviceLimit"] = device_limit_value + if external_squad_uuid: + user_data["externalSquadUuid"] = external_squad_uuid + logger.debug(f"{PANEL_REMNA} Данные для создания клиента: {user_data}") result = await remna.create_user(user_data) if result: diff --git a/handlers/keys/operations/renewal.py b/handlers/keys/operations/renewal.py index 1605fd35..e2653566 100644 --- a/handlers/keys/operations/renewal.py +++ b/handlers/keys/operations/renewal.py @@ -13,6 +13,7 @@ from database import ( resolve_device_limit_from_group, update_key_expiry, update_key_link, + get_tariff_by_id, ) from hooks.processors import process_get_cryptolink_after_renewal from logger import ( @@ -28,6 +29,7 @@ from .subgroup_migration import migrate_between_subgroups async def resolve_cluster(session: AsyncSession, cluster_id: str): + """Возвращает список серверов для кластера или конкретного сервера.""" servers = await get_servers(session) cluster = servers.get(cluster_id) if cluster: @@ -53,7 +55,9 @@ async def renew_on_remnawave( session: AsyncSession, reset_traffic: bool, target_server_name: str | None = None, + external_squad_uuid: str | None = None, ) -> bool: + """Продлевает подписку на Remnawave-узлах кластера.""" remnawave_nodes = [ s for s in cluster if str(s.get("panel_type", "3x-ui")).lower() == "remnawave" and s.get("inbound_id") ] @@ -70,13 +74,18 @@ async def renew_on_remnawave( expire_iso = datetime.utcfromtimestamp(new_expiry_time // 1000).isoformat() + "Z" traffic_limit_bytes = total_gb * 1024 * 1024 * 1024 if total_gb else 0 active_inbounds = [s["inbound_id"] for s in remnawave_nodes] - updated = await remna.update_user( + + update_kwargs = dict( uuid=client_id, expire_at=expire_iso, active_user_inbounds=active_inbounds, traffic_limit_bytes=traffic_limit_bytes, hwid_device_limit=hwid_device_limit, ) + if external_squad_uuid: + update_kwargs["external_squad_uuid"] = external_squad_uuid + + updated = await remna.update_user(**update_kwargs) if updated: if reset_traffic: try: @@ -100,6 +109,7 @@ async def renew_on_3xui( update_links: bool = False, target_server_name: str | None = None, ): + """Продлевает подписку на 3x-ui серверах кластера.""" tasks = [] for server_info in cluster: if target_server_name and server_info.get("server_name") != target_server_name: @@ -179,6 +189,7 @@ async def renew_key_in_cluster( old_subgroup: str | None = None, plan=None, ): + """Продлевает ключ в кластере с учётом подгрупп и Remnawave/3x-ui.""" try: servers_map = await get_servers(session) @@ -211,6 +222,12 @@ async def renew_key_in_cluster( if dl is not None: hwid_device_limit = dl + external_squad_uuid = None + if plan is not None: + tariff = await get_tariff_by_id(session, plan) + if tariff: + external_squad_uuid = tariff.get("external_squad") + if (target_subgroup or "") != (old_subgroup or "") and not single_server: new_client_id, remna_link = await migrate_between_subgroups( session=session, @@ -270,6 +287,7 @@ async def renew_key_in_cluster( session=session, reset_traffic=reset_traffic, target_server_name=server_id if single_server else None, + external_squad_uuid=external_squad_uuid, ) succeeded, _ = await renew_on_3xui(