diff --git a/README.md b/README.md
index 28761ad..1ed7934 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,29 @@
- **🧭 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..c409d92 100644
--- a/README.ru_RU.md
+++ b/README.ru_RU.md
@@ -80,7 +80,7 @@
> Конфигуратор реферальной системы.
- > Настройка наград: деньги, дни или автоматически создаваемые промокоды.
+ > Настройка наград: баллы или дни.
> Поддержка двухуровневых рефералов.
@@ -163,15 +163,29 @@
- **🧭 Миграция**
> Простая миграция с других ботов.
-- **🪄 Поддержка 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..489737f 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 = 👮♂️ Администраторы
@@ -885,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/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..43ee730 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
@@ -256,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
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]]