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:
@@ -803,8 +803,6 @@ PRICE_ROUNDING_ENABLED=true
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
!uv.lock
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
|
||||
-658
@@ -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,7 +2,6 @@
|
||||
|
||||
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_broadcasts import router as admin_broadcasts_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_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Subscription management routes for cabinet."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -2681,18 +2680,6 @@ async def get_device_price(
|
||||
# ============ 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:
|
||||
"""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
|
||||
|
||||
|
||||
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]:
|
||||
"""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:
|
||||
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
|
||||
logger.debug(
|
||||
'_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
|
||||
|
||||
|
||||
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:
|
||||
"""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.
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
@@ -2997,15 +2788,9 @@ async def _load_app_config_async() -> dict[str, Any]:
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
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 _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()
|
||||
return None
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
@@ -3420,18 +3205,22 @@ async def get_app_config(
|
||||
subscription_url = user.subscription.subscription_url
|
||||
subscription_crypto_link = user.subscription.subscription_crypto_link
|
||||
|
||||
# Load config from RemnaWave (if configured) or local file
|
||||
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()
|
||||
|
||||
# Строим platformNames из displayName каждой платформы RemnaWave
|
||||
# Build platformNames from displayName of each platform
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
# Фоллбэк для платформ без displayName (en достаточно)
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
@@ -3445,111 +3234,63 @@ async def get_app_config(
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
if is_remnawave:
|
||||
# ── RemnaWave original format ──
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
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():
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
platform_apps = []
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
app_data = {
|
||||
'id': app.get('id'),
|
||||
'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
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
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:
|
||||
platforms_legacy[platform_key] = platform_apps
|
||||
enriched_apps.append(app)
|
||||
|
||||
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 {
|
||||
'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,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url if not hide_link else None,
|
||||
'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None,
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('config', {}).get('branding', {}),
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -674,7 +674,6 @@ class Settings(BaseSettings):
|
||||
WEB_API_TOKEN_HMAC_SECRET: str | None = None
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = 'app-config.json'
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
APP_CONFIG_CACHE_TTL: int = 3600
|
||||
|
||||
@@ -1381,13 +1380,6 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
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:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
|
||||
@@ -2725,7 +2725,7 @@ async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, d
|
||||
else:
|
||||
text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n'
|
||||
else:
|
||||
text += 'ℹ️ Конфиг не выбран (используется app-config.json)\n\n'
|
||||
text += 'ℹ️ Конфиг не выбран (гайд-режим отключён)\n\n'
|
||||
|
||||
text += 'Выберите конфигурацию для гайд-режима:'
|
||||
|
||||
@@ -2744,7 +2744,7 @@ async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, d
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🗑 Сбросить (использовать app-config.json)',
|
||||
text='🗑 Сбросить (отключить гайд-режим)',
|
||||
callback_data='admin_remna_clear',
|
||||
)
|
||||
]
|
||||
@@ -2795,7 +2795,7 @@ async def select_remna_config(callback: types.CallbackQuery, db_user: User, db:
|
||||
@admin_required
|
||||
@error_handler
|
||||
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:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '')
|
||||
await db.commit()
|
||||
|
||||
@@ -11,19 +11,15 @@ from .autopay import (
|
||||
from .common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
format_additional_section,
|
||||
format_traffic_display,
|
||||
get_apps_for_device,
|
||||
get_apps_for_platform_async,
|
||||
get_confirm_switch_traffic_keyboard,
|
||||
get_device_name,
|
||||
get_localized_value,
|
||||
get_platforms_list,
|
||||
get_reset_devices_confirm_keyboard,
|
||||
get_step_description,
|
||||
get_traffic_switch_keyboard,
|
||||
invalidate_app_config_cache,
|
||||
load_app_config,
|
||||
load_app_config_async,
|
||||
normalize_app,
|
||||
render_guide_blocks,
|
||||
@@ -139,9 +135,7 @@ __all__ = [
|
||||
'devices_continue',
|
||||
'execute_change_devices',
|
||||
'execute_switch_traffic',
|
||||
'format_additional_section',
|
||||
'format_traffic_display',
|
||||
'get_apps_for_device',
|
||||
'get_apps_for_platform_async',
|
||||
'get_confirm_switch_traffic_keyboard',
|
||||
'get_countries_price_by_uuids_fallback',
|
||||
@@ -152,7 +146,6 @@ __all__ = [
|
||||
'get_platforms_list',
|
||||
'get_reset_devices_confirm_keyboard',
|
||||
'get_servers_display_names',
|
||||
'get_step_description',
|
||||
'get_subscription_cost',
|
||||
'get_subscription_info_text',
|
||||
'get_traffic_packages_info',
|
||||
@@ -186,7 +179,6 @@ __all__ = [
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'invalidate_app_config_cache',
|
||||
'load_app_config',
|
||||
'load_app_config_async',
|
||||
'normalize_app',
|
||||
'refresh_traffic_config',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import html as html_mod
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -167,22 +166,6 @@ def validate_traffic_price(gb: int) -> bool:
|
||||
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:
|
||||
if not isinstance(values, dict):
|
||||
@@ -214,40 +197,6 @@ def get_localized_value(values: Any, language: str, default_language: str = 'en'
|
||||
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:
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
names = {
|
||||
@@ -355,7 +283,7 @@ _PLATFORM_DISPLAY = {
|
||||
'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 = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
@@ -367,7 +295,7 @@ _DEVICE_TO_PLATFORM = {
|
||||
'apple_tv': 'appleTV',
|
||||
}
|
||||
|
||||
# Reverse: Remnawave platform key → legacy callback device_type
|
||||
# Reverse: Remnawave platform key → callback device_type
|
||||
_PLATFORM_TO_DEVICE = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
@@ -389,8 +317,11 @@ def _get_remnawave_config_uuid() -> str | None:
|
||||
return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None)
|
||||
|
||||
|
||||
async def load_app_config_async() -> dict[str, Any]:
|
||||
"""Load app config from Remnawave API (if configured) or local file, with TTL cache."""
|
||||
async def load_app_config_async() -> dict[str, Any] | None:
|
||||
"""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
|
||||
|
||||
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)
|
||||
return raw
|
||||
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()
|
||||
_app_config_cache = fallback
|
||||
_app_config_cache_ts = time.monotonic()
|
||||
return fallback
|
||||
return 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]]:
|
||||
"""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()
|
||||
is_remnawave = config.get('_isRemnawave', False)
|
||||
platforms = config.get('platforms', {})
|
||||
if not config:
|
||||
return []
|
||||
|
||||
platforms = config.get('platforms', {})
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
if is_remnawave:
|
||||
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
|
||||
platform_data = platforms.get(platform_key)
|
||||
if isinstance(platform_data, dict):
|
||||
apps = platform_data.get('apps', [])
|
||||
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)]
|
||||
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
|
||||
platform_data = platforms.get(platform_key)
|
||||
if isinstance(platform_data, dict):
|
||||
apps = platform_data.get('apps', [])
|
||||
return [normalize_app(app) for app in apps if isinstance(app, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def normalize_app(app: dict[str, Any], *, is_remnawave: bool) -> dict[str, Any]:
|
||||
"""Normalize 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)
|
||||
|
||||
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize Remnawave app dict to a unified format with blocks."""
|
||||
return {
|
||||
'id': app.get('id', app.get('name', 'unknown')),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('isFeatured', False),
|
||||
'isFeatured': app.get('featured', app.get('isFeatured', False)),
|
||||
'urlScheme': app.get('urlScheme', ''),
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'blocks': blocks,
|
||||
'blocks': app.get('blocks', []),
|
||||
'_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.
|
||||
"""
|
||||
is_remnawave = config.get('_isRemnawave', False)
|
||||
platforms = config.get('platforms', {})
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
@@ -610,20 +416,13 @@ def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
continue
|
||||
pd = platforms[pk]
|
||||
|
||||
# Check platform has apps
|
||||
if is_remnawave:
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
elif not isinstance(pd, list) or not pd:
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
|
||||
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
|
||||
|
||||
# Get displayName from Remnawave or fallback
|
||||
if is_remnawave and isinstance(pd, dict) and 'displayName' in pd:
|
||||
display_name_data = pd['displayName']
|
||||
else:
|
||||
display_name_data = display['name']
|
||||
display_name_data = pd.get('displayName', display['name'])
|
||||
|
||||
result.append(
|
||||
{
|
||||
@@ -638,10 +437,7 @@ def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
for pk, pd in platforms.items():
|
||||
if pk in order:
|
||||
continue
|
||||
if is_remnawave:
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
elif not isinstance(pd, list) or not pd:
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
|
||||
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
|
||||
|
||||
@@ -37,11 +37,8 @@ from app.utils.subscription_utils import (
|
||||
from .common import (
|
||||
_get_addon_discount_percent_for_user,
|
||||
_get_period_hint_from_subscription,
|
||||
format_additional_section,
|
||||
get_apps_for_device,
|
||||
get_apps_for_platform_async,
|
||||
get_device_name,
|
||||
get_step_description,
|
||||
logger,
|
||||
render_guide_blocks,
|
||||
)
|
||||
@@ -1273,18 +1270,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
# Try async Remnawave config first, fall back to legacy sync
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
if 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}'
|
||||
blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language)
|
||||
if blocks_text:
|
||||
guide_text += '\n\n' + blocks_text
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>')
|
||||
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(
|
||||
guide_text,
|
||||
reply_markup=get_connection_guide_keyboard(
|
||||
subscription_link,
|
||||
keyboard_app,
|
||||
featured_app,
|
||||
device_type,
|
||||
db_user.language,
|
||||
has_other_apps=bool(other_apps),
|
||||
is_blocks_format=is_blocks_format,
|
||||
),
|
||||
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)
|
||||
|
||||
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:
|
||||
await callback.answer(
|
||||
@@ -1481,21 +1416,9 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
|
||||
)
|
||||
return
|
||||
|
||||
# Try async config first
|
||||
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
|
||||
|
||||
# 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:
|
||||
await callback.answer(
|
||||
texts.t('SUBSCRIPTION_APP_NOT_FOUND', '❌ Приложение не найдено'),
|
||||
@@ -1533,54 +1456,17 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
|
||||
+ link_section
|
||||
)
|
||||
|
||||
if is_blocks_format:
|
||||
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
|
||||
if blocks_text:
|
||||
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
|
||||
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
|
||||
if blocks_text:
|
||||
guide_text += blocks_text + '\n\n'
|
||||
|
||||
await callback.message.edit_text(
|
||||
guide_text,
|
||||
reply_markup=get_specific_app_keyboard(
|
||||
subscription_link,
|
||||
keyboard_app,
|
||||
app,
|
||||
device_type,
|
||||
db_user.language,
|
||||
is_blocks_format=is_blocks_format,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
@@ -150,9 +150,28 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
platforms = None
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
device_text = texts.t(
|
||||
|
||||
+68
-222
@@ -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 = {
|
||||
'ru': '🇷🇺 Русский',
|
||||
@@ -2307,7 +2253,6 @@ def get_device_selection_keyboard(
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
if platforms:
|
||||
# Dynamic platforms from Remnawave config
|
||||
row: list[InlineKeyboardButton] = []
|
||||
for p in platforms:
|
||||
display_name = p.get('displayName', p['key'])
|
||||
@@ -2325,32 +2270,6 @@ def get_device_selection_keyboard(
|
||||
row = []
|
||||
if 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':
|
||||
keyboard.append(
|
||||
@@ -2373,8 +2292,6 @@ def get_connection_guide_keyboard(
|
||||
device_type: str,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
has_other_apps: bool = False,
|
||||
*,
|
||||
is_blocks_format: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
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]] = []
|
||||
|
||||
if is_blocks_format and 'blocks' in app:
|
||||
# Remnawave blocks format with colored buttons
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
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', '')
|
||||
btn_text = btn.get('text', {})
|
||||
if isinstance(btn_text, dict):
|
||||
btn_text = get_localized_value(btn_text, language)
|
||||
if not btn_text:
|
||||
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', '')
|
||||
resolved_url = btn.get('resolvedUrl', '')
|
||||
btn_url = btn.get('url', '') or btn.get('link', '')
|
||||
resolved_url = btn.get('resolvedUrl', '')
|
||||
|
||||
if btn_type == 'externalLink':
|
||||
# Download button — blue (primary)
|
||||
if btn_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'📥 {btn_text}',
|
||||
url=btn_url,
|
||||
style='primary',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
# Connect button — green (success)
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=final_url,
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Fallback: use raw subscription URL
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
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:
|
||||
keyboard.append(
|
||||
[
|
||||
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 btn_type == 'externalLink':
|
||||
if btn_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'📥 {btn_text}',
|
||||
url=btn_url,
|
||||
style='primary',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=final_url,
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
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:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'📋 {btn_text}',
|
||||
url=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
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:
|
||||
keyboard.append(
|
||||
@@ -2587,8 +2436,6 @@ def get_specific_app_keyboard(
|
||||
app: dict,
|
||||
device_type: str,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
*,
|
||||
is_blocks_format: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
|
||||
return get_connection_guide_keyboard(
|
||||
@@ -2597,7 +2444,6 @@ def get_specific_app_keyboard(
|
||||
device_type,
|
||||
language,
|
||||
has_other_apps=True,
|
||||
is_blocks_format=is_blocks_format,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -645,19 +645,6 @@ class BackupService:
|
||||
files_dir = staging_dir / 'files'
|
||||
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:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if log_path.exists():
|
||||
@@ -1493,46 +1480,10 @@ class BackupService:
|
||||
logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e)
|
||||
|
||||
async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]:
|
||||
snapshots: dict[str, dict[str, Any]] = {}
|
||||
|
||||
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
|
||||
return {}
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: dict[str, dict[str, Any]]) -> int:
|
||||
restored_files = 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
|
||||
return 0
|
||||
|
||||
async def get_backup_list(self) -> list[dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
@@ -171,7 +171,7 @@ class BotConfigurationService:
|
||||
'MINIAPP': 'Mini App и кастомные ссылки.',
|
||||
'HAPP': 'Интеграция Happ и связанные ссылки.',
|
||||
'SKIP': 'Настройки быстрого старта и гайд по подключению.',
|
||||
'ADDITIONAL': 'Конфигурация app-config.json, deep links и кеша.',
|
||||
'ADDITIONAL': 'Конфигурация deep links и кеша.',
|
||||
'DATABASE': 'Режим работы базы данных и пути до файлов.',
|
||||
'POSTGRES': 'Параметры подключения к PostgreSQL.',
|
||||
'SQLITE': 'Файл SQLite и резервные параметры.',
|
||||
@@ -295,7 +295,6 @@ class BotConfigurationService:
|
||||
'CABINET_BUTTON_STYLE': 'INTERFACE',
|
||||
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
|
||||
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
|
||||
'APP_CONFIG_PATH': 'ADDITIONAL',
|
||||
'ENABLE_DEEP_LINKS': 'ADDITIONAL',
|
||||
'APP_CONFIG_CACHE_TTL': 'ADDITIONAL',
|
||||
'INACTIVE_USER_DELETE_MONTHS': 'MAINTENANCE',
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -228,56 +226,6 @@ def _get_tariff_monthly_price(tariff) -> int:
|
||||
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_CENT = Decimal('0.01')
|
||||
|
||||
@@ -69,8 +69,6 @@ services:
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
- ./locales:/app/locales:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
Reference in New Issue
Block a user