From 4ea640edffc60c73537061a179988111881f1cd1 Mon Sep 17 00:00:00 2001 From: Ilay Date: Wed, 10 Dec 2025 17:34:25 +0500 Subject: [PATCH 1/3] feat: support for Remnawave 2.3.* and related improvements - allowed adding authorized user to plan without existing in database - implemented default banner for each locale - added missing banner for invitation page - updated assets README - ensured hook reset is applied on startup if configured - allowed modification of external squad - added user UUID synchronization - added support for Remnawave 2.3.* - added bot version display - updated README --- README.md | 25 +++- README.ru_RU.md | 25 +++- assets/README.md | 17 ++- assets/banners/en/.gitkeep | 0 assets/translations/en/buttons.ftl | 0 assets/translations/en/messages.ftl | 0 assets/translations/en/notifications.ftl | 0 assets/translations/en/utils.ftl | 0 assets/translations/ru/messages.ftl | 3 +- pyproject.toml | 7 +- src/__version__.py | 2 +- src/bot/routers/dashboard/remnashop/dialog.py | 3 +- .../routers/dashboard/remnashop/getters.py | 11 ++ .../dashboard/remnashop/plans/dialog.py | 6 +- .../dashboard/remnashop/plans/getters.py | 12 +- .../dashboard/remnashop/plans/handlers.py | 16 +-- .../routers/dashboard/remnawave/getters.py | 11 +- .../routers/dashboard/users/user/dialog.py | 5 +- .../routers/dashboard/users/user/getters.py | 37 +++--- .../routers/dashboard/users/user/handlers.py | 6 +- src/bot/routers/menu/dialog.py | 2 +- src/bot/widgets/banner.py | 41 ++++--- src/core/exceptions.py | 2 +- src/infrastructure/taskiq/tasks/importer.py | 8 +- .../taskiq/tasks/subscriptions.py | 19 ++- src/services/plan.py | 2 - src/services/remnawave.py | 116 +++++++++++------- src/services/subscription.py | 3 +- src/services/webhook.py | 5 +- uv.lock | 9 +- 30 files changed, 238 insertions(+), 155 deletions(-) delete mode 100644 assets/banners/en/.gitkeep delete mode 100644 assets/translations/en/buttons.ftl delete mode 100644 assets/translations/en/messages.ftl delete mode 100644 assets/translations/en/notifications.ftl delete mode 100644 assets/translations/en/utils.ftl diff --git a/README.md b/README.md index 28761ad..c7716a3 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ > Referral system configurator. - > Reward customization: money, extra days, or automatically generated promocodes. + > Reward customization: points or extra days. > Two-level referral support. @@ -163,15 +163,28 @@ - **🧭 Migration** > Seamless migration from other bots. -- **🪄 MiniApp Support (maposia)** +- **🪄 MiniApp Subscription Page Support** # ⚙️ Installation and configuration -Install Docker if not installed yet. -``` -sudo curl -fsSL https://get.docker.com | sh -``` +## Requirements +- Hardware: + - OS: Recommended Ubuntu or Debian + - RAM: Minimum 2 GB, recommended 4 GB + - CPU: Minimum 2 cores, recommended 4 cores + - Storage: 20 GB, minimum and recommended + +- Software: + - [Docker](https://docs.docker.com/get-started/get-docker/) + + Install Docker using official script + ``` + sudo curl -fsSL https://get.docker.com | sh + ``` + +> [!WARNING] The latest version of the bot is compatible only with RemnaWave panel version 2.3.* +> **Before installation, make sure your panel matches this version.** ## Step 1 – Download required files diff --git a/README.ru_RU.md b/README.ru_RU.md index bd6a9cd..fe8f2f3 100644 --- a/README.ru_RU.md +++ b/README.ru_RU.md @@ -80,7 +80,7 @@ > Конфигуратор реферальной системы. - > Настройка наград: деньги, дни или автоматически создаваемые промокоды. + > Настройка наград: баллы или дни. > Поддержка двухуровневых рефералов. @@ -163,15 +163,28 @@ - **🧭 Миграция** > Простая миграция с других ботов. -- **🪄 Поддержка MiniApp (maposia)** +- **🪄 Поддержка страницы подписки MiniApp** # ⚙️ Установка и настройка -Установите Docker, если он еще не установлен: -``` -sudo curl -fsSL https://get.docker.com | sh -``` +## Требования +- Аппаратные: + - ОС: рекомендуется Ubuntu или Debian + - ОЗУ: минимум 2 ГБ, рекомендуется 4 ГБ + - ЦПУ: минимум 2 ядра, рекомендуется 4 ядра + - Хранилище: минимум 20 ГБ + +- Программные: + - [Docker](https://docs.docker.com/get-started/get-docker/) + + Установить Docker с помощью официального скрипта + ``` + sudo curl -fsSL https://get.docker.com | sh + ``` + +> [!WARNING] Последняя версия бота совместима только с панелью RemnaWave версии 2.3.* +> **Перед установкой убедитесь, что ваша панель соответствует этой версии.** ## Шаг 1 – Скачивание необходимых файлов diff --git a/assets/README.md b/assets/README.md index 7c91f41..0fafe38 100644 --- a/assets/README.md +++ b/assets/README.md @@ -14,9 +14,12 @@ The banner system supports **localized versions**. A banner corresponding to the ### How it works: +When loading a banner, the system performs the following search steps: 1. **User's locale:** The system first attempts to find a banner in the folder corresponding to the current user's locale (e.g., `en`). Available locales are defined by the `APP_LOCALES` environment variable. -2. **Fallback:** If a banner is not found in the user's locale (or the locale folder itself is missing), the system automatically searches for a banner in the **default locale**, specified by the `APP_DEFAULT_LOCALE` environment variable. -3. **Placeholder banner:** If a banner is not found in either the user's locale or the default locale, a placeholder banner named `default.jpg` will be used. This file must be located directly in the root `banners` directory. +2. **Default (inside user’s locale):** If the specific banner is not found, the system checks for `default.{format}` inside the same locale folder. +3. **Fallback (default locale):** If neither the banner nor `default.{format}` exists in the user’s locale (or if the locale folder itself is missing), the system searches for the banner in the default locale specified +by the `APP_DEFAULT_LOCALE` environment variable. +4. **Placeholder banner:** If a banner is not found in either the user's locale or the default locale, a placeholder banner named `default.jpg` will be used. This file must be located directly in the root `banners` directory. This ensures that even if a specific banner or locale is not found, some banner will always be displayed, preventing empty or missing images. @@ -37,17 +40,19 @@ Banner filenames must correspond to the following predefined names, specified in * **`DEFAULT`**: The default banner, used when a specific banner is not found. * **`MENU`**: The main menu banner. * **`DASHBOARD`**: The dashboard banner. +* **`SUBSCRIPTION`**: The subscription banner. +* **`REFERRAL`**: The referral banner. ## Example file structure ``` banners/ ├── en/ -│ ├── MENU.jpg -│ └── DASHBOARD.jpg +│ ├── menu.jpg +│ └── dashboard.jpg ├── ru/ -│ ├── MENU.gif -│ └── DASHBOARD.gif +│ ├── menu.gif +│ └── dashboard.gif └── default.jpg ``` diff --git a/assets/banners/en/.gitkeep b/assets/banners/en/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/translations/en/buttons.ftl b/assets/translations/en/buttons.ftl deleted file mode 100644 index e69de29..0000000 diff --git a/assets/translations/en/messages.ftl b/assets/translations/en/messages.ftl deleted file mode 100644 index e69de29..0000000 diff --git a/assets/translations/en/notifications.ftl b/assets/translations/en/notifications.ftl deleted file mode 100644 index e69de29..0000000 diff --git a/assets/translations/en/utils.ftl b/assets/translations/en/utils.ftl deleted file mode 100644 index e69de29..0000000 diff --git a/assets/translations/ru/messages.ftl b/assets/translations/ru/messages.ftl index fcbcdee..6f705b5 100644 --- a/assets/translations/ru/messages.ftl +++ b/assets/translations/ru/messages.ftl @@ -404,6 +404,7 @@ msg-user-sync-version = { $version -> } msg-user-sync-subscription = + • ID: { $id } • Статус: { $status -> [ACTIVE] Активна [DISABLED] Отключена @@ -664,7 +665,7 @@ msg-remnawave-inbounds = # RemnaShop -msg-remnashop-main = 🛍 RemnaShop +msg-remnashop-main = 🛍 RemnaShop v{ $version } msg-admins-main = 👮‍♂️ Администраторы diff --git a/pyproject.toml b/pyproject.toml index fd5de3b..7288f4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,8 @@ dependencies = [ "msgspec~=0.19.0", "pydantic-settings~=2.11.0", "redis~=6.4.0", - "remnawave>=2.2.6", - #"remnawave @ file:///opt/python-sdk/remnawave", - #"remnawave @ git+https://github.com/remnawave/python-sdk.git@development", + "remnawave>=2.3.2", + #"remnapy>=2.3.1", "taskiq~=0.11.19", "taskiq-redis~=1.1.2", "uvicorn>=0.38.0", @@ -84,7 +83,7 @@ extra_checks = true explicit_package_bases = true [[tool.mypy.overrides]] -module = ["fluentogram.*", "remnawave.*", "qrcode.*"] +module = ["fluentogram.*", "remnawave.*", "qrcode.*", "remnapy.*"] follow_untyped_imports = true [[tool.mypy.overrides]] diff --git a/src/__version__.py b/src/__version__.py index 86716a7..a779a44 100644 --- a/src/__version__.py +++ b/src/__version__.py @@ -1 +1 @@ -__version__ = "0.5.5" +__version__ = "0.5.6" diff --git a/src/bot/routers/dashboard/remnashop/dialog.py b/src/bot/routers/dashboard/remnashop/dialog.py index 71aa840..a432fee 100644 --- a/src/bot/routers/dashboard/remnashop/dialog.py +++ b/src/bot/routers/dashboard/remnashop/dialog.py @@ -16,7 +16,7 @@ from src.bot.widgets import Banner, I18nFormat, IgnoreUpdate from src.core.enums import BannerName -from .getters import admins_getter +from .getters import admins_getter, remnashop_getter from .handlers import on_logs_request, on_user_role_remove, on_user_select remnashop = Window( @@ -85,6 +85,7 @@ ), IgnoreUpdate(), state=DashboardRemnashop.MAIN, + getter=remnashop_getter, ) admins = Window( diff --git a/src/bot/routers/dashboard/remnashop/getters.py b/src/bot/routers/dashboard/remnashop/getters.py index 8a1d001..af038e2 100644 --- a/src/bot/routers/dashboard/remnashop/getters.py +++ b/src/bot/routers/dashboard/remnashop/getters.py @@ -4,12 +4,23 @@ from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject +from src.__version__ import __version__ from src.core.config import AppConfig from src.core.enums import UserRole from src.infrastructure.database.models.dto import UserDto from src.services.user import UserService +async def remnashop_getter( + dialog_manager: DialogManager, + config: AppConfig, + **kwargs: Any, +) -> dict[str, Any]: + return { + "version": __version__, + } + + @inject async def admins_getter( dialog_manager: DialogManager, diff --git a/src/bot/routers/dashboard/remnashop/plans/dialog.py b/src/bot/routers/dashboard/remnashop/plans/dialog.py index 821710a..7d44126 100644 --- a/src/bot/routers/dashboard/remnashop/plans/dialog.py +++ b/src/bot/routers/dashboard/remnashop/plans/dialog.py @@ -17,7 +17,6 @@ from remnawave.enums.users import TrafficLimitStrategy from src.bot.keyboards import main_menu_button -from src.bot.routers.extra.test import show_dev_popup from src.bot.states import DashboardRemnashop, RemnashopPlans from src.bot.widgets import Banner, I18nFormat, IgnoreUpdate from src.core.enums import BannerName, Currency, PlanAvailability, PlanType @@ -523,11 +522,10 @@ ), ), Row( - Button( + SwitchTo( text=I18nFormat("btn-plan-external-squads"), id="external", - # state=RemnashopPlans.EXTERNAL_SQUADS, - on_click=show_dev_popup, + state=RemnashopPlans.EXTERNAL_SQUADS, ), ), Row( diff --git a/src/bot/routers/dashboard/remnashop/plans/getters.py b/src/bot/routers/dashboard/remnashop/plans/getters.py index 025ebaa..86f6eae 100644 --- a/src/bot/routers/dashboard/remnashop/plans/getters.py +++ b/src/bot/routers/dashboard/remnashop/plans/getters.py @@ -229,16 +229,16 @@ async def squads_getter( internal_dict.get(squad, str(squad)) for squad in plan.internal_squads ) - # external_response = await remnawave.external_squads.get_external_squads() - # if not isinstance(external_response, GetExternalSquadsResponseDto): - # raise ValueError("Wrong response from Remnawave external squads") + external_response = await remnawave.external_squads.get_external_squads() + if not isinstance(external_response, GetExternalSquadsResponseDto): + raise ValueError("Wrong response from Remnawave external squads") - # external_dict = {s.uuid: s.name for s in external_response.external_squads} - # external_squad_name = external_dict.get(plan.external_squad) if plan.external_squad else False + external_dict = {s.uuid: s.name for s in external_response.external_squads} + external_squad_name = external_dict.get(plan.external_squad) if plan.external_squad else False return { "internal_squads": internal_squads_names or False, - "external_squad": False, # external_squad_name, + "external_squad": external_squad_name or False, } diff --git a/src/bot/routers/dashboard/remnashop/plans/handlers.py b/src/bot/routers/dashboard/remnashop/plans/handlers.py index ddfca94..d2cc74a 100644 --- a/src/bot/routers/dashboard/remnashop/plans/handlers.py +++ b/src/bot/routers/dashboard/remnashop/plans/handlers.py @@ -597,25 +597,17 @@ async def on_allowed_user_input( if not plan: raise ValueError("PlanDto not found in dialog data") - allowed_user = await user_service.get(telegram_id=int(message.text)) + allowed_user_id = int(message.text) - if not allowed_user: - logger.warning(f"{log(user)} No user found with Telegram ID '{message.text}'") - await notification_service.notify_user( - user=user, - payload=MessagePayload(i18n_key="ntf-plan-no-user-found"), - ) - return # NOTE: Allow adding non-existent users to the list? - - if allowed_user.telegram_id in plan.allowed_user_ids: - logger.warning(f"{log(user)} User '{allowed_user.telegram_id}' is already allowed for plan") + if allowed_user_id in plan.allowed_user_ids: + logger.warning(f"{log(user)} User '{allowed_user_id}' is already allowed for plan") await notification_service.notify_user( user=user, payload=MessagePayload(i18n_key="ntf-plan-user-already-allowed"), ) return - plan.allowed_user_ids.append(allowed_user.telegram_id) + plan.allowed_user_ids.append(allowed_user_id) adapter.save(plan) diff --git a/src/bot/routers/dashboard/remnawave/getters.py b/src/bot/routers/dashboard/remnawave/getters.py index f6d3dda..f75eeab 100644 --- a/src/bot/routers/dashboard/remnawave/getters.py +++ b/src/bot/routers/dashboard/remnawave/getters.py @@ -34,6 +34,7 @@ async def system_getter( raise ValueError("Wrong response from Remnawave") return { + "version": "", # TODO: Добавить версию панели "cpu_cores": response.cpu.physical_cores, "cpu_threads": response.cpu.cores, "ram_used": i18n_format_bytes_to_unit(response.memory.active), @@ -59,10 +60,10 @@ async def users_getter( return { "users_total": str(response.users.total_users), - "users_active": str(response.users.status_counts.active), - "users_disabled": str(response.users.status_counts.disabled), - "users_limited": str(response.users.status_counts.limited), - "users_expired": str(response.users.status_counts.expired), + "users_active": str(response.users.status_counts.get("ACTIVE")), + "users_disabled": str(response.users.status_counts.get("DISABLED")), + "users_limited": str(response.users.status_counts.get("LIMITED")), + "users_expired": str(response.users.status_counts.get("EXPIRED")), "online_last_day": str(response.online_stats.last_day), "online_last_week": str(response.online_stats.last_week), "online_never": str(response.online_stats.never_online), @@ -127,7 +128,7 @@ async def nodes_getter( if not isinstance(response, GetAllNodesResponseDto): raise ValueError("Wrong response from Remnawave") - for node in response.root: + for node in response: kwargs_for_i18n = { "xray_uptime": i18n_format_seconds(node.xray_uptime), "traffic_used": i18n_format_bytes_to_unit(node.traffic_used_bytes), diff --git a/src/bot/routers/dashboard/users/user/dialog.py b/src/bot/routers/dashboard/users/user/dialog.py index b6e7d73..eb153c6 100644 --- a/src/bot/routers/dashboard/users/user/dialog.py +++ b/src/bot/routers/dashboard/users/user/dialog.py @@ -330,11 +330,10 @@ ), ), Row( - Button( + SwitchTo( text=I18nFormat("btn-user-subscription-external-squads"), id="external", - # state=DashboardUser.EXTERNAL_SQUADS, - on_click=show_dev_popup, + state=DashboardUser.EXTERNAL_SQUADS, ), ), Row( diff --git a/src/bot/routers/dashboard/users/user/getters.py b/src/bot/routers/dashboard/users/user/getters.py index 6ae8b59..9a678b6 100644 --- a/src/bot/routers/dashboard/users/user/getters.py +++ b/src/bot/routers/dashboard/users/user/getters.py @@ -8,6 +8,8 @@ from remnawave.exceptions import NotFoundError from remnawave.models import ( GetAllInternalSquadsResponseDto, + GetExternalSquadsResponseDto, + GetOneNodeResponseDto, TelegramUserResponseDto, ) @@ -93,6 +95,7 @@ async def subscription_getter( user_service: FromDishka[UserService], subscription_service: FromDishka[SubscriptionService], remnawave_service: FromDishka[RemnawaveService], + remnawave: FromDishka[RemnawaveSDK], **kwargs: Any, ) -> dict[str, Any]: target_telegram_id = dialog_manager.dialog_data["target_telegram_id"] @@ -117,6 +120,12 @@ async def subscription_getter( else False ) + last_node: Optional[GetOneNodeResponseDto] = None + if remna_user.last_connected_node_uuid: + result = await remnawave.nodes.get_one_node(str(remna_user.last_connected_node_uuid)) + assert isinstance(result, GetOneNodeResponseDto), "Wrong response from Remnawave" + last_node = result + return { "is_trial": subscription.is_trial, "is_active": subscription.is_active, @@ -145,13 +154,11 @@ async def subscription_getter( else False ), "last_connected_at": ( - remna_user.last_connected_node.connected_at.strftime(DATETIME_FORMAT) - if remna_user.last_connected_node + remna_user.first_connected.strftime(DATETIME_FORMAT) + if remna_user.first_connected else False ), - "node_name": ( - remna_user.last_connected_node.node_name if remna_user.last_connected_node else False - ), + "node_name": last_node.name if last_node else False, # "plan_name": subscription.plan.name, "plan_type": subscription.plan.type, @@ -279,18 +286,18 @@ async def squads_getter( internal_dict.get(squad, str(squad)) for squad in subscription.internal_squads ) - # external_response = await remnawave.external_squads.get_external_squads() - # if not isinstance(external_response, GetExternalSquadsResponseDto): - # raise ValueError("Wrong response from Remnawave external squads") + external_response = await remnawave.external_squads.get_external_squads() + if not isinstance(external_response, GetExternalSquadsResponseDto): + raise ValueError("Wrong response from Remnawave external squads") - # external_dict = {s.uuid: s.name for s in external_response.external_squads} - # external_squad_name = ( - # external_dict.get(subscription.external_squad) if subscription.external_squad else False - # ) + external_dict = {s.uuid: s.name for s in external_response.external_squads} + external_squad_name = ( + external_dict.get(subscription.external_squad) if subscription.external_squad else False + ) return { "internal_squads": internal_squads_names or False, - "external_squad": False, # external_squad_name, + "external_squad": external_squad_name or False, } @@ -546,7 +553,7 @@ async def role_getter( @inject -async def sync_getter( +async def sync_getter( # noqa: C901 dialog_manager: DialogManager, i18n: FromDishka[TranslatorRunner], user_service: FromDishka[UserService], @@ -592,6 +599,7 @@ async def sync_getter( internal_dict.get(squad, str(squad)) for squad in bot_subscription.internal_squads ) bot_kwargs = { + "id": str(bot_subscription.user_remna_id), "status": bot_subscription.status, "url": bot_subscription.url, "traffic_limit": i18n_format_traffic_limit(bot_subscription.traffic_limit), @@ -613,6 +621,7 @@ async def sync_getter( internal_dict.get(squad, str(squad)) for squad in remna_subscription.internal_squads ) remna_kwargs = { + "id": str(remna_subscription.uuid), "status": remna_subscription.status, "url": remna_subscription.url, "traffic_limit": i18n_format_traffic_limit(remna_subscription.traffic_limit), diff --git a/src/bot/routers/dashboard/users/user/handlers.py b/src/bot/routers/dashboard/users/user/handlers.py index 71d5361..6a4a516 100644 --- a/src/bot/routers/dashboard/users/user/handlers.py +++ b/src/bot/routers/dashboard/users/user/handlers.py @@ -1004,9 +1004,9 @@ async def on_sync_from_remnashop( created_user = await remnawave_service.create_user( user=target_user, subscription=subscription, + force=True, ) - - await remnawave_service.sync_user(created_user, creating=False) + await remnawave_service.sync_user(created_user, creating=False) await notification_service.notify_user( user=user, @@ -1097,7 +1097,7 @@ async def on_subscription_duration_select( subscription_url = remna_user.subscription_url if not subscription_url: - subscription_url = await remnawave_service.get_subscription_url(remna_user.uuid) + subscription_url = await remnawave_service.get_subscription_url(remna_user.uuid) # type: ignore[assignment] new_subscription = SubscriptionDto( user_remna_id=remna_user.uuid, diff --git a/src/bot/routers/menu/dialog.py b/src/bot/routers/menu/dialog.py index f9aefc4..19d9af7 100644 --- a/src/bot/routers/menu/dialog.py +++ b/src/bot/routers/menu/dialog.py @@ -140,7 +140,7 @@ ) invite = Window( - Banner(BannerName.MENU), + Banner(BannerName.REFERRAL), I18nFormat("msg-menu-invite"), Row( SwitchTo( diff --git a/src/bot/widgets/banner.py b/src/bot/widgets/banner.py index c416423..e4e650f 100644 --- a/src/bot/widgets/banner.py +++ b/src/bot/widgets/banner.py @@ -22,30 +22,33 @@ def get_banner( locale: Locale, default_locale: Locale, ) -> tuple[Path, ContentType]: - for current_locale in [locale, default_locale]: - path_locale = banners_dir / current_locale - - if not path_locale.exists(): - continue - - for format in BannerFormat: - path = path_locale / f"{name}.{format}" - - if not path.exists(): + def find_in_dirs(dirs: list[Path], filenames: list[str]) -> tuple[Path, ContentType] | None: + for directory in dirs: + if not directory.exists(): continue - - content_type = format.content_type - logger.debug(f"Found banner '{name}' in locale '{current_locale}': '{path}'") - return path, content_type + for format in BannerFormat: + for pattern in filenames: + filename = pattern.format(format=format) + candidate = directory / filename + if candidate.exists(): + return candidate, format.content_type + return None + + locale_dirs = [banners_dir / locale, banners_dir / default_locale] + + result = find_in_dirs( + locale_dirs, filenames=[f"{name}.{{format}}", f"{BannerName.DEFAULT}.{{format}}"] + ) + if result: + return result logger.warning(f"Banner '{name}' not found in locales '{locale}' or '{default_locale}'") - path = banners_dir / f"{BannerName.DEFAULT}.{BannerFormat.JPG}" - content_type = BannerFormat.JPG.content_type - if not path.exists(): - raise FileNotFoundError(f"Default banner not found: '{path}'") + result = find_in_dirs([banners_dir], [f"{BannerName.DEFAULT}.{{format}}"]) + if result: + return result - return path, content_type + raise FileNotFoundError("Default banner not found in any locale or globally") class Banner(StaticMedia): diff --git a/src/core/exceptions.py b/src/core/exceptions.py index b00ea09..eb0b7e4 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -1,2 +1,2 @@ class MenuRenderingError(Exception): - """Raised when main menu cannot be rendered.""" + """Raised when main menu cannot be rendered""" diff --git a/src/infrastructure/taskiq/tasks/importer.py b/src/infrastructure/taskiq/tasks/importer.py index 2b962af..3217f2c 100644 --- a/src/infrastructure/taskiq/tasks/importer.py +++ b/src/infrastructure/taskiq/tasks/importer.py @@ -4,7 +4,7 @@ from loguru import logger from remnawave import RemnawaveSDK from remnawave.exceptions import BadRequestError -from remnawave.models import CreateUserRequestDto, UserResponseDto, UsersResponseDto +from remnawave.models import CreateUserRequestDto, GetAllUsersResponseDto, UserResponseDto from src.infrastructure.taskiq.broker import broker from src.services.remnawave import RemnawaveService @@ -55,9 +55,9 @@ async def sync_all_users_from_panel_task( start = 0 size = 50 - while True: - response = await remnawave.users.get_all_users_v2(start=start, size=size) - if not isinstance(response, UsersResponseDto) or not response.users: + while True: # TODO: Get users count for cicle + response = await remnawave.users.get_all_users(start=start, size=size) + if not isinstance(response, GetAllUsersResponseDto) or not response.users: break all_remna_users.extend(response.users) diff --git a/src/infrastructure/taskiq/tasks/subscriptions.py b/src/infrastructure/taskiq/tasks/subscriptions.py index 4fabf95..a69f086 100644 --- a/src/infrastructure/taskiq/tasks/subscriptions.py +++ b/src/infrastructure/taskiq/tasks/subscriptions.py @@ -19,6 +19,7 @@ i18n_format_traffic_limit, ) from src.core.utils.message_payload import MessagePayload +from src.core.utils.types import RemnaUserDto from src.infrastructure.database.models.dto import ( PlanSnapshotDto, SubscriptionDto, @@ -166,7 +167,7 @@ async def purchase_subscription_task( subscription=subscription, ) - subscription.expire_at = updated_user.expire_at # type: ignore[assignment] + subscription.expire_at = updated_user.expire_at subscription.plan = plan await subscription_service.update(subscription) logger.debug(f"Renewed subscription for user '{user.telegram_id}'") @@ -237,16 +238,20 @@ async def purchase_subscription_task( @broker.task @inject async def delete_current_subscription_task( - user_telegram_id: int, + remna_user: RemnaUserDto, user_service: FromDishka[UserService], subscription_service: FromDishka[SubscriptionService], ) -> None: - logger.info(f"Delete current subscription started for user '{user_telegram_id}'") + logger.info(f"Delete current subscription started for user '{remna_user.telegram_id}'") - user = await user_service.get(user_telegram_id) + if not remna_user.telegram_id: + logger.debug(f"Skipping RemnaUser '{remna_user.username}': telegram_id is empty") + return + + user = await user_service.get(remna_user.telegram_id) if not user: - logger.debug(f"User '{user_telegram_id}' not found, skipping deletion") + logger.debug(f"User '{remna_user.telegram_id}' not found, skipping deletion") return subscription = await subscription_service.get_current(user.telegram_id) @@ -255,6 +260,10 @@ async def delete_current_subscription_task( logger.debug(f"No current subscription for user '{user.telegram_id}', skipping deletion") return + if subscription.user_remna_id != remna_user.uuid: + logger.debug(f"Subscription user UUID differs for '{user.telegram_id}', skipping deletion") + return + subscription.status = SubscriptionStatus.DELETED await subscription_service.update(subscription) await user_service.delete_current_subscription(user.telegram_id) diff --git a/src/services/plan.py b/src/services/plan.py index 61ff35e..e0ffc4d 100644 --- a/src/services/plan.py +++ b/src/services/plan.py @@ -15,8 +15,6 @@ from .base import BaseService -# TODO: Implement logic for plan availability for specific gateways -# TODO: Implement general discount for plan class PlanService(BaseService): uow: UnitOfWork diff --git a/src/services/remnawave.py b/src/services/remnawave.py index c9dcaa4..4badae3 100644 --- a/src/services/remnawave.py +++ b/src/services/remnawave.py @@ -1,15 +1,17 @@ from datetime import timedelta -from typing import Optional, cast +from typing import Optional, Union, cast from uuid import UUID from aiogram import Bot from fluentogram import TranslatorHub +from httpx import Response from loguru import logger from redis.asyncio import Redis from remnawave import RemnawaveSDK -from remnawave.exceptions import NotFoundError +from remnawave.exceptions import ConflictError, NotFoundError from remnawave.models import ( CreateUserRequestDto, + CreateUserResponseDto, DeleteUserHwidDeviceResponseDto, DeleteUserResponseDto, GetStatsResponseDto, @@ -102,51 +104,75 @@ async def create_user( user: UserDto, plan: Optional[PlanSnapshotDto] = None, subscription: Optional[SubscriptionDto] = None, + force: bool = False, ) -> UserResponseDto: - if subscription: - logger.info( - f"Creating RemnaUser '{user.telegram_id}' " - f"from subscription '{subscription.plan.name}'" - ) - created_user = await self.remnawave.users.create_user( - CreateUserRequestDto( - uuid=subscription.user_remna_id, - expire_at=subscription.expire_at, - username=user.remna_name, - traffic_limit_bytes=format_gb_to_bytes(subscription.traffic_limit), - traffic_limit_strategy=subscription.plan.traffic_limit_strategy, - description=user.remna_description, - tag=subscription.plan.tag, - telegram_id=user.telegram_id, - hwid_device_limit=format_device_count(subscription.device_limit), - active_internal_squads=subscription.internal_squads, - external_squad_uuid=subscription.external_squad, + async def _do_create() -> Union[CreateUserResponseDto, str, bytes, Response]: + if subscription: + logger.info( + f"Creating RemnaUser '{user.remna_name}' " + f"from subscription '{subscription.plan.name}'" ) - ) - elif plan: - logger.info(f"Creating RemnaUser '{user.telegram_id}' from plan '{plan.name}'") - created_user = await self.remnawave.users.create_user( - CreateUserRequestDto( - expire_at=format_days_to_datetime(plan.duration), - username=user.remna_name, - traffic_limit_bytes=format_gb_to_bytes(plan.traffic_limit), - traffic_limit_strategy=plan.traffic_limit_strategy, - description=user.remna_description, - tag=plan.tag, - telegram_id=user.telegram_id, - hwid_device_limit=format_device_count(plan.device_limit), - active_internal_squads=plan.internal_squads, - external_squad_uuid=plan.external_squad, + return await self.remnawave.users.create_user( + CreateUserRequestDto( + uuid=subscription.user_remna_id, + expire_at=subscription.expire_at, + username=user.remna_name, + traffic_limit_bytes=format_gb_to_bytes(subscription.traffic_limit), + traffic_limit_strategy=subscription.plan.traffic_limit_strategy, + description=user.remna_description, + tag=subscription.plan.tag, + telegram_id=user.telegram_id, + hwid_device_limit=format_device_count(subscription.device_limit), + active_internal_squads=subscription.internal_squads, + external_squad_uuid=subscription.external_squad, + ) ) - ) - else: + + if plan: + logger.info(f"Creating RemnaUser '{user.telegram_id}' from plan '{plan.name}'") + return await self.remnawave.users.create_user( + CreateUserRequestDto( + expire_at=format_days_to_datetime(plan.duration), + username=user.remna_name, + traffic_limit_bytes=format_gb_to_bytes(plan.traffic_limit), + traffic_limit_strategy=plan.traffic_limit_strategy, + description=user.remna_description, + tag=plan.tag, + telegram_id=user.telegram_id, + hwid_device_limit=format_device_count(plan.device_limit), + active_internal_squads=plan.internal_squads, + external_squad_uuid=plan.external_squad, + ) + ) + raise ValueError("Either 'plan' or 'subscription' must be provided") - if not isinstance(created_user, UserResponseDto): + try: + created = await _do_create() + + except ConflictError: + if not force: + raise + + logger.warning( + f"User '{user.remna_name}' already exists. Force flag enabled, " + f"removing and recreating" + ) + + old_remna_user = await self.remnawave.users.get_user_by_username(user.remna_name) + + if not isinstance(old_remna_user, UserResponseDto): + logger.warning(f"RemnaUser '{user.remna_name}' not found") + raise ValueError("Failed to get RemnaUser: unexpected response") + + await self.remnawave.users.delete_user(uuid=str(old_remna_user.uuid)) + created = await _do_create() + + if not isinstance(created, UserResponseDto): raise ValueError("Failed to create RemnaUser: unexpected response") - logger.info(f"RemnaUser '{created_user.telegram_id}' created successfully") - return created_user + logger.info(f"RemnaUser '{created.username}' created successfully") + return created async def updated_user( self, @@ -414,7 +440,7 @@ async def handle_user_event(self, event: str, remna_user: RemnaUserDto) -> None: ), "traffic_limit": ( i18n_format_bytes_to_unit(remna_user.traffic_limit_bytes) - if remna_user.traffic_limit_bytes > 0 # type: ignore[operator] + if remna_user.traffic_limit_bytes > 0 else i18n_format_traffic_limit(-1) ), "device_limit": ( @@ -422,7 +448,7 @@ async def handle_user_event(self, event: str, remna_user: RemnaUserDto) -> None: if remna_user.hwid_device_limit else i18n_format_device_limit(-1) ), - "expire_time": i18n_format_expire_time(remna_user.expire_at), # type: ignore[arg-type] + "expire_time": i18n_format_expire_time(remna_user.expire_at), } if event == RemnaUserEvent.MODIFIED: @@ -431,7 +457,7 @@ async def handle_user_event(self, event: str, remna_user: RemnaUserDto) -> None: elif event == RemnaUserEvent.DELETED: logger.debug(f"RemnaUser '{remna_user.telegram_id}' deleted") - await delete_current_subscription_task.kiq(user_telegram_id=remna_user.telegram_id) + await delete_current_subscription_task.kiq(remna_user) elif event in { RemnaUserEvent.REVOKED, @@ -445,7 +471,7 @@ async def handle_user_event(self, event: str, remna_user: RemnaUserDto) -> None: ) await update_status_current_subscription_task.kiq( user_telegram_id=remna_user.telegram_id, - status=SubscriptionStatus(remna_user.status), # type: ignore[arg-type] + status=SubscriptionStatus(remna_user.status), ) if event == RemnaUserEvent.LIMITED: await send_subscription_limited_notification_task.kiq( @@ -453,7 +479,7 @@ async def handle_user_event(self, event: str, remna_user: RemnaUserDto) -> None: i18n_kwargs=i18n_kwargs, ) elif event == RemnaUserEvent.EXPIRED: - if remna_user.expire_at + timedelta(days=3) < datetime_now(): # type: ignore[operator] + if remna_user.expire_at + timedelta(days=3) < datetime_now(): logger.debug( f"Subscription for RemnaUser '{user.telegram_id}' expired more than " "3 days ago, skipping - most likely an imported user" diff --git a/src/services/subscription.py b/src/services/subscription.py index 9e4333b..fd12a2e 100644 --- a/src/services/subscription.py +++ b/src/services/subscription.py @@ -208,7 +208,8 @@ def subscriptions_match( return False return ( - bot_subscription.status == remna_subscription.status + bot_subscription.user_remna_id == remna_subscription.uuid + and bot_subscription.status == remna_subscription.status and bot_subscription.url == remna_subscription.url and bot_subscription.traffic_limit == remna_subscription.traffic_limit and bot_subscription.device_limit == remna_subscription.device_limit diff --git a/src/services/webhook.py b/src/services/webhook.py index f253833..da31452 100644 --- a/src/services/webhook.py +++ b/src/services/webhook.py @@ -25,7 +25,10 @@ async def setup(self, allowed_updates: list[str]) -> WebhookInfo: webhook_data = webhook.model_dump(exclude_unset=True) webhook_hash: str = get_webhook_hash(webhook_data) - if await self._is_set(bot_id=self.bot.id, webhook_hash=webhook_hash): + if ( + await self._is_set(bot_id=self.bot.id, webhook_hash=webhook_hash) + and not self.config.bot.reset_webhook + ): logger.info("Bot webhook setup skipped, already configured") logger.debug(f"Current webhook URL: '{safe_webhook_url}'") return await self.bot.get_webhook_info() diff --git a/uv.lock b/uv.lock index ca22def..51f05bc 100644 --- a/uv.lock +++ b/uv.lock @@ -945,7 +945,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = "~=2.11.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, { name = "redis", specifier = "~=6.4.0" }, - { name = "remnawave", specifier = ">=2.2.6" }, + { name = "remnawave", specifier = ">=2.3.2" }, { name = "sqlalchemy", extras = ["mypy"], specifier = ">=2.0.0" }, { name = "taskiq", specifier = "~=0.11.19" }, { name = "taskiq-redis", specifier = "~=1.1.2" }, @@ -964,18 +964,19 @@ dev = [ [[package]] name = "remnawave" -version = "2.2.6" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cryptography" }, { name = "httpx" }, { name = "orjson" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-core" }, { name = "rapid-api-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/4a/7dc9ce30c30887e975da9a5feb61aebe80a16d1031fe7d3b427fb10ca7d3/remnawave-2.2.6.tar.gz", hash = "sha256:f7b02886678b8f417b994529822fbf5740bf2418820905ed66c3fa09c163a976", size = 45409, upload-time = "2025-11-11T23:18:38.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/04/88d3b28b8529e4971d18e8ae68afe444508b97cd42842cb4af98e1944dae/remnawave-2.3.2.tar.gz", hash = "sha256:42f896a30f5046d5f22e8a466d107a962538b32b224672aabed80d9065f6ed88", size = 49728, upload-time = "2025-12-10T04:40:21.799Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/f4/94dbf7cc509b5a4c80419f8331678bcac01923cdef10de850da793518f80/remnawave-2.2.6-py3-none-any.whl", hash = "sha256:043b758d2858ac60d0f4e3fe3431e1ed288b740830c998b67bac15b33c7a7ad7", size = 73305, upload-time = "2025-11-11T23:18:37.449Z" }, + { url = "https://files.pythonhosted.org/packages/0f/07/fc3b76ba657688c0b5d989e473b22a3addbf69f3487e863830ef6c2528b6/remnawave-2.3.2-py3-none-any.whl", hash = "sha256:d636d3fd9a810464faf4205caa78b41e2bbda04d83090ca0ee613af50ab2959f", size = 78320, upload-time = "2025-12-10T04:40:20.154Z" }, ] [[package]] From 13ed4949919fda3bd8b2a6c34b66e35fb237e653 Mon Sep 17 00:00:00 2001 From: Ilay Date: Wed, 10 Dec 2025 17:58:16 +0500 Subject: [PATCH 2/3] chore: add sync log and fix translation --- assets/translations/ru/messages.ftl | 4 ++-- src/services/subscription.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/translations/ru/messages.ftl b/assets/translations/ru/messages.ftl index 6f705b5..489737f 100644 --- a/assets/translations/ru/messages.ftl +++ b/assets/translations/ru/messages.ftl @@ -886,12 +886,12 @@ msg-plan-squads = 🔗 Сквады { $internal_squads -> - [0] { empty } + [0] { space } *[HAS] ⏺️ Внутренние: { $internal_squads } } { $external_squad -> - [0] { empty } + [0] { space } *[HAS] ⏹️ Внешний: { $external_squad } } diff --git a/src/services/subscription.py b/src/services/subscription.py index fd12a2e..43ee730 100644 --- a/src/services/subscription.py +++ b/src/services/subscription.py @@ -257,6 +257,7 @@ def apply_sync(target: T, source: Union[SubscriptionDto, RemnaSubscriptionDto]) old_value = getattr(target, field) new_value = getattr(source, field) if old_value != new_value: + logger.debug(f"Field '{field}' changed from '{old_value}' to '{new_value}'") setattr(target, field, new_value) return target From c8ee0d60ad72a643cf7494a675274a9d1fd2e715 Mon Sep 17 00:00:00 2001 From: Ilay Date: Wed, 10 Dec 2025 18:02:26 +0500 Subject: [PATCH 3/3] chore: update README --- README.md | 5 +++-- README.ru_RU.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c7716a3..1ed7934 100644 --- a/README.md +++ b/README.md @@ -183,8 +183,9 @@ sudo curl -fsSL https://get.docker.com | sh ``` -> [!WARNING] The latest version of the bot is compatible only with RemnaWave panel version 2.3.* -> **Before installation, make sure your panel matches this version.** +> [!WARNING] +> **The latest version of the bot is compatible only with RemnaWave panel version 2.3.\*** +> Before installation, make sure your panel matches this version. ## Step 1 – Download required files diff --git a/README.ru_RU.md b/README.ru_RU.md index fe8f2f3..c409d92 100644 --- a/README.ru_RU.md +++ b/README.ru_RU.md @@ -183,8 +183,9 @@ sudo curl -fsSL https://get.docker.com | sh ``` -> [!WARNING] Последняя версия бота совместима только с панелью RemnaWave версии 2.3.* -> **Перед установкой убедитесь, что ваша панель соответствует этой версии.** +> [!WARNING] +> **Последняя версия бота совместима только с панелью RemnaWave версии 2.3.\*** +> Перед установкой убедитесь, что ваша панель соответствует этой версии. ## Шаг 1 – Скачивание необходимых файлов