refactor: remove legacy app-config.json system

Replace dual-configuration architecture (Remnawave API + local file fallback)
with Remnawave-only approach. When config is unavailable, show explicit
"not configured" message instead of silent file fallback.

- Delete app-config.json and admin_apps.py CRUD module (~1260 lines)
- Remove sync loaders, legacy step format handlers, device_mapping dicts
- Remove miniapp /app-config.json endpoint and filesystem search
- Remove backup service app-config.json snapshot/restore
- Remove APP_CONFIG_PATH setting, env var, docker volume mount
- Remove hardcoded 6-device keyboard fallback
- Remove legacy step-based keyboard rendering (installationStep etc.)
- Add "config not configured" message when Remnawave config is missing
- Update admin UI: "clear config" disables guide mode instead of reverting
This commit is contained in:
Fringg
2026-02-24 05:58:25 +03:00
parent 711ec344c6
commit 295d2e877e
17 changed files with 183 additions and 2278 deletions
-2
View File
@@ -803,8 +803,6 @@ PRICE_ROUNDING_ENABLED=true
TZ=Europe/Moscow # или UTC, America/New_York и т.д. TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ ===== # ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600 APP_CONFIG_CACHE_TTL=3600
-1
View File
@@ -16,7 +16,6 @@
!uv.lock !uv.lock
!requirements.txt !requirements.txt
!alembic.ini !alembic.ini
!app-config.json
!release-please-config.json !release-please-config.json
!.release-please-manifest.json !.release-please-manifest.json
-658
View File
@@ -1,658 +0,0 @@
{
"config": {
"additionalLocales": [
"ru",
"zh",
"fa"
],
"branding": {
"name": "Subscription",
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
"supportUrl": "https://t.me"
}
},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "Open in App Store [EU]",
"fa": "باز کردن در App Store [EU]",
"ru": "Открыть в App Store [EU]",
"zh": "在 App Store 中打开 [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "Open in App Store [RU]",
"fa": "باز کردن در App Store [RU]",
"ru": "Открыть в App Store [RU]",
"zh": "在 App Store 中打开 [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "streisand",
"name": "Streisand",
"isFeatured": false,
"urlScheme": "streisand://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
},
{
"id": "shadowrocket",
"name": "Shadowrocket",
"isFeatured": false,
"urlScheme": "sub://",
"isNeedBase64Encoding": true,
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
"buttonText": {
"en": "Open in App Store",
"fa": "باز کردن در App Store",
"ru": "Открыть в App Store",
"zh": "在 App Store 中打开"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Open in Google Play",
"fa": "باز کردن در Google Play",
"ru": "Открыть в Google Play",
"zh": "在 Google Play 中打开"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
}
},
{
"id": "clash-meta",
"name": "Clash Meta",
"isFeatured": false,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
},
{
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
"buttonText": {
"en": "Open in F-Droid",
"fa": "در F-Droid باز کنید",
"ru": "Открыть в F-Droid",
"zh": "在 F-Droid 中打开"
}
}
],
"description": {
"en": "Download and install Clash Meta APK",
"fa": "دانلود و نصب Clash Meta APK",
"ru": "Скачайте и установите Clash Meta APK",
"zh": "下载并安装 Clash Meta APK"
}
},
"addSubscriptionStep": {
"description": {
"en": "Tap the button to import configuration",
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
"zh": "点击按钮导入配置"
}
},
"connectAndUseStep": {
"description": {
"en": "Open Clash Meta and tap on Connect",
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
"ru": "Откройте Clash Meta и нажмите Подключиться",
"zh": "打开 Clash Meta 并点击连接"
}
}
}
],
"macos": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"windows": [
{
"id": "clash-verge",
"name": "Clash Verge",
"isFeatured": true,
"urlScheme": "clash://install-config?url=",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
"buttonText": {
"en": "macOS (Intel)",
"fa": "مک (اینتل)",
"ru": "macOS (Intel)",
"zh": "macOS (Intel)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
"buttonText": {
"en": "macOS (Apple Silicon)",
"fa": "مک (Apple Silicon)",
"ru": "macOS (Apple Silicon)",
"zh": "macOS (Apple Silicon)"
}
},
{
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
}
},
"additionalBeforeAddSubscriptionStep": {
"buttons": [],
"description": {
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
"fa": "پس از راه‌اندازی برنامه، می‌توانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
},
"title": {
"en": "Change language",
"fa": "تغییر زبان",
"ru": "Смена языка",
"zh": "更改语言"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"additionalAfterAddSubscriptionStep": {
"buttons": [],
"title": {
"en": "If the subscription is not added",
"fa": "اگر اشتراک در برنامه نصب نشده است",
"ru": "Если подписка не добавилась",
"zh": "如果订阅未添加"
},
"description": {
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایل‌ها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
"fa": "می‌توانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
}
}
},
{
"id": "hiddify",
"name": "Hiddify",
"isFeatured": false,
"urlScheme": "hiddify://import/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
"buttonText": {
"en": "Windows",
"fa": "ویندوز",
"ru": "Windows",
"zh": "Windows"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
"buttonText": {
"en": "macOS",
"fa": "مک",
"ru": "macOS",
"zh": "macOS"
}
},
{
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
"buttonText": {
"en": "Linux",
"fa": "لینوکس",
"ru": "Linux",
"zh": "Linux"
}
}
],
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
}
}
],
"linux": [],
"androidTV": [
{
"id": "new-app-androidtv-1760203310792",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Button TextGoogle Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
],
"appleTV": [
{
"id": "new-app-appletv-1760203488851",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play",
"zh": "Button Text",
"fa": "Button Text"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение",
"zh": "-",
"fa": "-"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
"zh": "-",
"fa": "-"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
"zh": "-",
"fa": "-"
}
}
}
]
}
}
-2
View File
@@ -2,7 +2,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router from .admin_button_styles import router as admin_button_styles_router
@@ -80,7 +79,6 @@ router.include_router(wheel_router)
router.include_router(admin_ticket_notifications_router) router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router) router.include_router(admin_tickets_router)
router.include_router(admin_settings_router) router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router) router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router) router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router) router.include_router(admin_servers_router)
-600
View File
@@ -1,600 +0,0 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
from pathlib import Path
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
logger.error('App config file not found', path=str(config_path))
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='App config file not found',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error('Failed to parse app config', error=e, path=str(config_path))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to parse app config',
)
def _save_config(config: dict) -> None:
"""Save app config to file and invalidate bot's cached config."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error('Failed to save app config', error=e, path=str(config_path))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save app config',
)
try:
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
except Exception:
pass
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info('Admin updated branding', admin_id=admin.id)
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(
'Admin copied app from to as',
admin_id=admin.id,
app_id=app_id,
platform=platform,
target_platform=target_platform,
new_id=new_id,
)
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
enabled: bool
config_uuid: str | None = None
class UpdateRemnaWaveUuidRequest(BaseModel):
"""Request to update RemnaWave config UUID."""
uuid: str | None = None
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
return RemnaWaveConfigStatus(
enabled=bool(config_uuid),
config_uuid=config_uuid,
)
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
from app.handlers.subscription.common import invalidate_app_config_cache
invalidate_app_config_cache()
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
except Exception as e:
logger.error('Error saving RemnaWave config UUID', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save configuration',
)
return RemnaWaveConfigStatus(
enabled=bool(uuid_value),
config_uuid=uuid_value,
)
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(config_uuid)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
'view_position': config.view_position,
'config': config.config,
}
except HTTPException:
raise
except Exception as e:
logger.error('Error fetching RemnaWave config', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch config from RemnaWave',
)
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
):
"""List available subscription page configs from RemnaWave panel."""
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
return [
{
'uuid': c.uuid,
'name': c.name,
'view_position': c.view_position,
}
for c in configs
]
except Exception as e:
logger.error('Error listing RemnaWave configs', error=e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch configs from RemnaWave',
)
+55 -314
View File
@@ -1,7 +1,6 @@
"""Subscription management routes for cabinet.""" """Subscription management routes for cabinet."""
import base64 import base64
import json
import re import re
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
@@ -2681,18 +2680,6 @@ async def get_device_price(
# ============ App Config for Connection ============ # ============ App Config for Connection ============
def _load_app_config_from_file() -> dict[str, Any]:
"""Load app-config.json file."""
try:
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
logger.error('Failed to load app-config.json', error=e)
return {}
def _get_remnawave_config_uuid() -> str | None: def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env.""" """Get RemnaWave config UUID from system settings or env."""
@@ -2702,57 +2689,6 @@ def _get_remnawave_config_uuid() -> str | None:
return settings.CABINET_REMNA_SUB_CONFIG return settings.CABINET_REMNA_SUB_CONFIG
def _is_subscription_link_template(url: str) -> bool:
"""Check if URL is a RemnaWave subscription link template."""
if not url:
return False
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc.
if url.startswith('{{') and url.endswith('}}'):
return True
# Also check for button type "subscriptionLink" indicator
return False
def _convert_remnawave_block_to_step(block: dict[str, Any], url_scheme: str = '') -> dict[str, Any]:
"""Convert RemnaWave block format to cabinet step format."""
step = {
'description': block.get('description', {}),
}
if block.get('title'):
step['title'] = block['title']
if block.get('buttons'):
buttons = []
for btn in block['buttons']:
btn_url = btn.get('url', '') or btn.get('link', '')
btn_type = btn.get('type', '')
# Replace subscription link templates with {{deepLink}} placeholder
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink"
if (
_is_subscription_link_template(btn_url)
or btn_type == 'subscriptionLink'
or (
url_scheme
and btn_url
and (
btn_url.startswith(url_scheme)
or btn_url.endswith('://')
or btn_url.endswith('://add/')
or ('://' in btn_url and not btn_url.startswith('http'))
)
)
):
btn_url = '{{deepLink}}'
buttons.append(
{
'buttonLink': btn_url,
'buttonText': btn.get('text', {}),
}
)
step['buttons'] = buttons
return step
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]: def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
"""Extract URL scheme from buttons list. """Extract URL scheme from buttons list.
@@ -2822,15 +2758,6 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
if scheme: if scheme:
return scheme, uses_crypto return scheme, uses_crypto
# 4. Check in step structures (cabinet format)
for step_key in ['installationStep', 'addSubscriptionStep', 'connectAndUseStep']:
step = app.get(step_key, {})
if isinstance(step, dict):
step_buttons = step.get('buttons', [])
scheme, uses_crypto = _extract_scheme_from_buttons(step_buttons)
if scheme:
return scheme, uses_crypto
# No scheme found # No scheme found
logger.debug( logger.debug(
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme', '_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
@@ -2842,147 +2769,11 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
return '', False return '', False
def _find_subscription_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Find block that contains subscriptionLink button."""
for block in blocks:
if not isinstance(block, dict):
continue
buttons = block.get('buttons', [])
for btn in buttons:
if not isinstance(btn, dict):
continue
# Check for subscriptionLink type, {{SUBSCRIPTION_LINK}}, or {{HAPP_CRYPT4_LINK}} in link
btn_type = btn.get('type', '')
link = btn.get('link', '') or btn.get('url', '')
link_upper = link.upper() if link else ''
if btn_type == 'subscriptionLink' or 'SUBSCRIPTION_LINK' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
return block
return None
async def _load_app_config_async() -> dict[str, Any] | None:
"""Load app config from RemnaWave API (if configured).
def _find_connect_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None: Returns None when no Remnawave config is set or API fails.
"""Find block that is about connection/usage (usually last or has specific keywords)."""
# Look for block with "connect" or "use" in title
for block in blocks:
if not isinstance(block, dict):
continue
title = block.get('title', {})
title_en = title.get('en', '') if isinstance(title, dict) else ''
title_lower = title_en.lower()
if 'connect' in title_lower or 'use' in title_lower:
return block
# Fallback to last block if no match
return blocks[-1] if blocks else None
def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave app format to cabinet app format."""
blocks = app.get('blocks', [])
url_scheme, uses_crypto = _get_url_scheme_for_app(app)
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
app_name = app.get('name', 'unknown')
if url_scheme:
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
# Smart block mapping: find blocks by their content, not just position
# 1. First block is usually installation
installation_block = blocks[0] if len(blocks) > 0 else None
# 2. Find subscription block (with subscriptionLink button)
subscription_block = _find_subscription_block(blocks)
# 3. Find connect/use block (usually last or has "connect" in title)
connect_block = _find_connect_block(blocks)
# Convert blocks to steps
installation_step = (
_convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {'description': {}}
)
subscription_step = (
_convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {'description': {}}
)
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {'description': {}}
# Ensure subscription step has a deepLink button if urlScheme exists
if url_scheme:
has_deeplink_button = False
if 'buttons' in subscription_step:
for btn in subscription_step['buttons']:
if btn.get('buttonLink') == '{{deepLink}}':
has_deeplink_button = True
break
if not has_deeplink_button:
# Add deepLink button at the beginning
deeplink_button = {
'buttonLink': '{{deepLink}}',
'buttonText': {
'en': 'Open app',
'ru': 'Открыть приложение',
'zh': '打开应用',
'fa': 'باز کردن برنامه',
},
}
if 'buttons' not in subscription_step:
subscription_step['buttons'] = []
subscription_step['buttons'].insert(0, deeplink_button)
return {
'id': app.get('name', '').lower().replace(' ', '-'),
'name': app.get('name', ''),
'isFeatured': app.get('featured', False),
'urlScheme': url_scheme,
'usesCryptoLink': uses_crypto,
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'installationStep': installation_step,
'addSubscriptionStep': subscription_step,
'connectAndUseStep': connect_step,
}
def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, Any]:
"""Convert RemnaWave config format to cabinet format."""
platforms = {}
remnawave_platforms = config.get('platforms', {})
for platform_key, platform_data in remnawave_platforms.items():
if not isinstance(platform_data, dict):
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
cabinet_apps = []
for app in apps:
if isinstance(app, dict):
cabinet_apps.append(_convert_remnawave_app_to_cabinet(app))
if cabinet_apps:
platforms[platform_key] = cabinet_apps
# Convert branding
branding = {}
if config.get('brandingSettings'):
branding = {
'name': config['brandingSettings'].get('name', ''),
'logoUrl': config['brandingSettings'].get('logoUrl', ''),
'supportUrl': config['brandingSettings'].get('supportUrl', ''),
}
return {
'config': {
'additionalLocales': ['zh', 'fa'],
'branding': branding,
},
'platforms': platforms,
}
async def _load_app_config_async() -> dict[str, Any]:
"""Load app config from RemnaWave (if configured) or local file.
When config comes from RemnaWave, returns the original format with
``_isRemnawave`` flag so the caller can serve it as-is (enriched with
deep links) instead of converting to the legacy step-based format.
""" """
remnawave_uuid = _get_remnawave_config_uuid() remnawave_uuid = _get_remnawave_config_uuid()
@@ -2997,15 +2788,9 @@ async def _load_app_config_async() -> dict[str, Any]:
raw['_isRemnawave'] = True raw['_isRemnawave'] = True
return raw return raw
except Exception as e: except Exception as e:
logger.warning('Failed to load RemnaWave config, falling back to file', error=e) logger.warning('Failed to load RemnaWave config', error=e)
# Fallback to local file return None
return _load_app_config_from_file()
def _load_app_config() -> dict[str, Any]:
"""Load app-config.json file (sync version for compatibility)."""
return _load_app_config_from_file()
def _create_deep_link( def _create_deep_link(
@@ -3420,18 +3205,22 @@ async def get_app_config(
subscription_url = user.subscription.subscription_url subscription_url = user.subscription.subscription_url
subscription_crypto_link = user.subscription.subscription_crypto_link subscription_crypto_link = user.subscription.subscription_crypto_link
# Load config from RemnaWave (if configured) or local file
config = await _load_app_config_async() config = await _load_app_config_async()
is_remnawave = config.pop('_isRemnawave', False) if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='App configuration not set up.',
)
config.pop('_isRemnawave', None)
hide_link = settings.should_hide_subscription_link() hide_link = settings.should_hide_subscription_link()
# Строим platformNames из displayName каждой платформы RemnaWave # Build platformNames from displayName of each platform
platform_names: dict[str, Any] = {} platform_names: dict[str, Any] = {}
for pk, pd in config.get('platforms', {}).items(): for pk, pd in config.get('platforms', {}).items():
if isinstance(pd, dict) and 'displayName' in pd: if isinstance(pd, dict) and 'displayName' in pd:
platform_names[pk] = pd['displayName'] platform_names[pk] = pd['displayName']
# Фоллбэк для платформ без displayName (en достаточно)
fallback_names = { fallback_names = {
'ios': {'en': 'iPhone/iPad'}, 'ios': {'en': 'iPhone/iPad'},
'android': {'en': 'Android'}, 'android': {'en': 'Android'},
@@ -3445,111 +3234,63 @@ async def get_app_config(
if k not in platform_names: if k not in platform_names:
platform_names[k] = v platform_names[k] = v
if is_remnawave: # Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
# ── RemnaWave original format ── platforms: dict[str, Any] = {}
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs. for platform_key, platform_data in config.get('platforms', {}).items():
platforms: dict[str, Any] = {} if not isinstance(platform_data, dict):
for platform_key, platform_data in config.get('platforms', {}).items(): continue
if not isinstance(platform_data, dict): apps = platform_data.get('apps', [])
continue
apps = platform_data.get('apps', [])
if not isinstance(apps, list):
continue
enriched_apps = []
for app in apps:
if not isinstance(app, dict):
continue
# Generate deep link
deep_link = None
if subscription_url or subscription_crypto_link:
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
# Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
btn['resolvedUrl'] = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
enriched_apps.append(app)
if enriched_apps:
# Сохраняем platform-level поля (svgIconKey, displayName и т.д.)
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return {
'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link,
'branding': config.get('brandingSettings', {}),
}
# ── Legacy file-based format ──
platforms_raw = config.get('platforms', {})
if not isinstance(platforms_raw, dict):
platforms_raw = {}
platforms_legacy: dict[str, Any] = {}
for platform_key, apps in platforms_raw.items():
if not isinstance(apps, list): if not isinstance(apps, list):
continue continue
platform_apps = [] enriched_apps = []
for app in apps: for app in apps:
if not isinstance(app, dict): if not isinstance(app, dict):
continue continue
app_data = { # Generate deep link
'id': app.get('id'), deep_link = None
'name': app.get('name'),
'isFeatured': app.get('isFeatured', False),
'installationStep': app.get('installationStep'),
'addSubscriptionStep': app.get('addSubscriptionStep'),
'connectAndUseStep': app.get('connectAndUseStep'),
'additionalBeforeAddSubscriptionStep': app.get('additionalBeforeAddSubscriptionStep'),
'additionalAfterAddSubscriptionStep': app.get('additionalAfterAddSubscriptionStep'),
}
# Add deep link if subscription exists
if subscription_url or subscription_crypto_link: if subscription_url or subscription_crypto_link:
app_data['deepLink'] = _create_deep_link(app, subscription_url, subscription_crypto_link) deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
app['deepLink'] = deep_link
platform_apps.append(app_data) # Resolve templates only for subscriptionLink and copyButton (not external)
for block in app.get('blocks', []):
if not isinstance(block, dict):
continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
if btn_type in ('subscriptionLink', 'copyButton'):
url = btn.get('url', '') or btn.get('link', '')
if url and '{{' in url:
btn['resolvedUrl'] = _resolve_button_url(
url,
subscription_url,
subscription_crypto_link,
)
if platform_apps: enriched_apps.append(app)
platforms_legacy[platform_key] = platform_apps
if enriched_apps:
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
platform_output['apps'] = enriched_apps
platforms[platform_key] = platform_output
return { return {
'platforms': platforms_legacy, 'isRemnawave': True,
'platforms': platforms,
'svgLibrary': config.get('svgLibrary', {}),
'baseTranslations': config.get('baseTranslations'),
'baseSettings': config.get('baseSettings'),
'uiConfig': config.get('uiConfig', {}),
'platformNames': platform_names, 'platformNames': platform_names,
'hasSubscription': bool(subscription_url or subscription_crypto_link), 'hasSubscription': bool(subscription_url or subscription_crypto_link),
'subscriptionUrl': subscription_url if not hide_link else None, 'subscriptionUrl': subscription_url,
'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None, 'subscriptionCryptoLink': subscription_crypto_link,
'hideLink': hide_link, 'hideLink': hide_link,
'branding': config.get('config', {}).get('branding', {}), 'branding': config.get('brandingSettings', {}),
} }
-8
View File
@@ -674,7 +674,6 @@ class Settings(BaseSettings):
WEB_API_TOKEN_HMAC_SECRET: str | None = None WEB_API_TOKEN_HMAC_SECRET: str | None = None
WEB_API_REQUEST_LOGGING: bool = True WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = 'app-config.json'
ENABLE_DEEP_LINKS: bool = True ENABLE_DEEP_LINKS: bool = True
APP_CONFIG_CACHE_TTL: int = 3600 APP_CONFIG_CACHE_TTL: int = 3600
@@ -1381,13 +1380,6 @@ class Settings(BaseSettings):
return value return value
return None return None
def get_app_config_path(self) -> str:
if os.path.isabs(self.APP_CONFIG_PATH):
return self.APP_CONFIG_PATH
project_root = Path(__file__).parent.parent
return str(project_root / self.APP_CONFIG_PATH)
def is_deep_links_enabled(self) -> bool: def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS return self.ENABLE_DEEP_LINKS
+3 -3
View File
@@ -2725,7 +2725,7 @@ async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, d
else: else:
text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n' text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n'
else: else:
text += 'ℹ️ Конфиг не выбран (используется app-config.json)\n\n' text += 'ℹ️ Конфиг не выбран (гайд-режим отключён)\n\n'
text += 'Выберите конфигурацию для гайд-режима:' text += 'Выберите конфигурацию для гайд-режима:'
@@ -2744,7 +2744,7 @@ async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, d
keyboard.append( keyboard.append(
[ [
types.InlineKeyboardButton( types.InlineKeyboardButton(
text='🗑 Сбросить (использовать app-config.json)', text='🗑 Сбросить (отключить гайд-режим)',
callback_data='admin_remna_clear', callback_data='admin_remna_clear',
) )
] ]
@@ -2795,7 +2795,7 @@ async def select_remna_config(callback: types.CallbackQuery, db_user: User, db:
@admin_required @admin_required
@error_handler @error_handler
async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs): async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
"""Clear the Remnawave config, reverting to local app-config.json.""" """Clear the Remnawave config, disabling guide mode until new config is selected."""
try: try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '') await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '')
await db.commit() await db.commit()
-8
View File
@@ -11,19 +11,15 @@ from .autopay import (
from .common import ( from .common import (
build_redirect_link, build_redirect_link,
create_deep_link, create_deep_link,
format_additional_section,
format_traffic_display, format_traffic_display,
get_apps_for_device,
get_apps_for_platform_async, get_apps_for_platform_async,
get_confirm_switch_traffic_keyboard, get_confirm_switch_traffic_keyboard,
get_device_name, get_device_name,
get_localized_value, get_localized_value,
get_platforms_list, get_platforms_list,
get_reset_devices_confirm_keyboard, get_reset_devices_confirm_keyboard,
get_step_description,
get_traffic_switch_keyboard, get_traffic_switch_keyboard,
invalidate_app_config_cache, invalidate_app_config_cache,
load_app_config,
load_app_config_async, load_app_config_async,
normalize_app, normalize_app,
render_guide_blocks, render_guide_blocks,
@@ -139,9 +135,7 @@ __all__ = [
'devices_continue', 'devices_continue',
'execute_change_devices', 'execute_change_devices',
'execute_switch_traffic', 'execute_switch_traffic',
'format_additional_section',
'format_traffic_display', 'format_traffic_display',
'get_apps_for_device',
'get_apps_for_platform_async', 'get_apps_for_platform_async',
'get_confirm_switch_traffic_keyboard', 'get_confirm_switch_traffic_keyboard',
'get_countries_price_by_uuids_fallback', 'get_countries_price_by_uuids_fallback',
@@ -152,7 +146,6 @@ __all__ = [
'get_platforms_list', 'get_platforms_list',
'get_reset_devices_confirm_keyboard', 'get_reset_devices_confirm_keyboard',
'get_servers_display_names', 'get_servers_display_names',
'get_step_description',
'get_subscription_cost', 'get_subscription_cost',
'get_subscription_info_text', 'get_subscription_info_text',
'get_traffic_packages_info', 'get_traffic_packages_info',
@@ -186,7 +179,6 @@ __all__ = [
'handle_subscription_settings', 'handle_subscription_settings',
'handle_switch_traffic', 'handle_switch_traffic',
'invalidate_app_config_cache', 'invalidate_app_config_cache',
'load_app_config',
'load_app_config_async', 'load_app_config_async',
'normalize_app', 'normalize_app',
'refresh_traffic_config', 'refresh_traffic_config',
+25 -229
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import base64 import base64
import html as html_mod import html as html_mod
import json
import re import re
import time import time
from datetime import datetime from datetime import datetime
@@ -167,22 +166,6 @@ def validate_traffic_price(gb: int) -> bool:
return price > 0 return price > 0
def load_app_config() -> dict[str, Any]:
try:
from app.config import settings
config_path = settings.get_app_config_path()
with open(config_path, encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
logger.error('Некорректный формат app-config.json: ожидается объект')
except Exception as e:
logger.error('Ошибка загрузки конфига приложений', error=e)
return {}
def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str: def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict): if not isinstance(values, dict):
@@ -214,40 +197,6 @@ def get_localized_value(values: Any, language: str, default_language: str = 'en'
return '' return ''
def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str:
if not isinstance(app, dict):
return ''
step = app.get(step_key)
if not isinstance(step, dict):
return ''
description = step.get('description')
return get_localized_value(description, language)
def format_additional_section(additional: Any, texts, language: str) -> str:
if not isinstance(additional, dict):
return ''
title = get_localized_value(additional.get('title'), language)
description = get_localized_value(additional.get('description'), language)
parts: list[str] = []
if title:
parts.append(
texts.t(
'SUBSCRIPTION_ADDITIONAL_STEP_TITLE',
'<b>{title}:</b>',
).format(title=html_mod.escape(title))
)
if description:
parts.append(html_mod.escape(description))
return '\n'.join(parts)
def render_guide_blocks(blocks: list[dict], language: str) -> str: def render_guide_blocks(blocks: list[dict], language: str) -> str:
"""Render block-format guide steps to HTML text.""" """Render block-format guide steps to HTML text."""
@@ -306,27 +255,6 @@ def build_redirect_link(target_link: str | None, template: str | None) -> str |
return result return result
def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
config = load_app_config()
platforms = config.get('platforms', {}) if isinstance(config, dict) else {}
if not isinstance(platforms, dict):
return []
device_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'tv': 'androidTV',
'appletv': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = device_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
return apps if isinstance(apps, list) else []
def get_device_name(device_type: str, language: str = 'ru') -> str: def get_device_name(device_type: str, language: str = 'ru') -> str:
names = { names = {
@@ -355,7 +283,7 @@ _PLATFORM_DISPLAY = {
'appleTV': {'name': 'Apple TV', 'emoji': '📺'}, 'appleTV': {'name': 'Apple TV', 'emoji': '📺'},
} }
# Map legacy device_type keys to Remnawave platform keys # Map callback device_type keys to Remnawave platform keys
_DEVICE_TO_PLATFORM = { _DEVICE_TO_PLATFORM = {
'ios': 'ios', 'ios': 'ios',
'android': 'android', 'android': 'android',
@@ -367,7 +295,7 @@ _DEVICE_TO_PLATFORM = {
'apple_tv': 'appleTV', 'apple_tv': 'appleTV',
} }
# Reverse: Remnawave platform key → legacy callback device_type # Reverse: Remnawave platform key → callback device_type
_PLATFORM_TO_DEVICE = { _PLATFORM_TO_DEVICE = {
'ios': 'ios', 'ios': 'ios',
'android': 'android', 'android': 'android',
@@ -389,8 +317,11 @@ def _get_remnawave_config_uuid() -> str | None:
return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None) return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None)
async def load_app_config_async() -> dict[str, Any]: async def load_app_config_async() -> dict[str, Any] | None:
"""Load app config from Remnawave API (if configured) or local file, with TTL cache.""" """Load app config from Remnawave API (if configured), with TTL cache.
Returns None when no Remnawave config is set or API fails.
"""
global _app_config_cache, _app_config_cache_ts global _app_config_cache, _app_config_cache_ts
ttl = settings.APP_CONFIG_CACHE_TTL ttl = settings.APP_CONFIG_CACHE_TTL
@@ -419,12 +350,9 @@ async def load_app_config_async() -> dict[str, Any]:
logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid) logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid)
return raw return raw
except Exception as e: except Exception as e:
logger.warning('Failed to load Remnawave config, falling back to file', error=e) logger.warning('Failed to load Remnawave config', error=e)
fallback = load_app_config() return None
_app_config_cache = fallback
_app_config_cache_ts = time.monotonic()
return fallback
def invalidate_app_config_cache() -> None: def invalidate_app_config_cache() -> None:
@@ -440,153 +368,32 @@ def invalidate_app_config_cache() -> None:
async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]: async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
"""Get apps for a device type, using async Remnawave config if available.""" """Get apps for a device type from Remnawave config."""
config = await load_app_config_async() config = await load_app_config_async()
is_remnawave = config.get('_isRemnawave', False) if not config:
platforms = config.get('platforms', {}) return []
platforms = config.get('platforms', {})
if not isinstance(platforms, dict): if not isinstance(platforms, dict):
return [] return []
if is_remnawave: platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type) platform_data = platforms.get(platform_key)
platform_data = platforms.get(platform_key) if isinstance(platform_data, dict):
if isinstance(platform_data, dict): apps = platform_data.get('apps', [])
apps = platform_data.get('apps', []) return [normalize_app(app) for app in apps if isinstance(app, dict)]
return [normalize_app(app, is_remnawave=True) for app in apps if isinstance(app, dict)]
return []
# Legacy format — uses different keys for some platforms
legacy_mapping = {
'ios': 'ios',
'android': 'android',
'windows': 'windows',
'mac': 'macos',
'macos': 'macos',
'linux': 'linux',
'tv': 'androidTV',
'androidTV': 'androidTV',
'appletv': 'appleTV',
'appleTV': 'appleTV',
'apple_tv': 'appleTV',
}
config_key = legacy_mapping.get(device_type, device_type)
apps = platforms.get(config_key, [])
if isinstance(apps, list):
return [normalize_app(app, is_remnawave=False) for app in apps if isinstance(app, dict)]
return [] return []
def normalize_app(app: dict[str, Any], *, is_remnawave: bool) -> dict[str, Any]: def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
"""Normalize app dict to a unified format with blocks. """Normalize Remnawave app dict to a unified format with blocks."""
For legacy apps: converts installationStep/addSubscriptionStep/connectAndUseStep into blocks.
For Remnawave apps: already has blocks, just ensure consistent fields.
"""
if is_remnawave:
return {
'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''),
'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''),
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': app.get('blocks', []),
'_raw': app,
}
# Legacy format → convert steps to blocks
blocks: list[dict[str, Any]] = []
# Installation step → block with download buttons
install_step = app.get('installationStep')
if isinstance(install_step, dict):
install_block: dict[str, Any] = {
'title': install_step.get('title', {'en': 'Installation', 'ru': 'Установка'}),
'description': install_step.get('description', {}),
'buttons': [],
}
for btn in install_step.get('buttons', []):
if isinstance(btn, dict):
install_block['buttons'].append(
{
'type': 'externalLink',
'text': btn.get('buttonText', {}),
'url': btn.get('buttonLink', ''),
}
)
blocks.append(install_block)
# additionalBeforeAddSubscriptionStep
add_before = app.get('additionalBeforeAddSubscriptionStep')
if isinstance(add_before, dict):
before_block: dict[str, Any] = {
'title': add_before.get('title', {}),
'description': add_before.get('description', {}),
'buttons': [],
}
for btn in add_before.get('buttons', []):
if isinstance(btn, dict):
before_block['buttons'].append(
{
'type': 'externalLink',
'text': btn.get('buttonText', {}),
'url': btn.get('buttonLink', ''),
}
)
blocks.append(before_block)
# Add subscription step
add_step = app.get('addSubscriptionStep')
if isinstance(add_step, dict):
add_block: dict[str, Any] = {
'title': add_step.get('title', {'en': 'Add subscription', 'ru': 'Добавление подписки'}),
'description': add_step.get('description', {}),
'buttons': [
{
'type': 'subscriptionLink',
'text': {'en': 'Connect', 'ru': 'Подключиться'},
'url': '{{SUBSCRIPTION_LINK}}',
}
],
}
blocks.append(add_block)
# additionalAfterAddSubscriptionStep
add_after = app.get('additionalAfterAddSubscriptionStep')
if isinstance(add_after, dict):
after_block: dict[str, Any] = {
'title': add_after.get('title', {}),
'description': add_after.get('description', {}),
'buttons': [],
}
for btn in add_after.get('buttons', []):
if isinstance(btn, dict):
after_block['buttons'].append(
{
'type': 'externalLink',
'text': btn.get('buttonText', {}),
'url': btn.get('buttonLink', ''),
}
)
blocks.append(after_block)
# Connect and use step
connect_step = app.get('connectAndUseStep')
if isinstance(connect_step, dict):
connect_block: dict[str, Any] = {
'title': connect_step.get('title', {'en': 'Connect & Use', 'ru': 'Подключение'}),
'description': connect_step.get('description', {}),
'buttons': [],
}
blocks.append(connect_block)
return { return {
'id': app.get('id', app.get('name', 'unknown')), 'id': app.get('id', app.get('name', 'unknown')),
'name': app.get('name', ''), 'name': app.get('name', ''),
'isFeatured': app.get('isFeatured', False), 'isFeatured': app.get('featured', app.get('isFeatured', False)),
'urlScheme': app.get('urlScheme', ''), 'urlScheme': app.get('urlScheme', ''),
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False), 'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
'blocks': blocks, 'blocks': app.get('blocks', []),
'_raw': app, '_raw': app,
} }
@@ -596,7 +403,6 @@ def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order. Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order.
""" """
is_remnawave = config.get('_isRemnawave', False)
platforms = config.get('platforms', {}) platforms = config.get('platforms', {})
if not isinstance(platforms, dict): if not isinstance(platforms, dict):
return [] return []
@@ -610,20 +416,13 @@ def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
continue continue
pd = platforms[pk] pd = platforms[pk]
# Check platform has apps if not isinstance(pd, dict) or not pd.get('apps'):
if is_remnawave:
if not isinstance(pd, dict) or not pd.get('apps'):
continue
elif not isinstance(pd, list) or not pd:
continue continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'}) display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
# Get displayName from Remnawave or fallback # Get displayName from Remnawave or fallback
if is_remnawave and isinstance(pd, dict) and 'displayName' in pd: display_name_data = pd.get('displayName', display['name'])
display_name_data = pd['displayName']
else:
display_name_data = display['name']
result.append( result.append(
{ {
@@ -638,10 +437,7 @@ def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
for pk, pd in platforms.items(): for pk, pd in platforms.items():
if pk in order: if pk in order:
continue continue
if is_remnawave: if not isinstance(pd, dict) or not pd.get('apps'):
if not isinstance(pd, dict) or not pd.get('apps'):
continue
elif not isinstance(pd, list) or not pd:
continue continue
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'}) display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
+8 -122
View File
@@ -37,11 +37,8 @@ from app.utils.subscription_utils import (
from .common import ( from .common import (
_get_addon_discount_percent_for_user, _get_addon_discount_percent_for_user,
_get_period_hint_from_subscription, _get_period_hint_from_subscription,
format_additional_section,
get_apps_for_device,
get_apps_for_platform_async, get_apps_for_platform_async,
get_device_name, get_device_name,
get_step_description,
logger, logger,
render_guide_blocks, render_guide_blocks,
) )
@@ -1273,18 +1270,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
) )
return return
# Try async Remnawave config first, fall back to legacy sync
apps = await get_apps_for_platform_async(device_type, db_user.language) apps = await get_apps_for_platform_async(device_type, db_user.language)
is_blocks_format = bool(apps and apps[0].get('blocks'))
# If async returned empty, try legacy
if not apps:
apps_raw = get_apps_for_device(device_type, db_user.language)
if apps_raw:
from .common import normalize_app
apps = [normalize_app(a, is_remnawave=False) for a in apps_raw]
is_blocks_format = True
hide_subscription_link = settings.should_hide_subscription_link() hide_subscription_link = settings.should_hide_subscription_link()
@@ -1344,49 +1330,9 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.', 'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.',
) )
if is_blocks_format: blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language)
blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language) if blocks_text:
if blocks_text: guide_text += '\n\n' + blocks_text
guide_text += '\n\n' + blocks_text
else:
# Legacy steps
installation_description = get_step_description(
featured_app.get('_raw', featured_app), 'installationStep', db_user.language
)
add_description = get_step_description(
featured_app.get('_raw', featured_app), 'addSubscriptionStep', db_user.language
)
connect_description = get_step_description(
featured_app.get('_raw', featured_app), 'connectAndUseStep', db_user.language
)
additional_before_text = format_additional_section(
featured_app.get('_raw', featured_app).get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
featured_app.get('_raw', featured_app).get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>') guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>')
guide_text += '\n' + '\n'.join( guide_text += '\n' + '\n'.join(
@@ -1410,18 +1356,14 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
] ]
) )
# For keyboard: pass raw app if legacy, or normalized app with blocks
keyboard_app = featured_app.get('_raw', featured_app) if not is_blocks_format else featured_app
await callback.message.edit_text( await callback.message.edit_text(
guide_text, guide_text,
reply_markup=get_connection_guide_keyboard( reply_markup=get_connection_guide_keyboard(
subscription_link, subscription_link,
keyboard_app, featured_app,
device_type, device_type,
db_user.language, db_user.language,
has_other_apps=bool(other_apps), has_other_apps=bool(other_apps),
is_blocks_format=is_blocks_format,
), ),
parse_mode='HTML', parse_mode='HTML',
) )
@@ -1433,13 +1375,6 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
texts = get_texts(db_user.language) texts = get_texts(db_user.language)
apps = await get_apps_for_platform_async(device_type, db_user.language) apps = await get_apps_for_platform_async(device_type, db_user.language)
if not apps:
# Fallback to legacy
apps_raw = get_apps_for_device(device_type, db_user.language)
if apps_raw:
from .common import normalize_app
apps = [normalize_app(a, is_remnawave=False) for a in apps_raw]
if not apps: if not apps:
await callback.answer( await callback.answer(
@@ -1481,21 +1416,9 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
) )
return return
# Try async config first
apps = await get_apps_for_platform_async(device_type, db_user.language) apps = await get_apps_for_platform_async(device_type, db_user.language)
is_blocks_format = bool(apps and apps[0].get('blocks'))
app = next((a for a in apps if a.get('id') == app_id), None) if apps else None app = next((a for a in apps if a.get('id') == app_id), None) if apps else None
# Fallback to legacy
if not app:
apps_raw = get_apps_for_device(device_type, db_user.language)
app_raw = next((a for a in apps_raw if a.get('id') == app_id), None) if apps_raw else None
if app_raw:
from .common import normalize_app
app = normalize_app(app_raw, is_remnawave=False)
is_blocks_format = True
if not app: if not app:
await callback.answer( await callback.answer(
texts.t('SUBSCRIPTION_APP_NOT_FOUND', '❌ Приложение не найдено'), texts.t('SUBSCRIPTION_APP_NOT_FOUND', '❌ Приложение не найдено'),
@@ -1533,54 +1456,17 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
+ link_section + link_section
) )
if is_blocks_format: blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language) if blocks_text:
if blocks_text: guide_text += blocks_text + '\n\n'
guide_text += blocks_text + '\n\n'
else:
raw_app = app.get('_raw', app)
installation_description = get_step_description(raw_app, 'installationStep', db_user.language)
add_description = get_step_description(raw_app, 'addSubscriptionStep', db_user.language)
connect_description = get_step_description(raw_app, 'connectAndUseStep', db_user.language)
additional_before_text = format_additional_section(
raw_app.get('additionalBeforeAddSubscriptionStep'),
texts,
db_user.language,
)
additional_after_text = format_additional_section(
raw_app.get('additionalAfterAddSubscriptionStep'),
texts,
db_user.language,
)
guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
if installation_description:
guide_text += f'\n{installation_description}'
if additional_before_text:
guide_text += f'\n\n{additional_before_text}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
if add_description:
guide_text += f'\n{add_description}'
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
if connect_description:
guide_text += f'\n{connect_description}'
if additional_after_text:
guide_text += f'\n\n{additional_after_text}'
keyboard_app = app.get('_raw', app) if not is_blocks_format else app
await callback.message.edit_text( await callback.message.edit_text(
guide_text, guide_text,
reply_markup=get_specific_app_keyboard( reply_markup=get_specific_app_keyboard(
subscription_link, subscription_link,
keyboard_app, app,
device_type, device_type,
db_user.language, db_user.language,
is_blocks_format=is_blocks_format,
), ),
parse_mode='HTML', parse_mode='HTML',
) )
+21 -2
View File
@@ -150,9 +150,28 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
platforms = None platforms = None
try: try:
config = await load_app_config_async() config = await load_app_config_async()
platforms = get_platforms_list(config) or None if config:
platforms = get_platforms_list(config) or None
except Exception as e: except Exception as e:
logger.warning('Failed to load platforms for guide mode, using fallback', error=e) logger.warning('Failed to load platforms for guide mode', error=e)
if not platforms:
await callback.message.edit_text(
texts.t(
'GUIDE_CONFIG_NOT_SET',
'⚠️ <b>Конфигурация не настроена</b>\n\n'
'Администратор ещё не настроил конфигурацию приложений.\n'
'Обратитесь к администратору.',
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
]
),
parse_mode='HTML',
)
await callback.answer()
return
if hide_subscription_link: if hide_subscription_link:
device_text = texts.t( device_text = texts.t(
+68 -222
View File
@@ -154,60 +154,6 @@ async def get_main_menu_keyboard_async(
) )
def _get_localized_value(values, language: str, default_language: str = 'en') -> str:
if not isinstance(values, dict):
return ''
candidates = []
normalized_language = (language or '').strip().lower()
if normalized_language:
candidates.append(normalized_language)
if '-' in normalized_language:
candidates.append(normalized_language.split('-')[0])
default_language = (default_language or '').strip().lower()
if default_language and default_language not in candidates:
candidates.append(default_language)
for candidate in candidates:
if not candidate:
continue
value = values.get(candidate)
if isinstance(value, str) and value.strip():
return value
for value in values.values():
if isinstance(value, str) and value.strip():
return value
return ''
def _build_additional_buttons(additional_section, language: str) -> list[InlineKeyboardButton]:
if not isinstance(additional_section, dict):
return []
buttons = additional_section.get('buttons')
if not isinstance(buttons, list):
return []
localized_buttons: list[InlineKeyboardButton] = []
for button in buttons:
if not isinstance(button, dict):
continue
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
localized_buttons.append(InlineKeyboardButton(text=button_text, url=button_link))
return localized_buttons
_LANGUAGE_DISPLAY_NAMES = { _LANGUAGE_DISPLAY_NAMES = {
'ru': '🇷🇺 Русский', 'ru': '🇷🇺 Русский',
@@ -2307,7 +2253,6 @@ def get_device_selection_keyboard(
keyboard: list[list[InlineKeyboardButton]] = [] keyboard: list[list[InlineKeyboardButton]] = []
if platforms: if platforms:
# Dynamic platforms from Remnawave config
row: list[InlineKeyboardButton] = [] row: list[InlineKeyboardButton] = []
for p in platforms: for p in platforms:
display_name = p.get('displayName', p['key']) display_name = p.get('displayName', p['key'])
@@ -2325,32 +2270,6 @@ def get_device_selection_keyboard(
row = [] row = []
if row: if row:
keyboard.append(row) keyboard.append(row)
else:
# Hardcoded fallback (legacy 6-device layout)
keyboard = [
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android'
),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows'
),
InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'),
],
[
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv'
),
InlineKeyboardButton(
text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv'
),
],
]
if settings.CONNECT_BUTTON_MODE == 'guide': if settings.CONNECT_BUTTON_MODE == 'guide':
keyboard.append( keyboard.append(
@@ -2373,8 +2292,6 @@ def get_connection_guide_keyboard(
device_type: str, device_type: str,
language: str = DEFAULT_LANGUAGE, language: str = DEFAULT_LANGUAGE,
has_other_apps: bool = False, has_other_apps: bool = False,
*,
is_blocks_format: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
@@ -2382,146 +2299,78 @@ def get_connection_guide_keyboard(
keyboard: list[list[InlineKeyboardButton]] = [] keyboard: list[list[InlineKeyboardButton]] = []
if is_blocks_format and 'blocks' in app: for block in app.get('blocks', []):
# Remnawave blocks format with colored buttons if not isinstance(block, dict):
for block in app.get('blocks', []): continue
if not isinstance(block, dict): for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
btn_text = btn.get('text', {})
if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language)
if not btn_text:
continue continue
for btn in block.get('buttons', []):
if not isinstance(btn, dict):
continue
btn_type = btn.get('type', '')
btn_text = btn.get('text', {})
if isinstance(btn_text, dict):
btn_text = get_localized_value(btn_text, language)
if not btn_text:
continue
btn_url = btn.get('url', '') or btn.get('link', '') btn_url = btn.get('url', '') or btn.get('link', '')
resolved_url = btn.get('resolvedUrl', '') resolved_url = btn.get('resolvedUrl', '')
if btn_type == 'externalLink': if btn_type == 'externalLink':
# Download button — blue (primary) if btn_url:
if btn_url: keyboard.append(
keyboard.append( [
[ InlineKeyboardButton(
InlineKeyboardButton( text=f'📥 {btn_text}',
text=f'📥 {btn_text}', url=btn_url,
url=btn_url, style='primary',
style='primary', )
) ]
] )
) elif btn_type == 'subscriptionLink':
elif btn_type == 'subscriptionLink': url = resolved_url or resolve_button_url(btn_url, subscription_url)
# Connect button — green (success) deep_link = create_deep_link(app.get('_raw', app), subscription_url)
url = resolved_url or resolve_button_url(btn_url, subscription_url) final_url = deep_link or url or subscription_url
deep_link = create_deep_link(app.get('_raw', app), subscription_url) if final_url:
final_url = deep_link or url or subscription_url keyboard.append(
if final_url: [
keyboard.append( InlineKeyboardButton(
[ text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
InlineKeyboardButton( url=final_url,
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), style='success',
url=final_url, )
style='success', ]
) )
] elif settings.is_happ_cryptolink_mode():
) keyboard.append(
elif settings.is_happ_cryptolink_mode(): [
keyboard.append( InlineKeyboardButton(
[ text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
InlineKeyboardButton( callback_data='open_subscription_link',
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), style='success',
callback_data='open_subscription_link', )
style='success', ]
) )
] else:
) keyboard.append(
else: [
# Fallback: use raw subscription URL InlineKeyboardButton(
keyboard.append( text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
[ url=subscription_url,
InlineKeyboardButton( style='success',
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'), )
url=subscription_url, ]
style='success', )
) elif btn_type == 'copyButton':
] url = resolved_url or resolve_button_url(btn_url, subscription_url)
) if url:
elif btn_type == 'copyButton': keyboard.append(
url = resolved_url or resolve_button_url(btn_url, subscription_url) [
if url: InlineKeyboardButton(
keyboard.append( text=f'📋 {btn_text}',
[ url=url,
InlineKeyboardButton( )
text=f'📋 {btn_text}', ]
url=url,
)
]
)
else:
# Legacy step-based format
if 'installationStep' in app and 'buttons' in app['installationStep']:
app_buttons: list[InlineKeyboardButton] = []
for button in app['installationStep']['buttons']:
button_text = _get_localized_value(button.get('buttonText'), language)
button_link = button.get('buttonLink')
if not button_text or not button_link:
continue
app_buttons.append(
InlineKeyboardButton(
text=f'📥 {button_text}',
url=button_link,
style='primary',
) )
)
if len(app_buttons) == 2:
keyboard.append(app_buttons)
app_buttons = []
if app_buttons:
keyboard.append(app_buttons)
additional_before_buttons = _build_additional_buttons(
app.get('additionalBeforeAddSubscriptionStep'),
language,
)
for button in additional_before_buttons:
keyboard.append([button])
connect_link = create_deep_link(app, subscription_url)
if connect_link:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=connect_link,
style='success',
)
elif settings.is_happ_cryptolink_mode():
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='open_subscription_link',
style='success',
)
else:
connect_button = InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
url=subscription_url,
style='success',
)
keyboard.append([connect_button])
additional_after_buttons = _build_additional_buttons(
app.get('additionalAfterAddSubscriptionStep'),
language,
)
for button in additional_after_buttons:
keyboard.append([button])
if has_other_apps: if has_other_apps:
keyboard.append( keyboard.append(
@@ -2587,8 +2436,6 @@ def get_specific_app_keyboard(
app: dict, app: dict,
device_type: str, device_type: str,
language: str = DEFAULT_LANGUAGE, language: str = DEFAULT_LANGUAGE,
*,
is_blocks_format: bool = False,
) -> InlineKeyboardMarkup: ) -> InlineKeyboardMarkup:
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps" # Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
return get_connection_guide_keyboard( return get_connection_guide_keyboard(
@@ -2597,7 +2444,6 @@ def get_specific_app_keyboard(
device_type, device_type,
language, language,
has_other_apps=True, has_other_apps=True,
is_blocks_format=is_blocks_format,
) )
+2 -51
View File
@@ -645,19 +645,6 @@ class BackupService:
files_dir = staging_dir / 'files' files_dir = staging_dir / 'files'
files_dir.mkdir(parents=True, exist_ok=True) files_dir.mkdir(parents=True, exist_ok=True)
app_config_path = settings.get_app_config_path()
if app_config_path:
src = Path(app_config_path)
if src.exists():
dest = files_dir / src.name
await asyncio.to_thread(shutil.copy2, src, dest)
files_info.append(
{
'path': str(src),
'relative_path': f'files/{src.name}',
}
)
if include_logs and settings.LOG_FILE: if include_logs and settings.LOG_FILE:
log_path = Path(settings.LOG_FILE) log_path = Path(settings.LOG_FILE)
if log_path.exists(): if log_path.exists():
@@ -1493,46 +1480,10 @@ class BackupService:
logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e) logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e)
async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]: async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]:
snapshots: dict[str, dict[str, Any]] = {} return {}
app_config_path = settings.get_app_config_path()
if app_config_path:
path_obj = Path(app_config_path)
if path_obj.exists() and path_obj.is_file():
try:
async with aiofiles.open(path_obj, encoding='utf-8') as f:
content = await f.read()
snapshots['app_config'] = {
'path': str(path_obj),
'content': content,
'modified_at': datetime.fromtimestamp(path_obj.stat().st_mtime, tz=UTC).isoformat(),
}
logger.info('📁 Добавлен в бекап файл конфигурации', path_obj=path_obj)
except Exception as e:
logger.error('Ошибка чтения файла конфигурации', path_obj=path_obj, e=e)
return snapshots
async def _restore_file_snapshots(self, file_snapshots: dict[str, dict[str, Any]]) -> int: async def _restore_file_snapshots(self, file_snapshots: dict[str, dict[str, Any]]) -> int:
restored_files = 0 return 0
if not file_snapshots:
return restored_files
app_config_snapshot = file_snapshots.get('app_config')
if app_config_snapshot:
target_path = Path(settings.get_app_config_path())
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
await f.write(app_config_snapshot.get('content', ''))
restored_files += 1
logger.info('📁 Файл app-config восстановлен по пути', target_path=target_path)
except Exception as e:
logger.error('Ошибка восстановления файла', target_path=target_path, e=e)
return restored_files
async def get_backup_list(self) -> list[dict[str, Any]]: async def get_backup_list(self) -> list[dict[str, Any]]:
backups = [] backups = []
+1 -2
View File
@@ -171,7 +171,7 @@ class BotConfigurationService:
'MINIAPP': 'Mini App и кастомные ссылки.', 'MINIAPP': 'Mini App и кастомные ссылки.',
'HAPP': 'Интеграция Happ и связанные ссылки.', 'HAPP': 'Интеграция Happ и связанные ссылки.',
'SKIP': 'Настройки быстрого старта и гайд по подключению.', 'SKIP': 'Настройки быстрого старта и гайд по подключению.',
'ADDITIONAL': 'Конфигурация app-config.json, deep links и кеша.', 'ADDITIONAL': 'Конфигурация deep links и кеша.',
'DATABASE': 'Режим работы базы данных и пути до файлов.', 'DATABASE': 'Режим работы базы данных и пути до файлов.',
'POSTGRES': 'Параметры подключения к PostgreSQL.', 'POSTGRES': 'Параметры подключения к PostgreSQL.',
'SQLITE': 'Файл SQLite и резервные параметры.', 'SQLITE': 'Файл SQLite и резервные параметры.',
@@ -295,7 +295,6 @@ class BotConfigurationService:
'CABINET_BUTTON_STYLE': 'INTERFACE', 'CABINET_BUTTON_STYLE': 'INTERFACE',
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON', 'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON', 'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
'APP_CONFIG_PATH': 'ADDITIONAL',
'ENABLE_DEEP_LINKS': 'ADDITIONAL', 'ENABLE_DEEP_LINKS': 'ADDITIONAL',
'APP_CONFIG_CACHE_TTL': 'ADDITIONAL', 'APP_CONFIG_CACHE_TTL': 'ADDITIONAL',
'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE', 'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE',
-52
View File
@@ -1,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
import math import math
import re import re
from collections.abc import Collection from collections.abc import Collection
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from decimal import ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation from decimal import ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
@@ -228,56 +226,6 @@ def _get_tariff_monthly_price(tariff) -> int:
return 0 return 0
@router.get('/app-config.json')
async def get_app_config() -> dict[str, Any]:
data = _load_app_config_data()
if data is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='App config not found')
return data
def _get_app_config_candidate_files() -> list[Path]:
seen: set[Path] = set()
candidates: list[Path] = []
def _add_candidate(path: Path) -> None:
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
candidates.append(resolved)
cwd = Path.cwd()
_add_candidate(cwd / 'miniapp' / 'app-config.json')
_add_candidate(cwd / 'app-config.json')
current = Path(__file__).resolve()
for parent in current.parents:
_add_candidate(parent / 'miniapp' / 'app-config.json')
_add_candidate(parent / 'app-config.json')
_add_candidate(Path('/var/www/remnawave-miniapp/app-config.json'))
return candidates
def _load_app_config_data() -> dict[str, Any] | None:
for path in _get_app_config_candidate_files():
if not path.is_file():
continue
try:
with path.open('r', encoding='utf-8') as file:
data = json.load(file)
except (OSError, json.JSONDecodeError) as error:
logger.warning('Failed to load app-config from', path=path, error=error)
continue
if isinstance(data, dict):
return data
return None
_DECIMAL_ONE_HUNDRED = Decimal(100) _DECIMAL_ONE_HUNDRED = Decimal(100)
_DECIMAL_CENT = Decimal('0.01') _DECIMAL_CENT = Decimal('0.01')
-2
View File
@@ -69,8 +69,6 @@ services:
# Данные приложения (для SQLite в случае переключения) # Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw - ./data:/app/data:rw
- ./locales:/app/locales:rw - ./locales:/app/locales:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone # Timezone
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro