Compare commits

...

873 Commits

Author SHA1 Message Date
Egor f9e416341f Update Dockerfile 2025-10-08 08:08:25 +03:00
Egor 8a2149d2f8 Update docker-registry.yml 2025-10-08 08:08:15 +03:00
Egor e0f6e3b1ef Update docker-hub.yml 2025-10-08 08:08:01 +03:00
Egor 8948f4d302 Merge pull request #916 from Fr1ngg/b20uxt-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Prevent double counting server counters on paid subscriptions
2025-10-08 07:54:53 +03:00
Egor d13ff6830b Gate server counter updates for paid subscriptions 2025-10-08 07:54:31 +03:00
Egor b8aee33e88 Merge pull request #915 from Fr1ngg/revert-914-1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Revert "Update paid subscription creation to bump server counters"
2025-10-08 07:51:42 +03:00
Egor 17639f3a67 Revert "Update paid subscription creation to bump server counters" 2025-10-08 07:51:32 +03:00
Egor cb77b5a21c Merge pull request #914 from Fr1ngg/1cynlm-bedolaga/fix-trial-server-counter-updates-for-subscriptions
Update paid subscription creation to bump server counters
2025-10-08 07:48:01 +03:00
Egor 012ba2049e Update paid subscription creation to bump server counters 2025-10-08 07:47:40 +03:00
Egor 6acfcc9966 Merge pull request #913 from Fr1ngg/dev2
Рандомные сквады для триала, конфигурация доступных пакетов трафика через админку
2025-10-08 07:36:14 +03:00
Egor f6839c4184 Merge pull request #912 from Fr1ngg/pa8726-bedolaga/add-traffic-package-management-in-admin-panel
Clear admin price input messages after saving
2025-10-08 07:31:53 +03:00
Egor 1fbdcb7391 Remove success message when updating pricing 2025-10-08 07:31:36 +03:00
Egor 50a65d06d4 Merge pull request #911 from Fr1ngg/revert-910-owno31-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:31:10 +03:00
Egor 20855e5bfc Revert "Add admin controls for traffic package visibility" 2025-10-08 07:30:56 +03:00
Egor 2e4317e457 Merge pull request #910 from Fr1ngg/owno31-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:26:18 +03:00
Egor 89dd41ef43 Add admin controls for traffic package visibility 2025-10-08 07:26:04 +03:00
Egor 7674b0325b Merge pull request #909 from Fr1ngg/revert-908-pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin controls for traffic package visibility"
2025-10-08 07:25:45 +03:00
Egor 8a5141b58c Revert "Add admin controls for traffic package visibility" 2025-10-08 07:25:36 +03:00
Egor a819be0b5a Merge pull request #908 from Fr1ngg/pn3n44-bedolaga/add-traffic-package-management-in-admin-panel
Add admin controls for traffic package visibility
2025-10-08 07:23:52 +03:00
Egor 7de4730822 Add admin toggles for traffic packages 2025-10-08 07:23:36 +03:00
Egor b4815b291e Merge pull request #907 from Fr1ngg/revert-906-9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Revert "Add admin UI toggles for traffic packages"
2025-10-08 07:23:23 +03:00
Egor 68da3d7d30 Revert "Add admin UI toggles for traffic packages" 2025-10-08 07:23:14 +03:00
Egor ca13385dee Merge pull request #906 from Fr1ngg/9sm5by-bedolaga/add-traffic-package-management-in-admin-panel
Add admin UI toggles for traffic packages
2025-10-08 07:20:38 +03:00
Egor 9216128eb6 Add traffic package toggle controls in admin pricing 2025-10-08 07:20:23 +03:00
Egor 3d1fe09540 Merge pull request #905 from Fr1ngg/vkeste-bedolaga/fix-trial-server-availability-issue
Allow trial squads to be issued when inactive
2025-10-08 07:07:21 +03:00
Egor 07b8e04afe Allow trial squad selection when inactive 2025-10-08 07:07:06 +03:00
Egor 01039a969a Merge pull request #904 from Fr1ngg/8pyo0r-bedolaga/fix-user-counter-update-on-servers
Fix server user counts when subscriptions are removed
2025-10-08 06:58:10 +03:00
Egor a020db5bc6 Fix server counters when subscriptions are removed 2025-10-08 06:57:53 +03:00
Egor a91baf2acd Merge pull request #903 from Fr1ngg/revert-902-njiwqq-bedolaga/fix-user-counter-update-on-servers
Revert "Update trial subscription server counts automatically"
2025-10-08 06:57:31 +03:00
Egor 1caa2b2ba2 Revert "Update trial subscription server counts automatically" 2025-10-08 06:57:22 +03:00
Egor a7e9f732a2 Merge pull request #902 from Fr1ngg/njiwqq-bedolaga/fix-user-counter-update-on-servers
Update trial subscription server counts automatically
2025-10-08 06:44:43 +03:00
Egor c8794722a2 Update trial subscription server counts automatically 2025-10-08 06:44:28 +03:00
Egor bbdb3e454e Merge pull request #901 from Fr1ngg/revert-900-zf3oh7-bedolaga/fix-user-counter-update-issue
Revert "Fix server user counters for nullable values"
2025-10-08 06:36:07 +03:00
Egor fa693494d0 Revert "Fix server user counters for nullable values" 2025-10-08 06:35:45 +03:00
Egor 02c38a25ea Merge pull request #900 from Fr1ngg/zf3oh7-bedolaga/fix-user-counter-update-issue
Fix server user counters for nullable values
2025-10-08 06:34:03 +03:00
Egor d5d60a8c7a Fix server user counters for nullable values 2025-10-08 06:33:44 +03:00
Egor 864aaae017 Merge pull request #899 from Fr1ngg/revert-898-kkzy84-bedolaga/fix-user-counter-update-issue
Revert "Expire server cache after counter updates"
2025-10-08 06:33:17 +03:00
Egor 413173270d Revert "Expire server cache after counter updates" 2025-10-08 06:33:09 +03:00
Egor b8eb6606f1 Merge pull request #898 from Fr1ngg/kkzy84-bedolaga/fix-user-counter-update-issue
Expire server cache after counter updates
2025-10-08 06:31:40 +03:00
Egor 83226c8f3d Expire server cache after counter updates 2025-10-08 06:31:16 +03:00
Egor 8a96cddb4f Merge pull request #897 from Fr1ngg/revert-896-z6lcfu-bedolaga/fix-user-counter-update-issue
Revert "Fix server squad user counters on removal"
2025-10-08 06:30:14 +03:00
Egor e32f3191e4 Revert "Fix server squad user counters on removal" 2025-10-08 06:30:05 +03:00
Egor acb5c3c1d7 Merge pull request #896 from Fr1ngg/z6lcfu-bedolaga/fix-user-counter-update-issue
Fix server squad user counters on removal
2025-10-08 06:27:18 +03:00
Egor cf13b49b3c Fix server squad user counters on removal 2025-10-08 06:26:41 +03:00
Egor 2049765969 Merge pull request #895 from Fr1ngg/tkq4hh-bedolaga/refactor-trial-squad-selection-in-bot
feat: randomize trial squad distribution
2025-10-08 06:07:56 +03:00
Egor 6b2af1f4a3 feat: randomize trial squad distribution 2025-10-08 06:07:31 +03:00
Egor 00d080cbfb Merge pull request #894 from Fr1ngg/j4qq3i-bedolaga/add-admin-panel-for-menu-button-management
Add admin tools for configurable main menu buttons
2025-10-08 06:00:00 +03:00
Egor d0ce5438a8 Add admin API and service for dynamic main menu buttons 2025-10-08 05:59:37 +03:00
Egor 05914c4e14 Merge pull request #893 from Fr1ngg/dev2
Включение автоплатежа по дефолту
2025-10-08 05:21:12 +03:00
Egor a040d03df0 Merge pull request #892 from Fr1ngg/bj3dv4-bedolaga/add-default-auto-payment-setting-for-users
Enable configurable default autopay
2025-10-08 05:17:30 +03:00
Egor a29e0d5fb4 Update config.py 2025-10-08 05:17:12 +03:00
Egor ea7697fd66 Enable configurable default autopay 2025-10-08 05:16:11 +03:00
Egor ab86296256 Merge pull request #891 from Fr1ngg/dev2
Возможность уменьшать кол-во дней подписки юзерам
2025-10-08 05:00:57 +03:00
Egor 9bb384788f Merge pull request #890 from Fr1ngg/c9cmzh-bedolaga/add-ability-to-decrease-subscription-days
Allow admins to decrease user subscription days
2025-10-08 04:56:52 +03:00
Egor 6fc4eb033b Allow admins to decrease subscription days 2025-10-08 04:56:33 +03:00
Egor 8a8abf5b96 Merge pull request #889 from Fr1ngg/revert-888-fggclp-bedolaga/adapt-connection-page-for-app-schemes
Revert "feat: support dynamic miniapp app selection"
2025-10-08 04:50:32 +03:00
Egor 01e68bec68 Revert "feat: support dynamic miniapp app selection" 2025-10-08 04:50:22 +03:00
Egor 7b6488d166 Merge pull request #888 from Fr1ngg/fggclp-bedolaga/adapt-connection-page-for-app-schemes
feat: support dynamic miniapp app selection
2025-10-08 04:46:21 +03:00
Egor 6c4e4b366a feat: support dynamic miniapp app selection 2025-10-08 04:46:03 +03:00
Egor 1defa938ba Merge pull request #887 from Fr1ngg/revert-886-4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Revert "Adapt miniapp connection UX for per-app schemes"
2025-10-08 04:45:23 +03:00
Egor efaac8ff76 Revert "Adapt miniapp connection UX for per-app schemes" 2025-10-08 04:45:13 +03:00
Egor 9246312c68 Merge pull request #886 from Fr1ngg/4mz7rm-bedolaga/adapt-connection-page-for-app-schemes
Adapt miniapp connection UX for per-app schemes
2025-10-08 04:41:21 +03:00
Egor d4bbea616f feat: update miniapp connection flows 2025-10-08 04:41:01 +03:00
Egor 1be8e1a60f Merge pull request #885 from Fr1ngg/dev2
Вывод перечеркнутой исходной цены и скидочной вместе при продлении
2025-10-08 04:33:57 +03:00
Egor 0f1147a226 Merge pull request #884 from Fr1ngg/q6yun8-bedolaga/show-crossed-out-and-discounted-prices
Show strikethrough renewal prices when discounts apply
2025-10-08 04:30:22 +03:00
Egor dc7d0fa8d6 Show original prices when subscription renewal discounts apply 2025-10-08 04:30:05 +03:00
Egor 1fc4686490 Merge pull request #883 from Fr1ngg/revert-882-wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Revert "Update miniapp app selection and connection flows"
2025-10-08 04:29:31 +03:00
Egor 8378020a3e Revert "Update miniapp app selection and connection flows" 2025-10-08 04:29:21 +03:00
Egor bce31ea9a4 Merge pull request #882 from Fr1ngg/wu6jus-bedolaga/adapt-connection-page-for-app-schemes
Update miniapp app selection and connection flows
2025-10-08 04:27:48 +03:00
Egor 5b4337a436 Update miniapp connection flows per app selection 2025-10-08 04:26:16 +03:00
Egor ac0953a221 Merge pull request #881 from Fr1ngg/dev2
Потерянные локали и обновление механизма синхронизации с remnawave (синк со временем из докера)
2025-10-08 04:15:27 +03:00
Egor bfdb17d653 Merge pull request #880 from Fr1ngg/iocwov-bedolaga/fix-subscription-datetime-handling
Fix disabled subscription end date timezone
2025-10-08 03:57:37 +03:00
Egor 3159e80897 Fix disabled subscription end date timezone 2025-10-08 03:57:00 +03:00
Egor 35d3fb1916 Merge pull request #879 from Fr1ngg/revert-878-xg90tu-bedolaga/fix-subscription-datetime-handling
Revert "Normalize subscription timestamps to UTC"
2025-10-08 03:56:41 +03:00
Egor 4be51bbe51 Revert "Normalize subscription timestamps to UTC" 2025-10-08 03:56:31 +03:00
Egor 2416bd6ad5 Merge pull request #878 from Fr1ngg/xg90tu-bedolaga/fix-subscription-datetime-handling
Normalize subscription timestamps to UTC
2025-10-08 03:54:54 +03:00
Egor b3697b1871 Normalize subscription timestamps to UTC 2025-10-08 03:54:37 +03:00
Egor 483fab8427 Merge pull request #877 from Fr1ngg/dor5e8-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave user sync timezone handling
2025-10-08 03:47:19 +03:00
Egor 95ae04d553 Fix RemnaWave user sync timezone handling 2025-10-08 03:47:03 +03:00
Egor a6d8e77f9e Merge pull request #876 from Fr1ngg/revert-875-t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Normalize RemnaWave expiry dates to UTC"
2025-10-08 03:46:47 +03:00
Egor ae4814d1cb Revert "Normalize RemnaWave expiry dates to UTC" 2025-10-08 03:46:39 +03:00
Egor fef4f81744 Merge pull request #875 from Fr1ngg/t8r8ec-bedolaga/fix-user-sync-ignoring-time-zone
Normalize RemnaWave expiry dates to UTC
2025-10-08 03:43:50 +03:00
Egor 65057348e9 Normalize RemnaWave expiry dates to UTC 2025-10-08 03:43:35 +03:00
Egor 4d32e509e0 Merge pull request #874 from Fr1ngg/revert-873-virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Revert "Fix RemnaWave sync timezone handling"
2025-10-08 03:43:02 +03:00
Egor 8e336e1f7b Revert "Fix RemnaWave sync timezone handling" 2025-10-08 03:42:53 +03:00
Egor ee7078705b Merge pull request #873 from Fr1ngg/virkxj-bedolaga/fix-user-sync-ignoring-time-zone
Fix RemnaWave sync timezone handling
2025-10-08 03:33:46 +03:00
Egor 48fe845ff4 Fix RemnaWave sync timezone handling 2025-10-08 03:33:32 +03:00
Egor 99c882110a Merge pull request #872 from Fr1ngg/bedolaga-g891y6
Add missing Russian privacy policy locale strings
2025-10-08 03:27:31 +03:00
Egor a4f1323a81 Add missing Russian privacy policy translations 2025-10-08 03:27:11 +03:00
Egor aad1bb8805 Merge pull request #871 from Fr1ngg/dev2
Раздел (Скидки за траты) в Инфо
2025-10-08 03:08:37 +03:00
Egor d2eb1d35db Update ru.json 2025-10-08 03:07:39 +03:00
Egor d6fa2a8f3c Update ru.json 2025-10-08 03:06:59 +03:00
Egor 32b074efd4 Merge pull request #870 from Fr1ngg/rm42ap-bedolaga/add-promo-groups-section-to-bot
Add promo group progress overview to Info menu
2025-10-08 02:57:47 +03:00
Egor 787d801722 Add promo groups info section 2025-10-08 02:57:31 +03:00
Egor 91c3f2766a Merge pull request #869 from Fr1ngg/dev2
Dev2
2025-10-08 02:44:38 +03:00
Egor 911a20a4e3 Merge pull request #868 from Fr1ngg/main
w
2025-10-08 02:39:16 +03:00
Egor 8e04a0e2b5 Merge pull request #867 from Fr1ngg/lf0mas-bedolaga/add-external-admin-panel-settings
Очистить токен внешней админки при подмене
2025-10-08 02:31:37 +03:00
Egor 6225463af1 Очистка токена внешней админки при подмене 2025-10-08 02:31:12 +03:00
Egor d4badacd4a Merge pull request #866 from Fr1ngg/revert-865-auudl3-bedolaga/add-external-admin-panel-settings
Revert "Защитить токен внешней админки от подмены"
2025-10-08 02:30:48 +03:00
Egor 63bf04bb70 Revert "Защитить токен внешней админки от подмены" 2025-10-08 02:30:39 +03:00
Egor 2529c9bca2 Merge pull request #865 from Fr1ngg/auudl3-bedolaga/add-external-admin-panel-settings
Защитить токен внешней админки от подмены
2025-10-08 02:18:11 +03:00
Egor bc09d2f610 Защитить токен внешней админки от подмены 2025-10-08 02:17:57 +03:00
Egor d530d7a296 Merge pull request #864 from Fr1ngg/revert-863-mvul3c-bedolaga/add-external-admin-panel-settings
Revert "Усилить генерацию токена внешней админки"
2025-10-08 02:17:23 +03:00
Egor bea0977a86 Revert "Усилить генерацию токена внешней админки" 2025-10-08 02:17:13 +03:00
Egor 634b2782d8 Merge pull request #863 from Fr1ngg/mvul3c-bedolaga/add-external-admin-panel-settings
Усилить генерацию токена внешней админки
2025-10-08 01:56:32 +03:00
Egor 0e28cfa326 Усилить генерацию токена внешней админки 2025-10-08 01:56:16 +03:00
Egor 3d2ca8a02d Merge pull request #862 from Fr1ngg/revert-861-hb3c6g-bedolaga/add-external-admin-panel-settings
Revert "Добавить токен внешней админки и защиту настройки"
2025-10-08 01:55:28 +03:00
Egor e0e673e7bb Revert "Добавить токен внешней админки и защиту настройки" 2025-10-08 01:55:17 +03:00
Egor 0d4cea1e22 Merge pull request #861 from Fr1ngg/hb3c6g-bedolaga/add-external-admin-panel-settings
Добавить токен внешней админки и защиту настройки
2025-10-08 01:46:24 +03:00
Egor b643f45c01 Добавить токен внешней админки и защиту настройки 2025-10-08 01:46:06 +03:00
Egor b5b30a07b8 Merge pull request #860 from Fr1ngg/revert-859-lxh4tz-bedolaga/add-external-admin-panel-settings
Revert "Add external admin token support"
2025-10-08 01:45:47 +03:00
Egor 09ea9b0193 Revert "Add external admin token support" 2025-10-08 01:45:35 +03:00
Egor cf76ac7893 Merge pull request #859 from Fr1ngg/lxh4tz-bedolaga/add-external-admin-panel-settings
Add external admin token support
2025-10-08 01:42:46 +03:00
Egor 83b370669e Add external admin token support 2025-10-08 01:42:30 +03:00
Egor 6735cf17cf Merge pull request #858 from Fr1ngg/revert-857-m9d0zq-bedolaga/-api
Revert "Add user API tokens and verification endpoint"
2025-10-08 01:26:50 +03:00
Egor 422686dd2c Revert "Add user API tokens and verification endpoint" 2025-10-08 01:26:42 +03:00
Egor 147dc0343c Merge pull request #857 from Fr1ngg/m9d0zq-bedolaga/-api
Add user API tokens and verification endpoint
2025-10-08 01:24:30 +03:00
Egor cdff9c4666 Add user API token management and external verification endpoint 2025-10-08 01:24:12 +03:00
Egor 25db434e47 Merge pull request #856 from Fr1ngg/revert-855-iex73b-bedolaga/-api
Revert "Add user-linked API tokens and issuance flow"
2025-10-08 01:23:47 +03:00
Egor 5a8aeadcd9 Revert "Add user-linked API tokens and issuance flow" 2025-10-08 01:23:38 +03:00
Egor bdceb9c3cf Merge pull request #855 from Fr1ngg/iex73b-bedolaga/-api
Add user-linked API tokens and issuance flow
2025-10-08 01:19:44 +03:00
Egor f43e0daa69 Add user API tokens linked to Telegram users 2025-10-08 01:19:24 +03:00
Egor c24c668299 Update Dockerfile 2025-10-07 06:47:33 +03:00
Egor 41df40eed3 Update docker-registry.yml 2025-10-07 06:47:26 +03:00
Egor b1975cc3ff Update docker-hub.yml 2025-10-07 06:47:14 +03:00
Egor 369e2c3c36 Merge pull request #853 from Fr1ngg/dev2
Дополнительные методы API для работы со страницами из ИНФО + вынесение хостов в конфигурации для платежек
2025-10-07 06:35:19 +03:00
Egor e2ec4b05db Merge pull request #852 from Fr1ngg/r6u84x-bedolaga/extract-host-to-bot-configuration
Allow configuring webhook hosts
2025-10-07 06:32:21 +03:00
Egor 7493b13348 Allow configuring YooKassa webhook host 2025-10-07 06:32:03 +03:00
Egor 0a65aa16cd Merge pull request #851 from Fr1ngg/461rmg-bedolaga/add-api-endpoints-for-offer-faq-terms
Allow FAQ creation API to pass display order and status
2025-10-07 06:29:22 +03:00
Egor 64a4ece0fe Allow FAQ creation API to pass display order and status 2025-10-07 06:28:26 +03:00
Egor 71664cca3e Merge pull request #850 from Fr1ngg/revert-847-avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Revert "Add informational pages endpoints to admin web API"
2025-10-07 06:28:09 +03:00
Egor b21177f685 Revert "Add informational pages endpoints to admin web API" 2025-10-07 06:28:00 +03:00
Egor cde6aa4c26 Merge pull request #849 from Fr1ngg/revert-848-5sy6f4-bedolaga/extract-host-to-bot-configuration
Revert "Allow configuring Tribute webhook host"
2025-10-07 06:26:51 +03:00
Egor 5264fc0d90 Revert "Allow configuring Tribute webhook host" 2025-10-07 06:26:37 +03:00
Egor 7aa2004f27 Merge pull request #848 from Fr1ngg/5sy6f4-bedolaga/extract-host-to-bot-configuration
Allow configuring Tribute webhook host
2025-10-07 06:23:26 +03:00
Egor 40a34d1e55 Allow configuring Tribute webhook host 2025-10-07 06:22:41 +03:00
Egor 4204b459e2 Merge pull request #847 from Fr1ngg/avd1an-bedolaga/add-api-endpoints-for-offer-faq-terms
Add informational pages endpoints to admin web API
2025-10-07 06:21:03 +03:00
Egor cbe80e9447 Add pages management endpoints to web API 2025-10-07 06:20:44 +03:00
Egor 0cfd8d3c7b Merge pull request #846 from Fr1ngg/dev2
FAQ, Политика Конф, Оферта
2025-10-07 06:08:48 +03:00
Egor ddfa61c56f Merge pull request #845 from Fr1ngg/revert-842-revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Revert "Add FAQ management and user menu support""
2025-10-07 06:03:01 +03:00
Egor 7e340bc13a Revert "Revert "Add FAQ management and user menu support"" 2025-10-07 06:02:50 +03:00
Egor f7dd3be27c Merge pull request #844 from Fr1ngg/rcd0rc-bedolaga/add-offer-button-to-bot-info-section
Fix admin offer preview HTML truncation
2025-10-07 06:02:38 +03:00
Egor 3ff06ed9d1 Use paginator for admin offer preview 2025-10-07 06:02:19 +03:00
Egor 08d6a26b23 Merge pull request #843 from Fr1ngg/revert-841-ptoawe-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 06:01:38 +03:00
Egor 37e472d78a Revert "Add configurable public offer info section" 2025-10-07 06:01:25 +03:00
Egor 8cc04e01e9 Merge pull request #842 from Fr1ngg/revert-839-g9mnci-bedolaga/add-faq-button-to-bot-info-section
Revert "Add FAQ management and user menu support"
2025-10-07 06:01:18 +03:00
Egor c199d04b91 Revert "Add FAQ management and user menu support" 2025-10-07 06:01:09 +03:00
Egor 102d5d0a9c Merge pull request #839 from Fr1ngg/g9mnci-bedolaga/add-faq-button-to-bot-info-section
Add FAQ management and user menu support
2025-10-07 05:52:53 +03:00
Egor 8e7b44811e Merge pull request #841 from Fr1ngg/ptoawe-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:52:37 +03:00
Egor ab1be9943b Fix HTML paginator preserving entities 2025-10-07 05:52:19 +03:00
Egor 61b702272b Merge pull request #840 from Fr1ngg/revert-838-5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:51:59 +03:00
Egor 84bce0043d Revert "Add configurable public offer info section" 2025-10-07 05:51:51 +03:00
Egor f54528f2d5 Add FAQ management and user menu support 2025-10-07 05:47:41 +03:00
Egor b66911f4f7 Merge pull request #838 from Fr1ngg/5gl2xr-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:45:04 +03:00
Egor 352c14331d Handle HTML tags when paginating public offer text 2025-10-07 05:44:16 +03:00
Egor 7ea91f344c Merge pull request #837 from Fr1ngg/revert-836-t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:40:05 +03:00
Egor d2d5b6c823 Revert "Add configurable public offer info section" 2025-10-07 05:39:54 +03:00
Egor 4405c2287a Merge pull request #836 from Fr1ngg/t8dpxo-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:27:38 +03:00
Egor 288a72f739 Fix per-language public offer disabling 2025-10-07 05:27:21 +03:00
Egor 60391a8b89 Merge pull request #835 from Fr1ngg/revert-834-plexvn-bedolaga/add-offer-button-to-bot-info-section
Revert "Add configurable public offer info section"
2025-10-07 05:27:06 +03:00
Egor f2d4da9514 Revert "Add configurable public offer info section" 2025-10-07 05:26:57 +03:00
Egor 648ad24e6d Merge pull request #834 from Fr1ngg/plexvn-bedolaga/add-offer-button-to-bot-info-section
Add configurable public offer info section
2025-10-07 05:17:12 +03:00
Egor 5d28cd9930 Add configurable public offer info section 2025-10-07 05:16:53 +03:00
Egor 43974d1685 Merge pull request #833 from Fr1ngg/kq9kol-bedolaga/add-config-policy-button-to-bot-info
Add privacy policy info menu and admin controls
2025-10-07 04:57:06 +03:00
Egor 39ccc7fb4a Add privacy policy section and admin management 2025-10-07 04:56:45 +03:00
Egor d391a46b0e Merge pull request #831 from Fr1ngg/dev2
Расширения функционала отправки промо предложений / отправка конкретному юзеру
2025-10-07 02:39:35 +03:00
Egor 59dd638dd7 Merge pull request #830 from Fr1ngg/bedolaga-5bnc62
Fix promo offer durations and active discount display
2025-10-07 02:35:47 +03:00
Egor 3062061270 Fix promo offer durations and active discount display 2025-10-07 02:34:47 +03:00
Egor 5b0a64964e Merge pull request #829 from Fr1ngg/revert-828-bedolaga-48se3m
Revert "Add detailed promo offer status for user view"
2025-10-07 02:33:52 +03:00
Egor 70e3517d3a Revert "Add detailed promo offer status for user view" 2025-10-07 02:33:44 +03:00
Egor 08b64abb03 Merge pull request #828 from Fr1ngg/bedolaga-48se3m
Add detailed promo offer status for user view
2025-10-07 02:21:14 +03:00
Egor 84a3179974 Add detailed promo offer status for user view 2025-10-07 02:21:02 +03:00
Egor fa56ad8791 Merge pull request #827 from Fr1ngg/revert-826-bedolaga-ztqa24
Revert "Improve promo offer user view with status details"
2025-10-07 02:20:45 +03:00
Egor f584cb78f1 Revert "Improve promo offer user view with status details" 2025-10-07 02:20:36 +03:00
Egor ff3ac2b9f3 Merge pull request #826 from Fr1ngg/bedolaga-ztqa24
Improve promo offer user view with status details
2025-10-07 02:19:06 +03:00
Egor deabcfba28 Enhance promo offer user detail stats 2025-10-07 02:18:45 +03:00
Egor 85775604a8 Merge pull request #825 from Fr1ngg/revert-824-bedolaga-pzccm9
Revert "Fix promo offer localization placeholder mismatch"
2025-10-07 02:16:00 +03:00
Egor bf9b99818d Revert "Fix promo offer localization placeholder mismatch" 2025-10-07 02:15:52 +03:00
Egor 93a8c182bc Merge pull request #824 from Fr1ngg/bedolaga-pzccm9
Fix promo offer localization placeholder mismatch
2025-10-07 02:04:54 +03:00
Egor 7febaf9db2 Fix promo offer profile summary placeholders 2025-10-07 02:04:38 +03:00
Egor 21583279c6 Merge pull request #823 from Fr1ngg/revert-822-bedolaga-b9dizq
Revert "Cleanup promo offer user search prompt and add localization"
2025-10-07 02:04:11 +03:00
Egor df11e09d3a Revert "Cleanup promo offer user search prompt and add localization" 2025-10-07 02:04:02 +03:00
Egor 0b2ebbcdd8 Merge pull request #822 from Fr1ngg/bedolaga-b9dizq
Cleanup promo offer user search prompt and add localization
2025-10-07 01:57:12 +03:00
Egor 28be0954f9 Fix promo offer user search UX and translations 2025-10-07 01:56:59 +03:00
Egor 44164b2948 Merge pull request #821 from Fr1ngg/revert-820-bedolaga-72teet
Revert "Add user-targeted promo offer sending workflow"
2025-10-07 01:56:25 +03:00
Egor d9d4f171f6 Revert "Add user-targeted promo offer sending workflow" 2025-10-07 01:56:17 +03:00
Egor 4c73d1d0b6 Merge pull request #820 from Fr1ngg/bedolaga-72teet
Add user-targeted promo offer sending workflow
2025-10-07 01:41:34 +03:00
Egor 0bf71971f9 feat: add user-targeted promo offer sending 2025-10-07 01:41:15 +03:00
Egor ff19fb8950 Merge pull request #819 from Fr1ngg/revert-818-bedolaga-pm5a67
Revert "Add promo template editing workflow and restrictions"
2025-10-07 01:21:32 +03:00
Egor 68a37fcf72 Revert "Add promo template editing workflow and restrictions" 2025-10-07 01:21:20 +03:00
Egor 2caf16ccc6 Merge pull request #818 from Fr1ngg/bedolaga-pm5a67
Add promo template editing workflow and restrictions
2025-10-07 01:19:23 +03:00
Egor b38ee36698 Add promo template editing workflow and restrictions 2025-10-07 01:19:05 +03:00
Egor 3ee9c3de7b Merge pull request #817 from Fr1ngg/revert-816-bedolaga-m8ijxh
Revert "Improve admin promo template restrictions and editing"
2025-10-07 01:18:50 +03:00
Egor 0fce289926 Revert "Improve admin promo template restrictions and editing" 2025-10-07 01:18:42 +03:00
Egor b1e7f83d11 Merge pull request #816 from Fr1ngg/bedolaga-m8ijxh
Improve admin promo template restrictions and editing
2025-10-07 01:17:09 +03:00
Egor ff28f82240 Refine admin promo template workflow 2025-10-07 01:16:44 +03:00
Egor d5082be405 Merge pull request #815 from Fr1ngg/revert-814-bedolaga
Revert "Add per-user promo template sending in admin panel"
2025-10-07 01:16:13 +03:00
Egor 3e830a32ea Revert "Add per-user promo template sending in admin panel" 2025-10-07 01:16:05 +03:00
Egor c8a99ceed7 Merge pull request #814 from Fr1ngg/bedolaga
Add per-user promo template sending in admin panel
2025-10-07 00:19:18 +03:00
Egor 602ddd4013 feat(admin): manage user promo offers 2025-10-07 00:18:59 +03:00
Egor 33443955c7 Merge pull request #811 from Fr1ngg/dev2
Dev2
2025-10-06 04:48:28 +03:00
Egor 8f8b6ac1fe Merge pull request #810 from Fr1ngg/bedolaga/add-info-section-to-main-page
Add info section to main menu
2025-10-06 04:46:16 +03:00
Egor 8bb0795b1e Add info section to main menu 2025-10-06 04:45:54 +03:00
Egor 0055560d3e Merge pull request #809 from Fr1ngg/dev2
api promo offers
2025-10-06 04:37:05 +03:00
Egor dcc209deea Merge pull request #808 from Fr1ngg/bedolaga/add-api-endpoints-for-promo-offers
Add promo offer management endpoints
2025-10-06 04:27:40 +03:00
Egor a2cb7527f8 feat(api): add promo offers endpoints and docs 2025-10-06 04:27:21 +03:00
Egor 523c6ab69c Merge pull request #807 from Fr1ngg/dev2
Фикс промо предложений
2025-10-06 02:58:38 +03:00
Egor 91220b0b5f Merge pull request #806 from Fr1ngg/main
w
2025-10-06 02:57:53 +03:00
Egor f87dac8efa Merge pull request #805 from Fr1ngg/bedolaga/fix-discount-claim-error
Handle custom placeholders in discount claim message
2025-10-06 02:53:03 +03:00
Egor 44b75be97a Handle missing placeholders in discount claim message 2025-10-06 02:52:44 +03:00
Egor 500ed4cdf9 Update Dockerfile 2025-10-06 01:59:41 +03:00
Egor 498bd77076 Update docker-registry.yml 2025-10-06 01:59:29 +03:00
Egor 29afb6e67f Update docker-hub.yml 2025-10-06 01:59:16 +03:00
Egor 01d10b58dd Merge pull request #804 from Fr1ngg/dev2
Рассылка промопредложений + выдача сквадов(серверов) на время
2025-10-06 01:50:20 +03:00
Egor cf7aed9abf Merge pull request #803 from Fr1ngg/main
w
2025-10-06 01:46:02 +03:00
Egor 1b57531b28 Merge pull request #802 from Fr1ngg/bedolaga/fix-telegram-button-privacy-restriction-error-ptdrym
Handle Telegram privacy restriction fallback
2025-10-06 01:37:31 +03:00
Egor 375c096e82 Handle Telegram privacy restriction fallback 2025-10-06 01:37:12 +03:00
Egor 89b0daae1d Merge pull request #800 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-lplld3
Escape braces in promo hint server names
2025-10-06 01:25:44 +03:00
Egor 754c21a32f Escape braces in promo hint server names 2025-10-06 01:25:26 +03:00
Egor 4928cdde43 Merge pull request #799 from Fr1ngg/revert-798-bedolaga/fix-bot-main-message-for-test-server-36fdi2
Revert "Escape test server names in promo hint"
2025-10-06 01:25:14 +03:00
Egor 057e5f6ab0 Revert "Escape test server names in promo hint" 2025-10-06 01:25:03 +03:00
Egor 6d66d6f627 Merge pull request #798 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server-36fdi2
Escape test server names in promo hint
2025-10-06 01:15:58 +03:00
Egor be28e9bec4 Escape server names in promo hint 2025-10-06 01:15:42 +03:00
Egor b923c4bcaa Merge pull request #797 from Fr1ngg/revert-796-bedolaga/fix-bot-main-message-for-test-server
Revert "Display test server names in promo hint"
2025-10-06 01:15:26 +03:00
Egor 3a749c1864 Revert "Display test server names in promo hint" 2025-10-06 01:14:58 +03:00
Egor d16d5b7918 Merge pull request #796 from Fr1ngg/bedolaga/fix-bot-main-message-for-test-server
Display test server names in promo hint
2025-10-06 01:09:23 +03:00
Egor 8484515722 Show test server names in promo hint 2025-10-06 01:08:53 +03:00
Egor b9efff6e97 Update ru.json 2025-10-06 01:01:41 +03:00
Egor 061557c438 Update ru.json 2025-10-06 00:58:53 +03:00
Egor df09ccfbd3 Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
2025-10-06 00:49:26 +03:00
Egor 16c7e140d0 Guard promo offer extra_data lookups 2025-10-06 00:49:09 +03:00
Egor 967924a1ff Merge pull request #794 from Fr1ngg/revert-793-revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Revert "Display promo offer details on main menu"""
2025-10-06 00:48:55 +03:00
Egor e028a25df2 Revert "Revert "Revert "Display promo offer details on main menu""" 2025-10-06 00:48:46 +03:00
Egor d3fcd11201 Merge pull request #793 from Fr1ngg/revert-790-revert-789-bedolaga-ly8q4p
Revert "Revert "Display promo offer details on main menu""
2025-10-06 00:42:56 +03:00
Egor 97b9056b0b Revert "Revert "Display promo offer details on main menu"" 2025-10-06 00:42:46 +03:00
Egor 00ba52672b Merge pull request #792 from Fr1ngg/revert-791-bedolaga-g6lr1v
Revert "Add active promo info to main menu"
2025-10-06 00:42:01 +03:00
Egor efe16b4269 Revert "Add active promo info to main menu" 2025-10-06 00:41:42 +03:00
Egor 5e94ad2189 Merge pull request #791 from Fr1ngg/bedolaga-g6lr1v
Add active promo info to main menu
2025-10-06 00:37:37 +03:00
Egor 53bb582c81 Add active promo info to main menu 2025-10-06 00:37:19 +03:00
Egor 4c20ec19ab Merge pull request #790 from Fr1ngg/revert-789-bedolaga-ly8q4p
Revert "Display promo offer details on main menu"
2025-10-06 00:36:36 +03:00
Egor a7042ea012 Revert "Display promo offer details on main menu" 2025-10-06 00:36:26 +03:00
Egor f700285415 Merge pull request #789 from Fr1ngg/bedolaga-ly8q4p
Display promo offer details on main menu
2025-10-06 00:32:44 +03:00
Egor ed1fc22f6b Show promo offer details on main menu 2025-10-06 00:32:27 +03:00
Egor c3c0c764fd Merge pull request #788 from Fr1ngg/bedolaga/update-promo-offer-message-and-add-status-bar
Add promo offer timer to subscription flows
2025-10-06 00:10:22 +03:00
Egor 746e810c58 Add promo offer timer and updated discount messaging 2025-10-06 00:10:06 +03:00
Egor 2df5d21a9e Merge pull request #787 from Fr1ngg/bedolaga/fix-subscription-button-text-for-trial-users
Fix promo purchase discount CTA for trials
2025-10-05 23:35:36 +03:00
Egor d664b99e1c Adjust promo discount CTA for purchase offers 2025-10-05 23:35:09 +03:00
Egor 74fe3b09a2 Merge pull request #786 from Fr1ngg/revert-785-bedolaga-8svrvz
Revert "Adjust discount claim CTA for purchase offers"
2025-10-05 17:28:03 +03:00
Egor efea0e8414 Revert "Adjust discount claim CTA for purchase offers" 2025-10-05 17:27:54 +03:00
Egor 1dc4d0e04d Merge pull request #785 from Fr1ngg/bedolaga-8svrvz
Adjust discount claim CTA for purchase offers
2025-10-05 17:24:41 +03:00
Egor 84f06a96b3 Adjust discount claim CTA for purchase offers 2025-10-05 17:24:25 +03:00
Egor 4a7cc3306a Merge pull request #783 from Fr1ngg/bedolaga/add-duration-for-subscription-promo-discounts
Add expiration control for promo offer discounts
2025-10-05 17:01:38 +03:00
Egor 5d576bc727 Add expiration control for promo offer discounts 2025-10-05 17:01:11 +03:00
Egor 19a7a8e6b1 Merge pull request #782 from Fr1ngg/bedolaga/update-promo-message-buttons
Adjust promo offer keyboards to add close button
2025-10-05 06:11:11 +03:00
Egor 064f64afd0 Restrict promo offer keyboards to activation and close 2025-10-05 06:10:49 +03:00
Egor bfa5e5aab9 Merge pull request #781 from Fr1ngg/bedolaga-zcm3jb
Fix discount activation CTA to extend subscription
2025-10-05 05:47:29 +03:00
Egor f59bda57cf Adjust discount activation CTA 2025-10-05 05:46:55 +03:00
Egor 6887c265ad Merge pull request #780 from Fr1ngg/bedolaga/fix-chat-message-retention-during-promo-editing
Удаление сообщений администратора при редактировании промо-предложений
2025-10-05 05:31:06 +03:00
Egor 7db31ff5b5 Удаление пользовательских сообщений при редактировании промо 2025-10-05 05:30:45 +03:00
Egor 6bee1bb85d Merge pull request #779 from Fr1ngg/bedolaga/add-active-trials-category-to-promotions
Add active trial segment to purchase discount offer
2025-10-05 05:11:01 +03:00
Egor c604c5f6ff Add active trial segment to purchase promo 2025-10-05 05:10:45 +03:00
Egor ed5e6aa550 Merge pull request #777 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-qglaer
fix: rollback session after promo log failures
2025-10-05 04:06:21 +03:00
Egor 6b78d0f63e Merge pull request #778 from Fr1ngg/revert-776-bedolaga/add-paginated-log-for-promo-offers-nmhsmh
Revert "fix: restore promo test access claim flow"
2025-10-05 04:05:56 +03:00
Egor fea1819ef8 Revert "fix: restore promo test access claim flow" 2025-10-05 04:05:47 +03:00
Egor 45586277ab fix: rollback session after promo log failures 2025-10-05 04:05:43 +03:00
Egor 8ff3383246 Merge pull request #776 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-nmhsmh
fix: restore promo test access claim flow
2025-10-05 03:54:57 +03:00
Egor 7879dc5e43 fix: define newly added test access squads 2025-10-05 03:54:39 +03:00
Egor 392084e436 Merge pull request #775 from Fr1ngg/revert-774-revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "Revert "feat: add promo offer operation logs"""
2025-10-05 03:53:28 +03:00
Egor 256e1cb778 Revert "Revert "Revert "feat: add promo offer operation logs""" 2025-10-05 03:53:16 +03:00
Egor e131f77de3 Merge pull request #774 from Fr1ngg/revert-771-revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "Revert "feat: add promo offer operation logs""
2025-10-05 03:46:36 +03:00
Egor a60082e5e5 Revert "Revert "feat: add promo offer operation logs"" 2025-10-05 03:46:23 +03:00
Egor 81bb8f8f4a Merge pull request #773 from Fr1ngg/revert-772-bedolaga/add-paginated-log-for-promo-offers-3spy1o
Revert "Add promo offer activity log in admin panel"
2025-10-05 03:46:05 +03:00
Egor a9af75d9df Revert "Add promo offer activity log in admin panel" 2025-10-05 03:45:55 +03:00
Egor 30758848fa Merge pull request #772 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers-3spy1o
Add promo offer activity log in admin panel
2025-10-05 03:44:46 +03:00
Egor 00c024bd2c Add promo offer activity log in admin panel 2025-10-05 03:44:28 +03:00
Egor a69d28fb13 Merge pull request #771 from Fr1ngg/revert-770-bedolaga/add-paginated-log-for-promo-offers
Revert "feat: add promo offer operation logs"
2025-10-05 03:44:03 +03:00
Egor ac818ccf34 Revert "feat: add promo offer operation logs" 2025-10-05 03:43:53 +03:00
Egor 533335bad9 Merge pull request #770 from Fr1ngg/bedolaga/add-paginated-log-for-promo-offers
feat: add promo offer operation logs
2025-10-05 03:32:40 +03:00
Egor 37c99ae1fe feat: add promo offer operation logs 2025-10-05 03:32:24 +03:00
Egor d9b232b87b Merge pull request #769 from Fr1ngg/bedolaga/add-buttons-for-subscription-promotions
Enhance promo offer messaging and safeguards
2025-10-04 17:06:41 +03:00
Egor b8df611d0d Enhance promo offer flows 2025-10-04 17:06:24 +03:00
Egor e0b5beed02 Merge pull request #768 from Fr1ngg/bedolaga/add-purchase-button-to-discount-message
Fix promo discount message button and checkout crash
2025-10-04 16:43:04 +03:00
Egor f92cf97f63 Fix promo discount claim actions 2025-10-04 16:42:44 +03:00
Egor 9ae11bc525 Merge pull request #767 from Fr1ngg/bedolaga/update-promo-offer-functionality-in-admin-panel
Adjust promo test offers with server targeting
2025-10-04 16:29:38 +03:00
Egor ef1426a938 Adjust promo test offers with server targeting 2025-10-04 16:29:23 +03:00
Egor 456b02e2d9 Merge pull request #766 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-onp0w2
Ensure subscription service reloads RemnaWave configuration
2025-10-04 16:12:25 +03:00
Egor abb156a7b1 Fix RemnaWave config refresh in subscription service 2025-10-04 16:12:03 +03:00
Egor ca8917a43c Merge pull request #765 from Fr1ngg/revert-764-bedolaga/enhance-promo-offer-functionality-mxpx1o
Revert "Fix test access sync and add menu navigation"
2025-10-04 16:08:54 +03:00
Egor 8ce75007ff Revert "Fix test access sync and add menu navigation" 2025-10-04 16:08:45 +03:00
Egor 76ced9aeeb Merge pull request #764 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-mxpx1o
Fix test access sync and add menu navigation
2025-10-04 13:10:40 +03:00
Egor 42eeb67039 Fix test access sync and add back to menu button 2025-10-04 13:10:23 +03:00
Egor c11be659b3 Merge pull request #763 from Fr1ngg/revert-762-bedolaga/enhance-promo-offer-functionality-wktrm4
Revert "Improve test access promo syncing and messaging"
2025-10-04 13:09:58 +03:00
Egor 2a44d98015 Revert "Improve test access promo syncing and messaging" 2025-10-04 13:09:48 +03:00
Egor 885fa2fb1a Merge pull request #762 from Fr1ngg/bedolaga/enhance-promo-offer-functionality-wktrm4
Improve test access promo syncing and messaging
2025-10-04 13:07:00 +03:00
Egor 8bcc2135ca Handle Remnawave sync failures for test access offers 2025-10-04 13:06:43 +03:00
Egor a26d2fb50b Merge pull request #761 from Fr1ngg/revert-760-bedolaga/enhance-promo-offer-functionality
Revert "Add admin squad picker for test access promo offers"
2025-10-04 13:06:18 +03:00
Egor ad3a8b3af3 Revert "Add admin squad picker for test access promo offers" 2025-10-04 13:06:09 +03:00
Egor 918a450e63 Merge pull request #760 from Fr1ngg/bedolaga/enhance-promo-offer-functionality
Add admin squad picker for test access promo offers
2025-10-04 12:52:52 +03:00
Egor 596053c720 feat: add squad picker for test promo offers 2025-10-04 12:52:27 +03:00
Egor abb83b43bc Merge pull request #759 from Fr1ngg/bedolaga/fix-promo-discount-handling-and-messages
Fix promo discount consumption race and refresh promo offer texts
2025-10-04 12:25:22 +03:00
Egor 6f67a36a58 Fix promo discount consumption and admin copy 2025-10-04 12:25:00 +03:00
Egor 9cbc9bb3d5 Merge pull request #758 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-5mdcu6
Honor promo offer discounts in autopay renewals
2025-10-04 12:10:34 +03:00
Egor 1cbf3c5643 Recalculate promo offer discount during subscription confirmation 2025-10-04 12:10:04 +03:00
Egor 8f35d56e6a Merge pull request #757 from Fr1ngg/revert-756-bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Revert "Honor promo offer discounts in autopay renewals"
2025-10-04 12:09:13 +03:00
Egor b5773ba668 Revert "Honor promo offer discounts in autopay renewals" 2025-10-04 12:09:04 +03:00
Egor fa5847ee64 Merge pull request #756 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-3jskcs
Honor promo offer discounts in autopay renewals
2025-10-04 11:53:09 +03:00
Egor b561fbc9e5 Apply promo offer discounts to autopay renewals 2025-10-04 11:52:38 +03:00
Egor 305a0b6b95 Merge pull request #755 from Fr1ngg/revert-754-bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Revert "Fix promo offer validation to use pre-discount totals"
2025-10-04 11:52:02 +03:00
Egor 12a16097cf Revert "Fix promo offer validation to use pre-discount totals" 2025-10-04 11:51:52 +03:00
Egor c8986bea40 Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x
Fix promo offer validation to use pre-discount totals
2025-10-04 11:43:00 +03:00
Egor bcf01ecf6a Fix promo offer validation to use pre-discount totals 2025-10-04 11:42:43 +03:00
Egor a334fbffb7 Merge pull request #753 from Fr1ngg/revert-752-bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Revert "Refactor promo offers to apply stackable discounts"
2025-10-04 11:42:16 +03:00
Egor fc8f55195f Revert "Refactor promo offers to apply stackable discounts" 2025-10-04 11:42:06 +03:00
Egor dbd79cbc65 Merge pull request #752 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-lt6fpc
Refactor promo offers to apply stackable discounts
2025-10-04 11:35:06 +03:00
Egor 436686dd1b Ensure promo offers use stackable discounts 2025-10-04 11:34:46 +03:00
Egor 227e754f4a Merge pull request #751 from Fr1ngg/revert-750-bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Revert "Implement percent-based promo discounts"
2025-10-04 11:34:31 +03:00
Egor 3d43358b86 Revert "Implement percent-based promo discounts" 2025-10-04 11:34:22 +03:00
Egor f05f32b92e Merge pull request #750 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-c11ife
Implement percent-based promo discounts
2025-10-04 11:32:43 +03:00
Egor 7628b9aaa7 Implement percent-based promo discounts 2025-10-04 11:31:55 +03:00
Egor 9ab8f1dee9 Merge pull request #748 from Fr1ngg/revert-747-bedolaga/update-promo-offers-to-apply-percentage-discount
Revert "Implement stackable percent-based promo offers"
2025-10-04 11:31:05 +03:00
Egor 092ea034ec Revert "Implement stackable percent-based promo offers" 2025-10-04 11:30:57 +03:00
Egor c4e5261b31 Merge pull request #747 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount
Implement stackable percent-based promo offers
2025-10-04 11:29:04 +03:00
Egor 2baf2b9abb Adjust promo offer templates for percent discounts 2025-10-04 11:28:47 +03:00
Egor 87a6b05e54 Merge pull request #746 from Fr1ngg/revert-745-bedolaga/update-promo-offers-for-discounts-rwpidj
Revert "Implement percent-based promo offers"
2025-10-04 11:05:56 +03:00
Egor 0925dfdf80 Revert "Implement percent-based promo offers" 2025-10-04 11:05:47 +03:00
Egor cf294fae0a Merge pull request #745 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-rwpidj
Implement percent-based promo offers
2025-10-04 10:58:35 +03:00
Egor 12e77cd6bf Implement percent-based promo offers 2025-10-04 10:57:00 +03:00
Egor 867ae1b807 Merge pull request #744 from Fr1ngg/revert-743-bedolaga/update-promo-offers-for-discounts-9jz4ar
Revert "Clear expired promo discounts when preparing checkout summaries"
2025-10-04 10:55:45 +03:00
Egor db9f91b336 Revert "Clear expired promo discounts when preparing checkout summaries" 2025-10-04 10:55:32 +03:00
Egor df0792d4dd Merge pull request #743 from Fr1ngg/bedolaga/update-promo-offers-for-discounts-9jz4ar
Clear expired promo discounts when preparing checkout summaries
2025-10-04 10:54:35 +03:00
Egor 0e0569fe23 Clear expired promo discounts before checkout 2025-10-04 10:53:09 +03:00
Egor daa37370e3 Merge pull request #741 from Fr1ngg/bedolaga/fix-invalid-values-error-for-active-subscriptions
Fix parsing of promo offer send callbacks
2025-10-04 10:19:36 +03:00
Egor b35c731ba5 Fix promo offer segment parsing 2025-10-04 10:19:05 +03:00
Egor 06879089a1 Merge pull request #740 from Fr1ngg/bedolaga/fix-missing-join-in-promo-segment-query
Fix promo segment join for no subscription users
2025-10-04 10:14:01 +03:00
Egor b96d530808 Fix promo segment no subscription join 2025-10-04 10:13:45 +03:00
Egor 8f3c8f37c1 Merge pull request #739 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot-mgwac3
Add admin promo offer management and targeted promotions
2025-10-04 10:05:54 +03:00
Egor e4c7a9cf15 Add admin promo offers and targeted discount workflows 2025-10-04 10:05:31 +03:00
Egor 935e2356db Merge pull request #738 from Fr1ngg/revert-737-bedolaga/add-promotional-offers-feature-to-bot
Revert "Implement targeted promotional offer campaigns"
2025-10-04 10:04:59 +03:00
Egor 3b392a671d Revert "Implement targeted promotional offer campaigns" 2025-10-04 10:04:49 +03:00
Egor 4ceb09d609 Merge pull request #737 from Fr1ngg/bedolaga/add-promotional-offers-feature-to-bot
Implement targeted promotional offer campaigns
2025-10-04 09:59:41 +03:00
Egor 3f8ea7f8fd Add promotional offer broadcasting support 2025-10-04 09:59:17 +03:00
Egor 95525f2132 Update README.md 2025-10-04 08:03:44 +03:00
Egor d15b46dede Merge pull request #736 from Legacyyy777/DEV
Мулен 3
2025-10-04 07:44:54 +03:00
Egor f2fc7581a3 Update Dockerfile 2025-10-04 07:37:47 +03:00
Egor e96010d24d Update docker-registry.yml 2025-10-04 07:37:32 +03:00
Egor 35fc39b9f2 Update docker-hub.yml 2025-10-04 07:37:13 +03:00
Legacyyy777 7129625ab5 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 09:23:31 +05:00
Legacyyy777 e480356447 Оптимизирована обработка подключения к БД в WebhookServer. Удален цикл async for, добавлена обработка исключений при получении соединения с БД. 2025-10-04 09:21:49 +05:00
Egor 46ea2de300 Merge pull request #735 from Fr1ngg/dev2
Перенос настроек цен и управления серверов + рефактор запуска бота
2025-10-04 06:58:37 +03:00
Egor 2152ded3c4 Merge pull request #734 from Fr1ngg/bedolaga/improve-bot-startup-log-format-ih803q
feat: add structured startup timeline logging
2025-10-04 06:51:36 +03:00
Egor da0e8a2e3e feat: add structured startup timeline logging 2025-10-04 06:51:19 +03:00
Egor da540e8680 Merge pull request #733 from Fr1ngg/revert-732-bedolaga/improve-bot-startup-log-format
Revert "Enhance startup log presentation"
2025-10-04 06:51:07 +03:00
Egor a12ad52c7e Revert "Enhance startup log presentation" 2025-10-04 06:50:59 +03:00
Egor 38b5fa297b Merge pull request #732 from Fr1ngg/bedolaga/improve-bot-startup-log-format
Enhance startup log presentation
2025-10-04 06:49:05 +03:00
Egor d50475974c Improve startup logging aesthetics 2025-10-04 06:48:49 +03:00
Egor ada891f2d9 Merge pull request #731 from Fr1ngg/bedolaga/resolve-missing-localization-keys
Add missing admin pricing locale strings
2025-10-04 06:40:13 +03:00
Egor e7467e5434 Add missing admin pricing translations 2025-10-04 06:38:45 +03:00
Egor fa4a692574 Merge pull request #730 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-y4nuoo
Mutate period price cache in place
2025-10-04 06:31:18 +03:00
Egor 9c60ef2c33 Mutate period price cache in place 2025-10-04 06:30:50 +03:00
Egor 634633d12e Merge pull request #729 from Fr1ngg/revert-728-bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Revert "Fix subscription period price cache update"
2025-10-04 06:30:25 +03:00
Egor b3fd0c50ca Revert "Fix subscription period price cache update" 2025-10-04 06:30:17 +03:00
Egor acb267668e Merge pull request #728 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-ivhvb7
Fix subscription period price cache update
2025-10-04 06:24:55 +03:00
Egor 344cffef60 Fix period price cache refresh 2025-10-04 06:24:38 +03:00
Egor ef652d4518 Merge pull request #727 from Fr1ngg/revert-726-bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Revert "Fix subscription period price updates in admin panel"
2025-10-04 06:23:25 +03:00
Egor a66f9bbf28 Revert "Fix subscription period price updates in admin panel" 2025-10-04 06:23:17 +03:00
Egor 3e5fbcf2bd Merge pull request #726 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel-8wrvf1
Fix subscription period price updates in admin panel
2025-10-04 06:20:12 +03:00
Egor ba2de9e150 Fix period price updates in admin 2025-10-04 06:19:52 +03:00
Egor b638e04629 Merge pull request #725 from Fr1ngg/revert-724-bedolaga/fix-subscription-price-update-in-admin-panel
Revert "Fix subscription price refresh in admin panel"
2025-10-04 06:19:32 +03:00
Egor 45a5f012c2 Revert "Fix subscription price refresh in admin panel" 2025-10-04 06:19:24 +03:00
Egor 1a66094800 Merge pull request #724 from Fr1ngg/bedolaga/fix-subscription-price-update-in-admin-panel
Fix subscription price refresh in admin panel
2025-10-04 06:17:21 +03:00
Egor f7afe481bb Fix subscription price refresh in admin panel 2025-10-04 06:17:04 +03:00
Egor f04bf43962 Merge pull request #723 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-emq0t0
Refine admin pricing menu UX
2025-10-04 06:07:50 +03:00
Egor 3964ed1f3c Improve pricing admin panel UX 2025-10-04 06:07:35 +03:00
Egor 98fa8eef5d Merge pull request #722 from Fr1ngg/revert-721-bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX"
2025-10-04 06:07:18 +03:00
Egor c7b47eef4d Revert "Add trial and tariff configuration controls to admin pricing panel and refine UX" 2025-10-04 06:07:10 +03:00
Egor 9270bb3bf5 Merge pull request #721 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-2opyl2
Add trial and tariff configuration controls to admin pricing panel and refine UX
2025-10-04 06:04:55 +03:00
Egor ad2f6f6f22 Improve pricing admin UX 2025-10-04 06:04:33 +03:00
Egor 62749f9ffe Merge pull request #720 from Fr1ngg/revert-719-bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Revert "Add trial and tariff settings to admin pricing"
2025-10-04 05:58:04 +03:00
Egor e623f9df3a Revert "Add trial and tariff settings to admin pricing" 2025-10-04 05:57:49 +03:00
Egor 2b79de7d82 Merge pull request #719 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel-zipxk2
Add trial and tariff settings to admin pricing
2025-10-04 05:56:26 +03:00
Egor cdcc3b4c4a Add trial and tariff settings to admin pricing 2025-10-04 05:56:07 +03:00
Egor e757569628 Merge pull request #718 from Fr1ngg/revert-717-bedolaga/add-trial-period-management-in-admin-panel
Revert "Add trial and tariff configuration controls to admin pricing panel"
2025-10-04 05:55:34 +03:00
Egor 4f88cdf7db Revert "Add trial and tariff configuration controls to admin pricing panel" 2025-10-04 05:55:26 +03:00
Egor 406c13aea2 Merge pull request #717 from Fr1ngg/bedolaga/add-trial-period-management-in-admin-panel
Add trial and tariff configuration controls to admin pricing panel
2025-10-04 05:51:31 +03:00
Egor c95b1e4fad feat(admin): extend pricing panel settings 2025-10-04 05:51:12 +03:00
Egor 43c480fa33 Merge pull request #716 from Fr1ngg/revert-713-revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Revert "Refactor admin menu for servers and pricing management""
2025-10-04 05:37:11 +03:00
Egor cb25bbb2ed Revert "Revert "Refactor admin menu for servers and pricing management"" 2025-10-04 05:36:59 +03:00
Egor 2602a05fb2 Merge pull request #715 from Fr1ngg/revert-714-bedolaga/reorganize-admin-panel-menu-t36ykc
Revert "Reorganize admin menu and add pricing management"
2025-10-04 05:35:24 +03:00
Egor c31da6c2ae Revert "Reorganize admin menu and add pricing management" 2025-10-04 05:35:06 +03:00
Egor 17d51ab286 Merge pull request #714 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-t36ykc
Reorganize admin menu and add pricing management
2025-10-04 05:33:40 +03:00
Egor e576cb97ec feat: reorganize admin menu with pricing section 2025-10-04 05:33:25 +03:00
Egor 217fed9c9c Merge pull request #713 from Fr1ngg/revert-712-bedolaga/reorganize-admin-panel-menu-mzg1j9
Revert "Refactor admin menu for servers and pricing management"
2025-10-04 05:33:14 +03:00
Egor 030f4efbe1 Revert "Refactor admin menu for servers and pricing management" 2025-10-04 05:33:06 +03:00
Egor 33b3828abc Merge pull request #712 from Fr1ngg/bedolaga/reorganize-admin-panel-menu-mzg1j9
Refactor admin menu for servers and pricing management
2025-10-04 05:30:27 +03:00
Egor f2876fd02c Refactor admin menu for servers and pricing management 2025-10-04 05:30:08 +03:00
Egor ee9ba71f3c Merge pull request #711 from Fr1ngg/revert-710-bedolaga/reorganize-admin-panel-menu
Revert "Reorganize admin panel pricing and server controls"
2025-10-04 05:29:48 +03:00
Egor 6a5d5d960f Revert "Reorganize admin panel pricing and server controls" 2025-10-04 05:29:41 +03:00
Egor 910409cdfb Merge pull request #710 from Fr1ngg/bedolaga/reorganize-admin-panel-menu
Reorganize admin panel pricing and server controls
2025-10-04 05:27:32 +03:00
Egor d78b36b60e Add dedicated pricing management section to admin panel 2025-10-04 05:27:10 +03:00
Egor ca2f83db88 Обновление меню конфигурации бота
Dev2
2025-10-04 04:58:05 +03:00
Egor bf591f55ea Merge pull request #708 from Fr1ngg/bedolaga-hw3yna
feat: enhance admin settings control panel
2025-10-04 04:49:29 +03:00
Egor 99aa09349e feat: enhance admin settings control panel 2025-10-04 04:49:15 +03:00
Egor a391b635ed Merge pull request #707 from Fr1ngg/revert-704-bedolaga-8nay3u
Revert "Complete bot configuration dashboard actions"
2025-10-04 04:48:54 +03:00
Egor 4f1ff34dce Revert "Complete bot configuration dashboard actions" 2025-10-04 04:47:38 +03:00
Egor 9a23a305b5 Merge pull request #706 from Fr1ngg/revert-705-bedolaga/add-alembic-migration-for-system_settings_history-table
Revert "Add migration for system settings history audit table"
2025-10-04 04:47:20 +03:00
Egor 539e7e9066 Revert "Add migration for system settings history audit table" 2025-10-04 04:47:12 +03:00
Egor 6b1173a1b5 Merge pull request #705 from Fr1ngg/bedolaga/add-alembic-migration-for-system_settings_history-table
Add migration for system settings history audit table
2025-10-04 04:39:02 +03:00
Egor 23f8e158ad Add migration for system settings history audit table 2025-10-04 04:38:44 +03:00
Egor 578061be8c Merge pull request #704 from Fr1ngg/bedolaga-8nay3u
Complete bot configuration dashboard actions
2025-10-04 04:24:00 +03:00
Egor 84816b84e7 Complete bot configuration dashboard actions 2025-10-04 04:23:41 +03:00
Egor 0ad2f5bb8a Merge pull request #703 from Fr1ngg/revert-702-bedolaga-1gwn05
Revert "Implement admin bot configuration experience"
2025-10-04 04:23:22 +03:00
Egor e415b4b2ee Revert "Implement admin bot configuration experience" 2025-10-04 04:23:14 +03:00
Egor 7d6ecaaa1b Merge pull request #702 from Fr1ngg/bedolaga-1gwn05
Implement admin bot configuration experience
2025-10-04 04:21:20 +03:00
Egor 588461c53a Implement admin bot configuration experience 2025-10-04 04:21:01 +03:00
Egor 25c23bd653 Merge pull request #701 from Fr1ngg/revert-700-bedolaga-i1q6pf
Revert "feat: enrich bot configuration admin panel"
2025-10-04 04:20:45 +03:00
Egor 1a033de259 Revert "feat: enrich bot configuration admin panel" 2025-10-04 04:20:36 +03:00
Egor 850f8d2b2e Merge pull request #700 from Fr1ngg/bedolaga-i1q6pf
feat: enrich bot configuration admin panel
2025-10-04 04:15:48 +03:00
Egor 39374095eb feat: enrich bot configuration admin panel 2025-10-04 04:15:31 +03:00
Egor f4fe8913fa Merge pull request #699 from Fr1ngg/revert-698-bedolaga/add-subscription-plan-menu-in-admin-panel
Revert "Add admin tariffs menu scaffolding"
2025-10-04 03:48:07 +03:00
Egor 9a5203770b Revert "Add admin tariffs menu scaffolding" 2025-10-04 03:47:57 +03:00
Egor 64b0f5161c Merge pull request #698 from Fr1ngg/bedolaga/add-subscription-plan-menu-in-admin-panel
Add admin tariffs menu scaffolding
2025-10-04 02:40:28 +03:00
Egor 8e12609746 Add admin tariffs menu scaffolding 2025-10-04 02:39:16 +03:00
Egor 703d09c556 Merge pull request #697 from Fr1ngg/dev2
Dev2
2025-10-04 00:41:22 +03:00
Egor 7b238a32b4 Merge pull request #696 from Fr1ngg/main
w
2025-10-04 00:40:34 +03:00
Egor c72fa990fa Merge pull request #694 from Legacyyy777/DEV
Мулен правки 2
2025-10-04 00:39:28 +03:00
Egor f8fc929cbf Merge pull request #695 from Fr1ngg/bedolaga/update-install_bot.sh-for-docker-compose
Add Docker Compose pull update option and display container status
2025-10-04 00:38:58 +03:00
Egor 744aaeb5d6 feat: add docker compose pull update 2025-10-04 00:38:34 +03:00
Legacyyy777 515088ff45 Merge branch 'Fr1ngg:main' into DEV 2025-10-04 02:33:07 +05:00
Legacyyy777 96b64df989 Добавлен return после цикла async for db in get_db().
Добавлена обработка случая, когда не удается получить соединение с БД.
2025-10-04 02:32:04 +05:00
Egor f51c22c67e Merge pull request #693 from Fr1ngg/dev2
Dev2
2025-10-04 00:19:03 +03:00
Egor 010c412268 Merge pull request #682 from Fr1ngg/main
w
2025-10-04 00:14:53 +03:00
Egor 7d204684e1 Update install_bot.sh 2025-10-04 00:11:19 +03:00
Egor 08f47694ee Merge pull request #692 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup-mc8p92
Fix PostgreSQL sequence sync query
2025-10-03 23:55:59 +03:00
Egor e61977a368 Fix PostgreSQL sequence sync query 2025-10-03 23:55:43 +03:00
Egor 72ae83e6ef Merge pull request #691 from Fr1ngg/revert-690-bedolaga/fix-automigration-error-at-bot-startup
Revert "Fix PostgreSQL sequence sync for newer versions"
2025-10-03 23:55:19 +03:00
Egor 80aa0c2443 Revert "Fix PostgreSQL sequence sync for newer versions" 2025-10-03 23:55:09 +03:00
Egor 9e0eb3fd4a Merge pull request #690 from Fr1ngg/bedolaga/fix-automigration-error-at-bot-startup
Fix PostgreSQL sequence sync for newer versions
2025-10-03 23:54:29 +03:00
Egor 6fe4bad240 Fix PostgreSQL sequence sync for newer versions 2025-10-03 23:54:11 +03:00
Egor 859089e91d Merge pull request #689 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration-hr68zi
Handle users sequence desync after backup restore
2025-10-03 23:36:04 +03:00
Egor c4fa25321e Handle users sequence desync after backup restore 2025-10-03 23:35:47 +03:00
Egor 96a6f26b1e Merge pull request #688 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url-w44me1
Handle missing RemnaWave configuration in subscription service
2025-10-03 22:12:59 +03:00
Egor 3f0d125207 Handle missing RemnaWave configuration in subscription service 2025-10-03 22:12:43 +03:00
Egor cb5821fffc Merge pull request #687 from Fr1ngg/revert-686-bedolaga/fix-attributeerror-for-base_url
Revert "Handle missing RemnaWave API configuration gracefully"
2025-10-03 22:12:13 +03:00
Egor f79420a9b0 Revert "Handle missing RemnaWave API configuration gracefully" 2025-10-03 22:11:51 +03:00
Egor a7b4dc985f Merge pull request #686 from Fr1ngg/bedolaga/fix-attributeerror-for-base_url
Handle missing RemnaWave API configuration gracefully
2025-10-03 22:10:50 +03:00
Egor 38562744ab Handle missing RemnaWave API configuration gracefully 2025-10-03 22:09:29 +03:00
Egor 3d413ced59 Merge pull request #685 from Fr1ngg/revert-684-bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Revert "Fix admin top-up notifications failing after webhook"
2025-10-03 22:01:44 +03:00
Egor 0002ce0a9a Revert "Fix admin top-up notifications failing after webhook" 2025-10-03 22:01:35 +03:00
Egor 140d3580e9 Merge pull request #684 from Fr1ngg/bedolaga/fix-notification-error-for-payment-updates-q9qbo5
Fix admin top-up notifications failing after webhook
2025-10-03 21:57:26 +03:00
Egor cf5cb406d0 Fix admin top-up notifications failing after webhook 2025-10-03 21:57:07 +03:00
Egor 8c3f3d56c8 Merge pull request #681 from Legacyyy777/DEV
Правим мулен
2025-10-03 21:43:47 +03:00
Egor d33831dfe4 Delete .github/workflows/deploy.yml 2025-10-03 21:43:30 +03:00
Legacyyy777 b68f1256ca Добавлено логирование заголовков вебхука Mulen Pay для отладки и временно отключена проверка подписи. Обновлены сообщения логирования для улучшения диагностики. 2025-10-03 10:28:02 +05:00
Legacyyy777 3d3b019e60 Merge branch 'Fr1ngg:main' into DEV 2025-10-03 10:12:31 +05:00
Egor f1a801d693 Merge pull request #680 from Fr1ngg/dev2
Dev2
2025-10-03 00:35:27 +03:00
Egor de33e15ae0 Merge pull request #679 from Fr1ngg/bedolaga/fix-duplicate-key-error-in-user-registration
Sync PostgreSQL sequences after restore
2025-10-03 00:29:39 +03:00
Egor abd87c2a48 Sync PostgreSQL sequences after restore 2025-10-03 00:29:11 +03:00
Egor f691e91560 Merge pull request #678 from Fr1ngg/main
w
2025-10-03 00:23:44 +03:00
Egor 7dafb0c29f Update README.md 2025-10-02 23:56:23 +03:00
Egor 5037d92e36 Update index.html 2025-10-02 23:15:53 +03:00
Egor d765e08017 Merge pull request #676 from Fr1ngg/bedolaga/document-manual-reverse-proxy-setup-for-caddy-and-nginx
docs: mount miniapp assets in caddy
2025-10-02 19:57:17 +03:00
Egor 4958613ad9 Update README.md 2025-10-02 19:56:59 +03:00
Egor 489667c847 Update README.md 2025-10-02 19:52:55 +03:00
Egor ccedfad51f docs: mount miniapp assets in caddy 2025-10-02 19:50:46 +03:00
Egor 841ac5cdb3 Update README.md 2025-10-02 19:13:40 +03:00
Egor a8b6202fe9 Merge pull request #675 from Fr1ngg/bedolaga/update-readme-for-install_bot.sh
docs: document installer usage
2025-10-02 19:12:02 +03:00
Egor 4708c67dcc docs: document installer usage 2025-10-02 19:11:34 +03:00
Egor f705b508a1 Merge pull request #674 from Fr1ngg/dev2
Dev2
2025-10-02 17:35:18 +03:00
Egor 6030fb08ec Update install_bot.sh 2025-10-02 17:32:17 +03:00
Egor a5b152e73a Merge pull request #673 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy-dzg7sf
Refactor install script for Caddy-only proxy support
2025-10-02 03:49:05 +03:00
Egor 087a57fc11 Refactor installer to focus on Caddy proxy 2025-10-02 03:48:49 +03:00
Egor 95e44f99a2 Merge pull request #672 from Fr1ngg/revert-671-bedolaga/remove-nginx-support-and-connect-to-caddy
Revert "Refine installer Caddy integration"
2025-10-02 03:48:12 +03:00
Egor b8bb2acd64 Revert "Refine installer Caddy integration" 2025-10-02 03:48:03 +03:00
Egor eb3e806e0f Merge pull request #671 from Fr1ngg/bedolaga/remove-nginx-support-and-connect-to-caddy
Refine installer Caddy integration
2025-10-02 03:44:40 +03:00
Egor 33911aca93 Update installer to rely solely on Caddy 2025-10-02 03:44:22 +03:00
Egor 63c6e64cbe Merge pull request #655 from Fr1ngg/bedolaga/add-subscription-link-redirect-for-desktop-289875
Add desktop redirect fallback for Happ subscription links
2025-10-02 03:40:49 +03:00
Egor 358c8aed3b Merge branch 'main' into bedolaga/add-subscription-link-redirect-for-desktop-289875 2025-10-02 03:40:18 +03:00
Egor 4ac994454b Merge pull request #670 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-bnh3fm
Allow optional RemnaWave settings for initial startup
2025-10-02 03:14:14 +03:00
Egor 5d6f080663 Allow missing RemnaWave settings on first start 2025-10-02 03:13:31 +03:00
Egor 33f0e3b9ba Merge pull request #669 from Fr1ngg/revert-668-bedolaga/fix-bot-startup-validation-errors
Revert "Allow optional settings to initialize without env overrides"
2025-10-02 03:13:01 +03:00
Egor ef809de9a2 Revert "Allow optional settings to initialize without env overrides" 2025-10-02 03:12:50 +03:00
Egor 628c53513c Merge pull request #668 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Allow optional settings to initialize without env overrides
2025-10-02 02:10:50 +03:00
Egor 876362cd80 Make optional env settings optional 2025-10-02 02:10:22 +03:00
Egor 1a9efd65a1 Update install_bot.sh 2025-10-02 02:02:21 +03:00
Egor 121918c33a Merge pull request #667 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh-xyoa0f
Improve install bot state persistence and path resolution
2025-10-02 00:45:11 +03:00
Egor b46c3bd142 Improve install state handling in install_bot 2025-10-02 00:44:53 +03:00
Egor 566b563626 Merge pull request #666 from Fr1ngg/revert-665-bedolaga/remove-installation-check-in-install_bot.sh
Revert "Handle missing installation state in install_bot.sh"
2025-10-02 00:44:16 +03:00
Egor 397af1cc86 Revert "Handle missing installation state in install_bot.sh" 2025-10-02 00:44:07 +03:00
Egor bdfa2a3039 Merge pull request #665 from Fr1ngg/bedolaga/remove-installation-check-in-install_bot.sh
Handle missing installation state in install_bot.sh
2025-10-02 00:32:31 +03:00
Egor 38001410b8 Handle missing bot install state 2025-10-02 00:31:54 +03:00
Egor c2693fc46a Merge pull request #663 from Fr1ngg/bedolaga/fix-install_bot.sh-to-append-domains
Preserve existing Caddyfile entries when updating webhooks
2025-10-01 23:31:08 +03:00
Egor 9db84af7a9 Preserve existing Caddyfile entries when updating webhooks 2025-10-01 23:25:16 +03:00
Egor 0172138084 Merge pull request #662 from Fr1ngg/bedolaga/fix-script-not-removing-created-network
Add docker network cleanup option to proxy menu
2025-10-01 23:20:05 +03:00
Egor 0e913ec78b Add docker network cleanup option to proxy menu 2025-10-01 23:11:06 +03:00
Egor 6a5d0181b0 Update install_bot.sh 2025-10-01 23:07:05 +03:00
Egor 7684ed382e Update install_bot.sh 2025-10-01 21:55:46 +03:00
Egor 0d47acf044 Merge pull request #661 from Fr1ngg/bedolaga/create-auto-installer-for-bot
Add interactive installation script for the bot
2025-10-01 21:47:15 +03:00
Egor 2d09b10f36 Add interactive installation and monitoring script 2025-10-01 21:46:58 +03:00
Egor 77c99ad33e Merge pull request #660 from Fr1ngg/revert-659-bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Revert "Add automated installer and monitoring script"
2025-10-01 21:33:19 +03:00
Egor a41531944c Revert "Add automated installer and monitoring script" 2025-10-01 21:33:11 +03:00
Egor 9b2d9406d3 Merge pull request #659 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring-ucg6rt
Add automated installer and monitoring script
2025-10-01 21:25:42 +03:00
Egor 1776a5b424 Add automated installer and monitoring script 2025-10-01 21:24:00 +03:00
Egor 419b921487 Merge pull request #658 from Fr1ngg/revert-657-bedolaga/create-auto-installer-for-bot-monitoring
Revert "Add automated installation and monitoring script"
2025-10-01 21:23:20 +03:00
Egor c1f7404912 Revert "Add automated installation and monitoring script" 2025-10-01 21:23:11 +03:00
Egor 1c07f40d43 Merge pull request #657 from Fr1ngg/bedolaga/create-auto-installer-for-bot-monitoring
Add automated installation and monitoring script
2025-10-01 21:16:43 +03:00
Egor d2e6a42eb8 Add automated installer and monitoring script 2025-10-01 21:16:26 +03:00
Egor 5a7960bb14 Merge pull request #656 from Fr1ngg/main
w
2025-10-01 21:03:53 +03:00
Egor 27d48f68b4 Update index.html 2025-10-01 20:27:54 +03:00
Egor 9465c6cba3 Create index.html 2025-10-01 20:25:50 +03:00
Egor 3088be1dde Add desktop redirect fallback for Happ subscription links 2025-10-01 19:46:15 +03:00
Egor 845a8175fa Merge pull request #652 from Fr1ngg/revert-651-bedolaga/d
Revert "Fix desktop handling of happ subscription links"
2025-10-01 19:34:46 +03:00
Egor af27fb570c Revert "Fix desktop handling of happ subscription links" 2025-10-01 19:34:38 +03:00
Egor 181bb137fb Merge pull request #651 from Fr1ngg/bedolaga/d
Fix desktop handling of happ subscription links
2025-10-01 19:29:54 +03:00
Egor 220646c7ac Fix desktop handling of happ subscription links 2025-10-01 19:29:30 +03:00
Egor 4513deb2a1 Add files via upload 2025-10-01 07:11:06 +03:00
Egor e1001156b4 Update Dockerfile 2025-10-01 07:05:17 +03:00
Egor ba2c534622 Update Dockerfile 2025-10-01 07:05:02 +03:00
Egor 72f06a784e Update docker-registry.yml 2025-10-01 07:04:44 +03:00
Egor 04e862b6bd Update docker-hub.yml 2025-10-01 07:04:31 +03:00
Egor 7f4af89d60 Update miniapp-setup.md 2025-10-01 06:43:37 +03:00
Egor f68bb699eb Update index.html 2025-10-01 06:32:56 +03:00
Egor c1d19e2ade Merge pull request #648 from Fr1ngg/dev2
Миниапка с подпиской
2025-10-01 06:14:01 +03:00
Egor e4c7e8b27f Merge pull request #647 from Fr1ngg/bedolaga/localize-subscription-button-to-english-w0zeno
Adjust purchase button localization and navigation
2025-10-01 06:10:51 +03:00
Egor c63c483b9b Adjust purchase button localization and navigation 2025-10-01 06:10:37 +03:00
Egor 4b04696feb Merge pull request #646 from Fr1ngg/revert-645-bedolaga/localize-subscription-button-to-english
Revert "Adjust miniapp purchase button behavior"
2025-10-01 06:10:09 +03:00
Egor 9ffbba7259 Revert "Adjust miniapp purchase button behavior" 2025-10-01 06:10:02 +03:00
Egor cc0b927388 Merge pull request #645 from Fr1ngg/bedolaga/localize-subscription-button-to-english
Adjust miniapp purchase button behavior
2025-10-01 06:08:34 +03:00
Egor cbcf2d55da Adjust miniapp purchase button behavior 2025-10-01 06:08:18 +03:00
Egor f7c70f60e1 Merge pull request #644 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp-izs6w9
Add configurable purchase link button to mini app error state
2025-10-01 06:01:30 +03:00
Egor 7c96b0f71c Add purchase link support to mini app error state 2025-10-01 06:01:12 +03:00
Egor 2002032be3 Merge pull request #643 from Fr1ngg/revert-642-bedolaga/add-subscription-purchase-button-in-miniapp
Revert "Add configurable purchase link to the mini app"
2025-10-01 06:00:40 +03:00
Egor ed6ff293ea Revert "Add configurable purchase link to the mini app" 2025-10-01 06:00:29 +03:00
Egor 65e66353b2 Merge pull request #642 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp
Add configurable purchase link to the mini app
2025-10-01 05:57:08 +03:00
Egor 4fa87804d9 Add miniapp purchase link support 2025-10-01 05:56:51 +03:00
Egor 17587a9b30 Update miniapp-setup.md 2025-10-01 05:53:53 +03:00
Egor 37c552c705 Merge pull request #641 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding-etzom5
Open Happ CryptoLink subscriptions externally
2025-10-01 05:42:46 +03:00
Egor 95c6e72f1b fix: open happ cryptolink externally 2025-10-01 05:42:32 +03:00
Egor 4fa49e6f2e Merge pull request #640 from Fr1ngg/revert-639-bedolaga/remove-happ_cryptolink_redirect_template-binding
Revert "Open HAPP cryptolink from miniapp externally"
2025-10-01 05:41:48 +03:00
Egor 3aaa3d0671 Revert "Open HAPP cryptolink from miniapp externally" 2025-10-01 05:41:40 +03:00
Egor 9c013f19c4 Merge pull request #639 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding
Open HAPP cryptolink from miniapp externally
2025-10-01 05:39:33 +03:00
Egor c7b6929dec Open HAPP cryptolink in external browser 2025-10-01 05:39:17 +03:00
Egor 4dca932ec2 Merge pull request #638 from Fr1ngg/revert-637-bedolaga/fix-hap_cryptolink-redirect-link
Revert "Fix miniapp connect button to open Happ CryptoLink directly"
2025-10-01 05:36:38 +03:00
Egor 997fcf8ec7 Revert "Fix miniapp connect button to open Happ CryptoLink directly" 2025-10-01 05:35:43 +03:00
Egor eb8c8ec4ce Merge pull request #637 from Fr1ngg/bedolaga/fix-hap_cryptolink-redirect-link
Fix miniapp connect button to open Happ CryptoLink directly
2025-10-01 05:34:04 +03:00
Egor f4ed61c4ca Adjust miniapp connect link handling 2025-10-01 05:33:30 +03:00
Egor 70d3434952 Merge pull request #636 from Fr1ngg/revert-635-revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"""
2025-10-01 05:27:04 +03:00
Egor 450d75227b Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""" 2025-10-01 05:26:48 +03:00
Egor 6e61980fa9 Merge pull request #635 from Fr1ngg/revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""
2025-10-01 05:25:02 +03:00
Egor 777e243bcd Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"" 2025-10-01 05:24:52 +03:00
Egor 46d9c25968 Merge pull request #634 from Fr1ngg/revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Add configurable purchase link and direct Happ flow in miniapp"
2025-10-01 05:24:39 +03:00
Egor e26223a7d8 Revert "Add configurable purchase link and direct Happ flow in miniapp" 2025-10-01 05:24:28 +03:00
Egor 2098165229 Delete miniapp/index2.html 2025-10-01 05:19:00 +03:00
Egor c7e9d84585 Merge pull request #633 from Fr1ngg/bedolaga/remove-redirect-to-happ_cryptolink
Add configurable purchase link and direct Happ flow in miniapp
2025-10-01 05:17:29 +03:00
Egor 8a92d5e5a9 Add purchase link support and adjust miniapp connect flow 2025-10-01 05:17:12 +03:00
Egor 8b8f187cb3 Update index.html 2025-10-01 04:42:40 +03:00
Egor 02e3faf194 Create index2.html 2025-10-01 04:25:14 +03:00
Egor 7e8e05a0fc Merge pull request #631 from Fr1ngg/bedolaga/add-service-name-configuration-option-d3ekaj
Add configurable miniapp branding
2025-10-01 04:03:32 +03:00
Egor c3ca12a8a5 Update config.py 2025-10-01 04:03:14 +03:00
Egor d82b24927b Update README.md 2025-10-01 04:02:53 +03:00
Egor 7d9d05b1e8 Update .env.example 2025-10-01 04:02:33 +03:00
Egor cc96919672 Add configurable miniapp branding 2025-10-01 04:01:47 +03:00
Egor 1f0f5a4317 Merge pull request #630 from Fr1ngg/revert-629-bedolaga/add-service-name-configuration-option
Revert "feat: allow configuring miniapp branding"
2025-10-01 04:01:31 +03:00
Egor cd9218ec09 Revert "feat: allow configuring miniapp branding" 2025-10-01 04:01:22 +03:00
Egor 7256a0d1ab Merge pull request #629 from Fr1ngg/bedolaga/add-service-name-configuration-option
feat: allow configuring miniapp branding
2025-10-01 04:01:08 +03:00
Egor e2f5191844 Update config.py 2025-10-01 04:00:48 +03:00
Egor d964a2d53c Update README.md 2025-10-01 04:00:18 +03:00
Egor 7ace06834b Update .env.example 2025-10-01 03:59:49 +03:00
Egor 0a789d3bd1 feat: allow configuring miniapp branding 2025-10-01 03:58:34 +03:00
Egor 9c44c3ebd0 Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr
Enhance miniapp subscription details
2025-10-01 03:46:09 +03:00
Egor 67650666fc Enhance miniapp subscription details 2025-10-01 03:45:51 +03:00
Egor b5e343cfb0 Merge pull request #627 from Fr1ngg/revert-626-bedolaga/update-miniapp-index.html-template
Revert "Improve miniapp subscription metadata display"
2025-10-01 03:45:36 +03:00
Egor 68f72a88a4 Revert "Improve miniapp subscription metadata display" 2025-10-01 03:45:28 +03:00
Egor a225540c48 Merge pull request #626 from Fr1ngg/bedolaga/update-miniapp-index.html-template
Improve miniapp subscription metadata display
2025-10-01 03:44:30 +03:00
Egor 3fdc1fdeda Enhance miniapp subscription details 2025-10-01 03:44:05 +03:00
Egor 0748e9eed3 Merge pull request #625 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-waqu1h
Add localization and account data to miniapp
2025-10-01 03:17:27 +03:00
Egor f420e07a24 Add localization and account data to miniapp 2025-10-01 03:17:09 +03:00
Egor 885a56ecf4 Merge pull request #624 from Fr1ngg/revert-623-bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Revert "Enhance miniapp page with localization and account data"
2025-10-01 03:16:47 +03:00
Egor 4e4e80f3c4 Revert "Enhance miniapp page with localization and account data" 2025-10-01 03:16:38 +03:00
Egor 4942ac4c5e Merge pull request #623 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Enhance miniapp page with localization and account data
2025-10-01 03:14:58 +03:00
Egor 20f83ac751 feat: enhance miniapp UI with localization and account data 2025-10-01 03:14:40 +03:00
Egor 6b2b20cca2 Merge pull request #622 from Fr1ngg/revert-621-bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Revert "Add localization, balance, and server info to miniapp"
2025-10-01 03:13:44 +03:00
Egor 9db392b410 Revert "Add localization, balance, and server info to miniapp" 2025-10-01 03:13:34 +03:00
Egor 444e87d70e Merge pull request #621 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Add localization, balance, and server info to miniapp
2025-10-01 03:11:27 +03:00
Egor d8a6b6b08c Enhance miniapp with localization and account data 2025-10-01 03:11:09 +03:00
Egor 2658c298f0 Merge pull request #620 from Fr1ngg/revert-619-bedolaga/add-language-switch-and-subscription-redirect
Revert "Add localization and account data to mini app"
2025-10-01 03:10:44 +03:00
Egor 0ca4f9630a Revert "Add localization and account data to mini app" 2025-10-01 03:10:35 +03:00
Egor 718c9aba92 Merge pull request #619 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect
Add localization and account data to mini app
2025-10-01 03:05:52 +03:00
Egor 078f1f8878 Add localization and account info to mini app 2025-10-01 03:05:34 +03:00
Egor c76eeea020 Merge pull request #617 from Fr1ngg/bedolaga/integrate-miniapp-page-with-bot-api
Document miniapp deployment and reverse proxy setup
2025-10-01 02:32:57 +03:00
Egor f919368d0b Document miniapp deployment and reverse proxy setup 2025-10-01 02:32:36 +03:00
Egor ccb442fe44 Create index.html 2025-10-01 02:10:06 +03:00
Egor d8c0dd862b Merge pull request #616 from Fr1ngg/dev2
W
2025-10-01 02:08:39 +03:00
Egor 1ed418b176 Delete miniapp/index.html 2025-10-01 02:08:17 +03:00
Egor 59efd837cd Merge pull request #615 from Fr1ngg/revert-614-bedolaga/add-third-purchase-mode-for-subscriptions
Revert "Add tariff-based subscription mode"
2025-10-01 02:06:32 +03:00
Egor 60d23bedde Revert "Add tariff-based subscription mode" 2025-10-01 02:06:18 +03:00
Egor d08f74179f Merge pull request #614 from Fr1ngg/bedolaga/add-third-purchase-mode-for-subscriptions
Add tariff-based subscription mode
2025-10-01 02:05:00 +03:00
Egor 70ad084bcd Add tariff-aware subscription API handling 2025-10-01 02:04:20 +03:00
Egor 818ef1ccb0 Create index.html 2025-10-01 02:03:30 +03:00
Egor f6369f7b44 Merge pull request #613 from Fr1ngg/bedolaga/add-english-locale-support-14ehhu
Add English localization for subscription management flows
2025-10-01 01:23:19 +03:00
Egor 5f8295a588 Add English localization for subscription settings flows 2025-10-01 01:22:49 +03:00
Egor 5c51788dc3 Merge pull request #612 from Fr1ngg/revert-611-bedolaga/add-english-locale-support
Revert "Add English localization for subscription management interfaces"
2025-10-01 01:22:36 +03:00
Egor 24e6d57273 Revert "Add English localization for subscription management interfaces" 2025-10-01 01:22:28 +03:00
Egor be95d34122 Merge pull request #611 from Fr1ngg/bedolaga/add-english-locale-support
Add English localization for subscription management interfaces
2025-10-01 01:18:14 +03:00
Egor 359cdd106d Add English localization for subscription management menus 2025-10-01 01:17:43 +03:00
Egor 0bc5b10a78 Merge pull request #610 from Fr1ngg/bedolaga/add-english-localization-for-user-management
Add English localization for admin user management view
2025-10-01 01:04:24 +03:00
Egor efd4eda62a Add English localization for admin user view 2025-10-01 01:04:05 +03:00
Egor d4c169e6bb Merge pull request #609 from Fr1ngg/bedolaga/add-localization-to-admin-panel-submenu
Add English localization for admin support menus
2025-10-01 00:08:24 +03:00
Egor c12f3d443c Add English localization for admin support menus 2025-10-01 00:08:03 +03:00
Egor db7ecea006 Merge pull request #608 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue-xgkdfd
Handle additional MulenPay webhook signature formats
2025-09-30 23:57:35 +03:00
Egor 2b4136faa7 Handle additional MulenPay webhook signature formats 2025-09-30 23:57:18 +03:00
Egor ca09ce8cf4 Merge pull request #607 from Fr1ngg/revert-606-bedolaga/fix-payment-callback-signature-issue
Revert "Improve MulenPay webhook signature handling"
2025-09-30 23:57:02 +03:00
Egor 533087b333 Revert "Improve MulenPay webhook signature handling" 2025-09-30 23:56:54 +03:00
Egor 80afcbb737 Merge pull request #606 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue
Improve MulenPay webhook signature handling
2025-09-30 23:51:44 +03:00
Egor 845faf037f Improve MulenPay webhook signature handling 2025-09-30 23:51:29 +03:00
Egor 1f160ced02 Merge pull request #605 from Fr1ngg/bedolaga/add-english-translations-for-admin-panel
Add missing admin panel locale translations
2025-09-30 23:47:25 +03:00
Egor 07e4c07d6e Add admin panel translations for ru/en locales 2025-09-30 23:47:06 +03:00
Egor 78ab84b021 Merge pull request #604 from Fr1ngg/bedolaga/add-card-payment-option-to-paypalych
Support PayPalych card payments with configurable button labels
2025-09-30 23:42:36 +03:00
Egor db5551df23 Add customizable PayPalych payment buttons 2025-09-30 23:42:19 +03:00
Egor ee75395c31 Merge pull request #603 from Fr1ngg/bedolaga/add-missing-translations-for-admin-panel
Add missing admin translations for Russian and English locales
2025-09-30 22:16:22 +03:00
Egor 453bb75b7f Add missing admin translations for Russian and English 2025-09-30 22:15:57 +03:00
Egor 88d2c8325d Merge pull request #602 from Fr1ngg/bedolaga/add-notifications-for-unsubscribed-users
Add monitoring notification for trial users leaving the channel
2025-09-30 21:45:13 +03:00
Egor 8fe9a3dbe2 feat: notify trial users when leaving channel 2025-09-30 21:44:49 +03:00
Egor cf6c282b25 Merge pull request #600 from PEDZEO/main
fix /start moderator
2025-09-30 21:12:03 +03:00
Egor 773e525a6a Merge pull request #599 from SantaSpeen/main
[+] Adding support stock app-config.json
2025-09-30 21:08:44 +03:00
Egor a7d35d51b8 Merge pull request #601 from Fr1ngg/revert-596-bedolaga/fix-all-merge-conflicts
Revert "Resolve duplicate handlers and cleanup imports"
2025-09-30 21:07:46 +03:00
Egor 46eb93dabe Revert "Resolve duplicate handlers and cleanup imports" 2025-09-30 21:07:37 +03:00
Egor 43e63f864e Merge pull request #594 from Gy9vin/main
Расширенный фильтр пользователей
2025-09-30 20:56:45 +03:00
PEDZEO 8774b53570 fix /start moderator 2025-09-30 18:45:44 +03:00
SantaSpeen 2aaec2ca58 Merge pull request #1 from SantaSpeen/slave
Merge into main
2025-09-30 17:20:56 +03:00
SantaSpeen 5efe36878d [~] Update upstream 2025-09-30 17:20:08 +03:00
SantaSpeen b9861249b7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/handlers/subscription.py
2025-09-30 17:11:32 +03:00
Egor 2c9b585f4b Merge pull request #597 from Fr1ngg/dev2
fix
2025-09-30 16:51:54 +03:00
Egor dd5b36ff4a Merge pull request #596 from Fr1ngg/bedolaga/fix-all-merge-conflicts
Resolve duplicate handlers and cleanup imports
2025-09-30 16:41:09 +03:00
Egor 144a942a97 Resolve duplicate handlers and cleanup imports 2025-09-30 16:40:08 +03:00
SantaSpeen ff661d84ee [+] Update app-config.json: new get_apps_for_device 2025-09-30 14:46:55 +03:00
SantaSpeen ab1b8619f8 [+] Update get_apps_for_device: Allow app-config.json just from panel 2025-09-30 14:26:22 +03:00
gy9vin 0f621bfe5a Revert "Оживление виджета "Детальная статистика пользователей""
This reverts commit 400cc5a32e.
2025-09-30 12:42:17 +03:00
gy9vin c344f418c5 Расширение фильтров 2025-09-30 12:39:15 +03:00
gy9vin 400cc5a32e Оживление виджета "Детальная статистика пользователей" 2025-09-30 11:55:04 +03:00
Egor b6a383b5d8 Merge pull request #593 from Fr1ngg/dev2
Логи в боте
2025-09-30 04:37:25 +03:00
Egor a8215ddec5 Update system_logs.py 2025-09-30 04:34:09 +03:00
Egor 954c96ed16 Merge pull request #592 from Fr1ngg/bedolaga/add-log-section-to-admin-panel
Add system logs view to admin panel
2025-09-30 04:30:47 +03:00
Egor ed9a3613f5 Add system logs view to admin panel 2025-09-30 04:30:30 +03:00
Egor 29743351de Delete app/webapi/vpn-admin-dashboard.html 2025-09-30 03:52:57 +03:00
Egor fd897464f5 Update Dockerfile 2025-09-30 03:52:36 +03:00
Egor d9b0420202 Update docker-registry.yml 2025-09-30 03:52:24 +03:00
Egor 527d907035 Update docker-hub.yml 2025-09-30 03:52:11 +03:00
Egor 6c3270e6d7 Merge pull request #590 from Fr1ngg/dev2
Dev to main
2025-09-30 03:36:11 +03:00
Egor b7382eb7c3 Merge pull request #589 from Fr1ngg/bedolaga/update-bot-configuration-menu
Update admin bot configuration menu grouping
2025-09-30 03:32:04 +03:00
Egor 0cd7d45338 Refine bot configuration menu structure 2025-09-30 03:31:37 +03:00
Egor 23dcb9699c Merge pull request #588 from Fr1ngg/bedolaga/fix-duplicate-cryptolink-display
Remove duplicate Happ CryptoLink link display
2025-09-30 03:17:49 +03:00
Egor 9603952b2a Adjust Happ CryptoLink message formatting 2025-09-30 03:17:17 +03:00
Egor 299eeee9d3 Merge pull request #587 from Fr1ngg/bedolaga-xjizv4
Convert Happ cryptolink message to happ scheme
2025-09-30 03:11:48 +03:00
Egor 63c7e9c08b Adjust Happ cryptolink message formatting 2025-09-30 03:11:28 +03:00
Egor 42131457bf Merge pull request #586 from Fr1ngg/revert-585-bedolaga-uswi1l
Revert "feat: update Happ cryptolink subscription link handling"
2025-09-30 03:11:17 +03:00
Egor a7370fbe52 Revert "feat: update Happ cryptolink subscription link handling" 2025-09-30 03:11:08 +03:00
Egor 41f0a15cea Merge pull request #585 from Fr1ngg/bedolaga-uswi1l
feat: update Happ cryptolink subscription link handling
2025-09-30 03:08:59 +03:00
Egor dd92f37fc1 feat: adjust Happ cryptolink presentation 2025-09-30 03:08:42 +03:00
Egor 46d4d699dd Merge pull request #584 from Fr1ngg/revert-583-bedolaga-bsbzht
Revert "Update Happ CryptoLink message formatting"
2025-09-30 03:07:37 +03:00
Egor 76b594363b Revert "Update Happ CryptoLink message formatting" 2025-09-30 03:07:28 +03:00
Egor 107b18d642 Merge pull request #583 from Fr1ngg/bedolaga-bsbzht
Update Happ CryptoLink message formatting
2025-09-30 03:05:01 +03:00
Egor 9973491503 Update Happ CryptoLink message formatting 2025-09-30 03:04:26 +03:00
Egor 418081048e Merge pull request #582 from Fr1ngg/bedolaga/add-language-selection-toggle-in-bot-config
feat: allow disabling language selection
2025-09-30 02:51:53 +03:00
Egor f22838b99f feat: make language selection optional 2025-09-30 02:51:31 +03:00
Egor 9bdacea568 Update .env.example 2025-09-30 02:50:26 +03:00
Egor 6bec0ed0c8 Merge pull request #581 from Fr1ngg/bedolaga/add-language-selection-at-start
Add language selection workflow and menu option
2025-09-30 02:38:29 +03:00
Egor 4363955443 Add language selection workflow and menu option 2025-09-30 02:37:57 +03:00
Egor 47a272c7e8 Merge pull request #580 from Fr1ngg/bedolaga/fix-subscription-renewal-error
Prevent async session errors when extending subscriptions
2025-09-30 02:17:24 +03:00
Egor 07ce852917 Fix subscription extension notifications and bot init 2025-09-30 02:17:06 +03:00
Egor a6ada5c9e3 Merge pull request #579 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups-zboyk5
feat: expand admin promo notifications
2025-09-30 02:06:47 +03:00
Egor 90c5b1d003 feat: expand admin notifications for promo events 2025-09-30 02:06:31 +03:00
Egor 326aca31d7 Merge pull request #578 from Fr1ngg/revert-577-bedolaga/add-admin-notifications-for-promo-groups
Revert "Enhance admin notifications with promo group context"
2025-09-30 02:06:20 +03:00
Egor 4fd5ba863b Revert "Enhance admin notifications with promo group context" 2025-09-30 02:06:11 +03:00
Egor 4bd0b221e7 Merge pull request #577 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups
Enhance admin notifications with promo group context
2025-09-30 01:57:14 +03:00
Egor f47511f26a Enhance admin notifications with promo group context 2025-09-30 01:56:52 +03:00
Egor 2d409eaa1f Merge pull request #576 from Fr1ngg/bedolaga/update-subscription-status-to-icons
Move subscription status icons into server user buttons
2025-09-30 01:41:16 +03:00
Egor 9d42c4fb7d Adjust server user list status icons 2025-09-30 01:41:03 +03:00
Egor d3ced8f7e2 Merge pull request #575 from Fr1ngg/bedolaga/add-pagination-to-users-list-page
Add pagination to admin server users list
2025-09-30 01:34:24 +03:00
Egor a5715b2ca8 Add pagination to server users admin view 2025-09-30 01:34:07 +03:00
Egor c18dd604d8 Merge pull request #574 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-g9vyec
Fix server user list to account for JSON-based connections
2025-09-30 01:28:59 +03:00
Egor c2656f4ee6 Fix server users list by including JSON connections 2025-09-30 01:28:43 +03:00
Egor 18641d46d3 Merge pull request #573 from Fr1ngg/revert-572-bedolaga/fix-user-count-display-on-server-list-e72yq9
Revert "Fix server user list counts"
2025-09-30 01:28:28 +03:00
Egor 7ef3a08215 Revert "Fix server user list counts" 2025-09-30 01:28:20 +03:00
Egor 55f1a3b220 Merge pull request #572 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-e72yq9
Fix server user list counts
2025-09-30 01:26:51 +03:00
Egor e2f313e6ea Fix server user list counts 2025-09-30 01:26:37 +03:00
Egor 5f99478433 Merge pull request #571 from Fr1ngg/revert-570-bedolaga/fix-user-count-display-on-server-list-gt974l
Revert "Fix server user list to include JSON connections"
2025-09-30 01:26:12 +03:00
Egor 89f0a3663d Revert "Fix server user list to include JSON connections" 2025-09-30 01:26:03 +03:00
Egor e53dab87e3 Merge pull request #570 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-gt974l
Fix server user list to include JSON connections
2025-09-30 01:24:22 +03:00
Egor 6d85c179bb Fix server user list to include JSON connections 2025-09-30 01:24:05 +03:00
Egor 30dafcef49 Merge pull request #569 from Fr1ngg/revert-568-bedolaga/fix-user-count-display-on-server-list
Revert "Fix admin server user list query"
2025-09-30 01:23:47 +03:00
Egor 32d7a7394e Revert "Fix admin server user list query" 2025-09-30 01:23:36 +03:00
Egor 3cb4f27433 Merge pull request #568 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list
Fix admin server user list query
2025-09-30 01:22:06 +03:00
Egor 50692566d5 Fix server user list to use connected squads 2025-09-30 01:21:49 +03:00
Egor fbb2afd9e1 Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
2025-09-30 01:03:15 +03:00
Egor 3de951b153 Enhance backup coverage and configuration snapshots 2025-09-30 01:02:57 +03:00
Egor 1b86d467b5 Merge pull request #566 from Fr1ngg/bedolaga/investigate-subscription-cancellation-bug
Convert extended trials to paid subscriptions
2025-09-30 00:51:15 +03:00
Egor e4743ced9b Convert extended trials to paid subscriptions 2025-09-30 00:50:57 +03:00
Egor 2351056d56 Merge pull request #565 from Fr1ngg/bedolaga/fix-subscription-promotion-allocation
Fix total spent calculation for promo groups
2025-09-30 00:43:06 +03:00
Egor 748f9a2393 Fix total spent calculation for promo groups 2025-09-30 00:42:46 +03:00
Egor ada15065ea Merge pull request #564 from Fr1ngg/bedolaga/fix-user-statistics-in-admin-panel
Fix admin user subscription stats
2025-09-29 17:37:52 +03:00
Egor 53f1831f59 Fix admin user subscription stats 2025-09-29 17:37:20 +03:00
Egor 860ebf18b6 Merge pull request #563 from Fr1ngg/bedolaga/fix-promotion-group-mechanics-and-discounts
Fix base period discount rounding and summary formatting
2025-09-29 16:00:27 +03:00
Egor ae05ca08ea Round discounted subscription prices up and clean summary 2025-09-29 15:43:50 +03:00
Egor 88e8a56526 Merge pull request #562 from Fr1ngg/bedolaga/fix-transaction-history-logging-for-admin-actions
Log admin-triggered balance deductions in transaction history
2025-09-29 15:11:05 +03:00
Egor fb4d714441 Log admin balance deductions as transactions 2025-09-29 15:10:47 +03:00
Egor 2690f63a82 Merge pull request #561 from Fr1ngg/bedolaga/update-subscription-pricing-calculation
Fix admin subscription purchase cost calculation
2025-09-29 14:59:02 +03:00
Egor ec9855a0a9 Fix admin subscription price calculation 2025-09-29 14:57:47 +03:00
Egor 984ad8e448 Merge pull request #560 from Fr1ngg/bedolaga/add-traffic-reset-on-subscription-payment
Add configurable traffic reset on subscription payments
2025-09-29 14:41:06 +03:00
Egor ca81b32502 Add configurable traffic reset on payments 2025-09-29 14:40:36 +03:00
Egor 70f190501b Merge pull request #559 from Fr1ngg/bedolaga/fix-automation-migration-errors
Fix promo group migration defaults for addon discounts
2025-09-29 14:21:46 +03:00
Egor 432efdbb7c Fix promo group seeding for new databases 2025-09-29 14:21:23 +03:00
Egor 83a821a591 Merge pull request #558 from Fr1ngg/bedolaga/add-trial-and-subscription-days-summation
Add setting to roll remaining trial time into paid subscriptions
2025-09-29 14:02:33 +03:00
Egor 14bdfd4a1e Add configurable trial rollover for paid subscriptions 2025-09-29 14:02:14 +03:00
Egor 66ff2a38d3 Merge pull request #557 from Fr1ngg/bedolaga/update-subscription-link-handling
Respect hidden subscription link setting
2025-09-29 13:40:46 +03:00
Egor 9f7c9cad38 feat: respect hidden subscription link setting 2025-09-29 13:40:27 +03:00
Egor 2132226b15 Merge pull request #556 from Fr1ngg/revert-555-bedolaga/hide-subscription-link-in-notifications
Revert "Hide subscription link when configured"
2025-09-29 13:16:49 +03:00
Egor 849465b2e1 Revert "Hide subscription link when configured" 2025-09-29 13:16:38 +03:00
Egor 402b31625b Merge pull request #555 from Fr1ngg/bedolaga/hide-subscription-link-in-notifications
Hide subscription link when configured
2025-09-29 13:12:42 +03:00
Egor 5fc24dbfae Hide subscription link in success messages when configured 2025-09-29 13:12:24 +03:00
Egor 8ba65cc2fe Merge pull request #554 from Fr1ngg/main
w
2025-09-29 13:02:20 +03:00
Egor ee2bffe08e Merge pull request #550 from PEDZEO/feature/ticket-user-info
Исправил поведение кнопок
2025-09-29 12:58:48 +03:00
PEDZEO c4b5db9002 fix(validation): sanitize имён + HTML-escape только в /start 2025-09-29 02:17:19 +03:00
PEDZEO 1a78bdc194 Исправил поведение кнопок 2025-09-29 01:31:49 +03:00
Egor a3bb749a6a Merge pull request #549 from Fr1ngg/main
w
2025-09-28 22:39:25 +03:00
Egor 626f78b230 Merge pull request #548 from PEDZEO/feature/ticket-user-info
Feature/ticket user info
2025-09-28 22:21:47 +03:00
PEDZEO 5d44285c0a feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:47 +03:00
PEDZEO 2d8091ddcb feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:29 +03:00
Egor fa6a4698ab Merge pull request #546 from FireWookie/dev2
Update API
2025-09-28 16:41:40 +03:00
firewookie f40454313f add ads compaigns remote route 2025-09-28 14:21:53 +05:00
firewookie 8a256b1bc2 add broadcast service 2025-09-28 14:05:35 +05:00
firewookie 71ac7ea464 backup logic and add documentation to stats route 2025-09-28 14:04:17 +05:00
firewookie bbc9f93429 update api 2025-09-28 13:36:53 +05:00
firewookie 3ba9598b5e update api 2025-09-28 13:35:13 +05:00
firewookie ffa51986a8 env to json 2025-09-28 13:34:43 +05:00
Egor 7e20140a00 Merge pull request #544 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue-j3pfif
Add fallback for bootstrap API token authentication
2025-09-28 06:28:06 +03:00
Egor 50d080f54e Improve web API token authentication fallback 2025-09-28 06:27:38 +03:00
Egor 29829b1c8c Merge pull request #543 from Fr1ngg/revert-542-bedolaga/fix-bot-token-authorization-issue
Revert "Ensure bootstrap web API token works without migration"
2025-09-28 06:26:47 +03:00
Egor e84e707d2f Revert "Ensure bootstrap web API token works without migration" 2025-09-28 06:26:37 +03:00
Egor c12566ae53 Merge pull request #542 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue
Ensure bootstrap web API token works without migration
2025-09-28 06:24:31 +03:00
Egor e6976b5262 Handle bootstrap API token fallback 2025-09-28 06:24:13 +03:00
Egor 81cfdcf7ac Merge pull request #541 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-jxb0f9
Synchronize bootstrap web API token with config overrides
2025-09-28 06:16:38 +03:00
Egor 9c1ca5b748 Ensure default web API token stays in sync with settings 2025-09-28 06:16:13 +03:00
Egor a0d14f97f4 Merge pull request #540 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-mvwipi
Handle missing RemnaWave dependencies in web API
2025-09-28 06:03:25 +03:00
Egor 8fdc70fdc3 Handle optional RemnaWave dependencies in web API 2025-09-28 06:03:11 +03:00
Egor d93139a458 Merge pull request #539 from Fr1ngg/revert-538-bedolaga/add-admin-panel-functionality-to-bot-api
Revert "Handle optional RemnaWave dependencies in web API"
2025-09-28 06:02:51 +03:00
Egor b78dc12db9 Revert "Handle optional RemnaWave dependencies in web API" 2025-09-28 06:02:42 +03:00
Egor 2a02f589bf Merge pull request #538 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api
Handle optional RemnaWave dependencies in web API
2025-09-28 06:00:37 +03:00
Egor bcfa4f8400 Handle optional RemnaWave dependencies in web API 2025-09-28 06:00:18 +03:00
Egor 042dc900ef Merge pull request #537 from Fr1ngg/bedolaga/add-endpoint-information-to-redoc
Fix OpenAPI export for RemnaWave endpoints
2025-09-28 05:45:49 +03:00
Egor 6004607bf6 Update __init__.py 2025-09-28 05:45:41 +03:00
Egor 40e4b1ce7f Ensure RemnaWave routes are imported for OpenAPI 2025-09-28 05:45:09 +03:00
Egor a4c5a2295f Merge pull request #536 from Fr1ngg/bedolaga/fix-missing-remnawave-endpoints-in-docs
Fix RemnaWave API docs registration
2025-09-28 05:29:01 +03:00
Egor 3d9a75ff51 Fix RemnaWave API docs registration 2025-09-28 05:28:41 +03:00
Egor 1216a4dd9d Merge pull request #535 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints-6ae7or
Add RemnaWave API documentation to Swagger
2025-09-28 05:09:45 +03:00
Egor 4bc1c402e9 feat: document remnawave endpoints in swagger 2025-09-28 05:09:24 +03:00
Egor 42a758bd68 Merge pull request #534 from Fr1ngg/revert-533-bedolaga/fix-documentation-for-remnawave-endpoints
Revert "Fix RemnaWave routes visibility in docs"
2025-09-28 05:08:18 +03:00
Egor 07a0d0ca43 Revert "Fix RemnaWave routes visibility in docs" 2025-09-28 05:08:08 +03:00
Egor f53b04444b Merge pull request #533 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints
Fix RemnaWave routes visibility in docs
2025-09-28 05:06:32 +03:00
Egor 9877c00c92 Fix RemnaWave routes visibility in docs 2025-09-28 05:06:16 +03:00
Egor 14aa175193 Merge pull request #532 from Fr1ngg/revert-529-revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Revert "Add RemnaWave management endpoints to web API""
2025-09-28 04:57:19 +03:00
Egor 562766a637 Revert "Revert "Add RemnaWave management endpoints to web API"" 2025-09-28 04:57:10 +03:00
Egor 4616911189 Merge pull request #531 from Fr1ngg/revert-530-bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Revert "Add Remnawave component management API"
2025-09-28 04:56:43 +03:00
Egor 1e14a0919f Revert "Add Remnawave component management API" 2025-09-28 04:56:33 +03:00
Egor e1f0bd152c Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
2025-09-28 04:53:36 +03:00
Egor c10e34ad43 Add Remnawave component management API 2025-09-28 04:53:01 +03:00
Egor 714f742560 Merge pull request #529 from Fr1ngg/revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Add RemnaWave management endpoints to web API"
2025-09-28 04:51:51 +03:00
Egor dc5e440fe2 Revert "Add RemnaWave management endpoints to web API" 2025-09-28 04:51:42 +03:00
Egor d53873dc7f Merge pull request #527 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components
Add RemnaWave management endpoints to web API
2025-09-28 04:46:12 +03:00
Egor 10ab557d8d feat(webapi): add remnawave integration endpoints 2025-09-28 04:45:57 +03:00
Egor 81147d42a9 Merge pull request #526 from Fr1ngg/revert-525-bedolaga/-scalar-redoc-api-osbdld
Revert "Replace ReDoc docs with Scalar UI"
2025-09-28 04:30:48 +03:00
Egor 8d5e2037d3 Revert "Replace ReDoc docs with Scalar UI" 2025-09-28 04:30:38 +03:00
Egor 7f03e61b5f Merge pull request #525 from Fr1ngg/bedolaga/-scalar-redoc-api-osbdld
Replace ReDoc docs with Scalar UI
2025-09-28 04:28:00 +03:00
Egor 870ee2fb66 Replace ReDoc docs with Scalar UI 2025-09-28 04:27:43 +03:00
Egor a525079282 Merge pull request #524 from Fr1ngg/revert-523-bedolaga/-scalar-redoc-api
Revert "Replace ReDoc with Scalar API reference"
2025-09-28 04:27:06 +03:00
Egor 39aff2bc1b Revert "Replace ReDoc with Scalar API reference" 2025-09-28 04:26:57 +03:00
Egor b4dc9f1970 Merge pull request #523 from Fr1ngg/bedolaga/-scalar-redoc-api
Replace ReDoc with Scalar API reference
2025-09-28 04:24:32 +03:00
Egor 37ac3e7a81 Replace ReDoc with Scalar API reference 2025-09-28 04:24:13 +03:00
Egor e3e6428d2a Merge pull request #522 from Fr1ngg/bedolaga/enable-swagger-for-api-in-bot
docs: explain enabling swagger ui
2025-09-28 04:20:43 +03:00
Egor 3e3385d1eb docs: explain enabling swagger ui 2025-09-28 04:20:28 +03:00
Egor 0375e74227 Merge pull request #521 from Fr1ngg/revert-505-revert-504-bedolaga/-docs
Revert "Revert "Enable API key auth in web API docs""
2025-09-28 04:02:41 +03:00
Egor b6bdeeefdd Revert "Revert "Enable API key auth in web API docs"" 2025-09-28 04:02:28 +03:00
Egor 24b3660f7b Merge pull request #520 from Fr1ngg/bedolaga/fix-bot-startup-issue
Fix database URL initialization
2025-09-28 03:56:01 +03:00
Egor da2c30cd8d Fix database URL initialization 2025-09-28 03:55:43 +03:00
Egor 1fad95c0bb Merge pull request #519 from Fr1ngg/revert-518-bedolaga/fix-bot-startup-validation-errors-f8krj6
Revert "Allow bot to boot without RemnaWave API settings"
2025-09-28 03:50:14 +03:00
Egor 6a1b1a9a91 Revert "Allow bot to boot without RemnaWave API settings" 2025-09-28 03:50:06 +03:00
Egor 9abf0b6ebe Merge pull request #518 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-f8krj6
Allow bot to boot without RemnaWave API settings
2025-09-28 03:46:33 +03:00
Egor 34e90b335b Handle missing RemnaWave configuration 2025-09-28 03:46:12 +03:00
Egor e84c82f29d Merge pull request #517 from Fr1ngg/revert-506-bedolaga/fix-bot-startup-error-due-to-missing-database_url
Revert "Make DATABASE_URL optional for default setup"
2025-09-28 03:38:31 +03:00
Egor 1c929a1a70 Revert "Make DATABASE_URL optional for default setup" 2025-09-28 03:38:23 +03:00
Egor 1c8878e651 Merge pull request #516 from Fr1ngg/revert-507-bedolaga/fix-missing-database_url-in-settings
Revert "Fix default handling for DATABASE_URL setting"
2025-09-28 03:37:53 +03:00
Egor 84645d84f7 Revert "Fix default handling for DATABASE_URL setting" 2025-09-28 03:37:44 +03:00
Egor 0cc59501bc Merge pull request #515 from Fr1ngg/revert-510-bedolaga/fix-missing-database_url-in-settings-wqxv3w
Revert "Allow optional Remnawave integration settings"
2025-09-28 03:36:54 +03:00
Egor ef6aaa58ea Revert "Allow optional Remnawave integration settings" 2025-09-28 03:36:42 +03:00
Egor 942ce48241 Merge pull request #514 from Fr1ngg/revert-513-bedolaga/fix-bot-startup-validation-errors-2waops
Revert "Handle missing optional RemnaWave settings"
2025-09-28 03:36:19 +03:00
Egor 625ec43c34 Revert "Handle missing optional RemnaWave settings" 2025-09-28 03:36:10 +03:00
Egor f184303415 Merge pull request #513 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-2waops
Handle missing optional RemnaWave settings
2025-09-28 03:35:02 +03:00
Egor 84a17864cd Handle missing optional RemnaWave settings 2025-09-28 03:34:44 +03:00
Egor 1476247519 Merge pull request #512 from Fr1ngg/revert-511-bedolaga/fix-bot-startup-validation-errors
Revert "Fix missing defaults for optional Remnawave settings"
2025-09-28 03:34:05 +03:00
Egor 0ea95005ee Revert "Fix missing defaults for optional Remnawave settings" 2025-09-28 03:33:56 +03:00
Egor ba808f66da Merge pull request #511 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Fix missing defaults for optional Remnawave settings
2025-09-28 03:32:42 +03:00
Egor e8472a502f Make optional Remnawave settings nullable by default 2025-09-28 03:32:23 +03:00
Egor 7d552ab715 Merge pull request #510 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-wqxv3w
Allow optional Remnawave integration settings
2025-09-28 03:27:55 +03:00
Egor 00de3a41df Allow optional Remnawave integration settings 2025-09-28 03:27:25 +03:00
Egor 36f38187d9 Merge pull request #509 from Fr1ngg/revert-508-bedolaga/fix-missing-database_url-in-settings-gvqm03
Revert "Make Remnawave settings optional by default"
2025-09-28 03:27:12 +03:00
Egor dd68b2b788 Revert "Make Remnawave settings optional by default" 2025-09-28 03:27:02 +03:00
Egor 7a9d37c884 Merge pull request #508 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-gvqm03
Make Remnawave settings optional by default
2025-09-28 03:25:20 +03:00
Egor 6a904d4be2 Make Remnawave settings optional by default 2025-09-28 03:25:04 +03:00
Egor b5b8408055 Merge pull request #507 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings
Fix default handling for DATABASE_URL setting
2025-09-28 03:20:07 +03:00
Egor 86a3228579 Fix default handling for DATABASE_URL setting 2025-09-28 03:19:52 +03:00
Egor 560993c0f3 Merge pull request #506 from Fr1ngg/bedolaga/fix-bot-startup-error-due-to-missing-database_url
Make DATABASE_URL optional for default setup
2025-09-28 03:11:15 +03:00
Egor a3fe935149 Make DATABASE_URL optional in settings 2025-09-28 03:10:57 +03:00
Egor a75549aebf Merge pull request #505 from Fr1ngg/revert-504-bedolaga/-docs
Revert "Enable API key auth in web API docs"
2025-09-28 03:02:31 +03:00
Egor be3fc7aa09 Revert "Enable API key auth in web API docs" 2025-09-28 03:02:22 +03:00
Egor 6cbfc6d74b Merge pull request #504 from Fr1ngg/bedolaga/-docs
Enable API key auth in web API docs
2025-09-28 02:59:25 +03:00
Egor 7ff7a051d9 Enable API key auth in web API docs 2025-09-28 02:59:07 +03:00
Egor 9a2f156158 Merge pull request #503 from Fr1ngg/bedolaga/fix-err_empty_response-on-api-endpoint
Expose admin API port for docker deployments
2025-09-28 02:48:58 +03:00
Egor 38b058636c Expose admin API port in docker compose 2025-09-28 02:48:42 +03:00
Egor 32523f5dea Merge pull request #502 from Fr1ngg/bedolaga/fix-web-api-startup-error
Handle uvicorn startup flag regression for admin web API
2025-09-28 02:32:09 +03:00
Egor ff70e38dbc Fix web API startup wait for uvicorn 0.32 2025-09-28 02:31:56 +03:00
Egor 8110f52bc3 Merge pull request #501 from Fr1ngg/bedolaga/fix-bot-api-launch-issue-3qh95y
Fix FastAPI 204 delete endpoints to return empty responses
2025-09-28 02:25:43 +03:00
Egor a5a0176433 Fix 204 responses to avoid response bodies 2025-09-28 02:25:26 +03:00
Egor 2d960e3e30 Merge pull request #500 from Fr1ngg/revert-499-bedolaga/fix-bot-api-launch-issue
Revert "Fix 204 responses for web API deletes"
2025-09-28 02:25:10 +03:00
Egor b5fa978023 Revert "Fix 204 responses for web API deletes" 2025-09-28 02:25:01 +03:00
Egor 9cc49e9f66 Merge pull request #499 from Fr1ngg/bedolaga/fix-bot-api-launch-issue
Fix 204 responses for web API deletes
2025-09-28 02:24:17 +03:00
Egor dbf63e518f Fix 204 responses for web API deletes 2025-09-28 02:23:58 +03:00
Egor 3e218b6e6d Merge pull request #498 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-vi0z1h
Handle RemnaWave server removal and add connected users view
2025-09-28 02:08:25 +03:00
Egor 2f52f6b8de Remove missing RemnaWave servers and list connected users 2025-09-28 02:08:05 +03:00
Egor 5d377cf885 Merge pull request #497 from Fr1ngg/revert-496-bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Revert "Handle removed RemnaWave squads and expose server users"
2025-09-28 02:07:50 +03:00
Egor 62ecacc3cc Revert "Handle removed RemnaWave squads and expose server users" 2025-09-28 02:07:43 +03:00
Egor 4ab6d45e50 Merge pull request #496 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Handle removed RemnaWave squads and expose server users
2025-09-28 02:02:55 +03:00
Egor d1bed5e41a Handle removed RemnaWave squads and expose server users 2025-09-28 02:02:37 +03:00
Egor b907f884ba Merge pull request #495 from Fr1ngg/revert-494-bedolaga/fix-server-deletion-issue-in-subscriptions
Revert "Handle RemnaWave server removal and expose server user list"
2025-09-28 02:02:08 +03:00
Egor 83a76c928d Revert "Handle RemnaWave server removal and expose server user list" 2025-09-28 02:01:57 +03:00
Egor 90d3f3aef2 Merge pull request #494 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions
Handle RemnaWave server removal and expose server user list
2025-09-28 01:56:59 +03:00
Egor 263df606d2 Remove missing RemnaWave servers during sync and add user list view 2025-09-28 01:56:40 +03:00
Egor 2a38969e3d Merge pull request #493 from Fr1ngg/main
w
2025-09-28 01:50:53 +03:00
Egor 300feec18e Merge pull request #492 from Fr1ngg/dev3
Dev3
2025-09-28 01:38:14 +03:00
Egor 7c56452eb4 Merge pull request #491 from Fr1ngg/bedolaga/fix-payment-button-and-status-issues
Fix PayPalych SBP payments
2025-09-28 01:34:02 +03:00
Egor 548ec540f2 Fix PayPalych SBP payments 2025-09-28 01:33:38 +03:00
Egor bceba34cc3 Merge pull request #490 from Fr1ngg/main
w
2025-09-28 01:14:29 +03:00
Egor b07ce2be5c Merge pull request #487 from yazhog/main
Пропуск выбора стран при оформлении подписки если доступен только 1 сквад, фикс уведомлений при пополнении баланса
2025-09-28 01:13:50 +03:00
Egor 9472cf5e8c Merge pull request #484 from PEDZEO/feature/ticket-report
Добавлен отчет по тикетам
2025-09-28 01:12:51 +03:00
yazhog 82f34e8c6e Merge pull request #44 from yazhog/codex/update-payment-options-layout
Reorder subscription return button in top-up selection
2025-09-27 16:48:18 +03:00
yazhog 02b718dac5 Adjust top-up keyboard button order 2025-09-27 16:44:52 +03:00
yazhog a65a23acb2 Merge pull request #43 from yazhog/codex/refactor-admin-notification-subscription-handling
Avoid lazy subscription load in admin notifications
2025-09-27 15:58:11 +03:00
yazhog 12523e7515 Avoid lazy subscription load in admin notifications 2025-09-27 15:54:38 +03:00
yazhog d3497f924a Merge pull request #42 from yazhog/codex/skip-country-selection-if-one-available
Skip country selection step when only one option is available
2025-09-27 13:29:20 +03:00
yazhog 75faa54b85 Skip country selection when only one option 2025-09-27 13:10:03 +03:00
Legacyyy777 02a9ac9be5 Merge branch 'Fr1ngg:main' into DEV 2025-09-26 20:39:23 +05:00
Pedzeo d22f9aaf62 Добавлен отчет по тикетам 2025-09-26 17:14:00 +03:00
Egor 68f5e2ab80 Add files via upload 2025-09-26 15:17:29 +03:00
Egor 4e2b71cb85 Update Dockerfile 2025-09-26 05:42:14 +03:00
Egor e229a7ee65 Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
2025-09-26 05:37:28 +03:00
Egor abf1f53218 Add web admin API schemas and docs 2025-09-26 05:37:08 +03:00
Egor 2859219f80 Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
2025-09-26 05:18:55 +03:00
Egor e71b7f4533 Revert "Revert "Add administrative web API and database support"" 2025-09-26 05:18:41 +03:00
Egor 5ba652fab6 Merge pull request #478 from Fr1ngg/revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert "Add administrative web API and database support"
2025-09-26 05:18:32 +03:00
Egor 0a3913a2aa Revert "Add administrative web API and database support" 2025-09-26 05:18:21 +03:00
Egor 118ef8a29f Merge pull request #477 from Fr1ngg/bedolaga/add-api-for-bot-integration-vlrm04
Add administrative web API and database support
2025-09-26 05:17:33 +03:00
Egor b5f7e06605 Add administrative web API and database support 2025-09-26 05:16:58 +03:00
Egor d258d27406 Merge pull request #476 from Fr1ngg/revert-475-revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert "Revert 474 revert 473 bedolaga/add api for bot integration"
2025-09-26 05:16:43 +03:00
Egor 554fb28cdb Revert "Revert 474 revert 473 bedolaga/add api for bot integration" 2025-09-26 05:16:36 +03:00
Egor 808901c38b Merge pull request #475 from Fr1ngg/revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert 474 revert 473 bedolaga/add api for bot integration
2025-09-26 05:11:38 +03:00
Egor 68508d8a86 Revert "Revert "Add web admin API for bot management"" 2025-09-26 05:11:13 +03:00
Egor 0daf8d7691 Merge pull request #474 from Fr1ngg/revert-473-bedolaga/add-api-for-bot-integration
Revert "Add web admin API for bot management"
2025-09-26 05:10:12 +03:00
Egor 5aeb2038c5 Revert "Add web admin API for bot management" 2025-09-26 05:09:59 +03:00
Egor d3a1060117 Merge pull request #473 from Fr1ngg/bedolaga/add-api-for-bot-integration
Add web admin API for bot management
2025-09-26 05:09:54 +03:00
Egor 4d22e99076 feat: add web admin api server 2025-09-26 05:09:26 +03:00
Legacyyy777 23f53dc5df Merge branch 'Fr1ngg:main' into DEV 2025-09-25 19:15:06 +05:00
Legacyyy777 b4d857eb88 Merge branch 'Fr1ngg:main' into DEV 2025-09-23 21:40:33 +05:00
Legacyyy777 51ba5b980c Merge branch 'Fr1ngg:main' into DEV 2025-09-21 10:57:47 +05:00
Legacyyy777 9fb6d7ecf6 Merge branch 'Fr1ngg:main' into DEV 2025-09-20 18:13:25 +05:00
Legacyyy777 d297e9b6d1 Add deploy workflow 2025-09-19 22:08:52 +05:00
149 changed files with 35904 additions and 3151 deletions
+14
View File
@@ -89,8 +89,13 @@ MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# Плюсовать дни триала при покупке платной подписке
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -142,6 +147,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -156,6 +162,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -206,6 +213,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
@@ -285,6 +293,10 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
@@ -338,6 +350,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.0-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.4.0-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.0-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.0-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.6-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3
View File
@@ -4,10 +4,13 @@
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!install_bot.sh
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
+2 -2
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.0"
ARG VERSION="v2.4.6"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8081 8082 8083
EXPOSE 8080 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
+325 -2
View File
@@ -71,7 +71,53 @@ eGames Cookies | Cookies в формате key:value | Для панелей eGa
## 🚀 Быстрый старт
### 🐳 Docker запуск
### 🧙‍♂️ Автоустановка через `install_bot.sh`
Скрипт-установщик берёт на себя подготовку окружения, настройку конфигурации и дальнейшее обслуживание бота. Он работает поверх Docker Compose и требует заранее установить:
- **Docker Engine** и **Docker Compose plugin** (2.20+);
- **Git** и **Bash** (по умолчанию есть в большинстве Linux дистрибутивов);
- `openssl` (используется для генерации токенов, но не обязателен — при отсутствии скрипт использует `urandom`).
```bash
# 1. Скачай репозиторий
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
cd remnawave-bedolaga-telegram-bot
# 2. Создай необходимые директории
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
chmod -R 755 ./logs ./data
sudo chown -R 1000:1000 ./logs ./data
# 3. Запусти мастер установки
chmod +x install_bot.sh # один раз, если файл не исполняемый
./install_bot.sh
```
На первом запуске мастер:
1. Спросит путь установки и сохранит его в `./.bot_install_state` — можно оставлять путь по умолчанию (текущая директория).
2. Поможет собрать `.env`: запросит обязательные токены (бот, Remnawave, админы), при необходимости сгенерирует Web API и PostgreSQL пароли, предложит авторизацию Basic Auth или eGames secret.
3. Подготовит структуру каталогов (`logs`, `data`, `backups` и т. д.) и проверит, что Docker готов к запуску.
4. Создаст (или обновит) `docker-compose.yml`, настроит внешнюю сеть `bot_network`, чтобы дальнейшие сервисы (например, Caddy) могли подключаться.
5. Запустит контейнеры бота, PostgreSQL и Redis и выведет их статус.
После установки повторный запуск `./install_bot.sh` открывает **интерактивное меню управления**:
- 📊 Мониторинг состояния контейнеров и ресурсов.
- ⚙️ Управление сервисами (запуск/остановка/пересборка).
- 📋 Просмотр и поиск по логам.
- 🔄 Обновление проекта из Git с автоматическим бэкапом.
- 💾 Создание и 📦 восстановление резервных копий (включая базу данных).
- 🧹 Очистка логов, бэкапов и образов.
- 🌐 Помощник настройки обратного прокси Caddy (webhook + miniapp, обновление `docker-compose`, перезагрузка).
- ⚙️ Конфигуратор `.env` (редактирование, пересоздание, маскировка секретов).
> 💡 Скрипт можно запускать сколько угодно раз — он хранит путь установки и понимает, когда конфигурация уже создана. Меню работает и по SSH (достаточно TTY), а для скриптов можно передать путь установки через stdin.
### 🐳 Ручной Docker запуск
Если не хочется пользоваться мастером, можно настроить всё вручную:
```bash
# 1. Скачай репозиторий
@@ -91,11 +137,269 @@ sudo chown -R 1000:1000 ./logs ./data
docker compose up -d
# 5. Проверь статус
docker compose logs
docker compose logs
```
---
## 🌐 Настройка обратного прокси и доменов
> Этот раздел описывает полноценную ручную настройку обратного прокси для **двух разных доменов**: отдельный домен для вебхуков
> (`hooks.example.com`) и отдельный домен для мини-приложения (`miniapp.example.com`). Оба прокси-сервера (Caddy или nginx) дол
> жны работать в одной Docker-сети с ботом, чтобы обращаться к сервису по внутреннему имени `remnawave_bot` без проброса порто
> в наружу.
### 1. Планирование доменов и переменных окружения
1. Добавьте в DNS по **A/AAAA-записи** для обоих доменов на IP сервера, где запущен бот.
2. Убедитесь, что входящий трафик на **80/tcp и 443/tcp** открыт (брандмауэр, облачный фаервол).
3. В `.env` пропишите корректные URL, чтобы бот формировал ссылки с HTTPS-доменами:
```env
WEBHOOK_URL=https://hooks.example.com
WEB_API_ENABLED=true
WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com
MINIAPP_CUSTOM_URL=https://miniapp.example.com
```
При необходимости добавьте пути для дополнительных провайдеров (`TRIBUTE_WEBHOOK_PATH`, `YOOKASSA_WEBHOOK_PATH` и т. д.) —
далее в конфигурациях мы их пробрасываем на соответствующие порты контейнера бота.
### 2. Общая Docker-сеть для бота и прокси
`docker-compose.yml` бота создаёт сеть `bot_network`. Чтобы внешний прокси видел сервис `remnawave_bot`, нужно:
```bash
# Если бот уже запущен через docker compose up -d
docker compose ps
# Убедиться, что сеть существует
docker network ls | grep bot_network || docker network create bot_network
# Подключить прокси (если контейнер уже запущен отдельно)
docker network connect bot_network <proxy_container_name>
```
Если прокси запускается через **собственный docker-compose**, в файле нужно объявить ту же сеть как внешнюю:
```yaml
networks:
bot_network:
external: true
```
Именно так поступает мастер `install_bot.sh`: при первичной установке он создаёт `bot_network` и при повторном запуске в меню №
8 («🌐 Настройка обратного прокси (Caddy)») может автоматически подготовить `docker-compose` и `Caddyfile`, обновить сеть и пер
езапустить сервис.
### 3. Ручная установка Caddy в Docker
1. Создайте каталог для конфигурации:
```bash
mkdir -p ~/caddy
cd ~/caddy
```
2. Сохраните docker-compose-файл `docker-compose.caddy.yml`:
```yaml
services:
caddy:
image: caddy:2-alpine
container_name: remnawave_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
# Укажите путь к каталогу miniapp из репозитория бота
- /root/remnawave-bedolaga-telegram-bot/miniapp:/miniapp:ro
- /root/remnawave-bedolaga-telegram-bot/miniapp/redirect:/miniapp/redirect:ro
networks:
- bot_network
volumes:
caddy_data:
caddy_config:
networks:
bot_network:
external: true
```
3. Создайте `Caddyfile` с двумя виртуальными хостами:
```caddy
webhook.domain.com {
handle /tribute-webhook* {
reverse_proxy localhost:8081
}
handle /cryptobot-webhook* {
reverse_proxy localhost:8081
}
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
}
}
miniapp.domain.com {
encode gzip zstd
root * /var/www/remnawave-miniapp
file_server
@config path /app-config.json
header @config Access-Control-Allow-Origin "*"
# Redirect for /miniapp/redirect/index.html
@redirect path /miniapp/redirect/index.html
redir @redirect /miniapp/redirect/index.html permanent
reverse_proxy /miniapp/* 127.0.0.1:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
```
- Каталог `miniapp` из репозитория должен быть смонтирован в контейнер Caddy по путям `/miniapp` и `/miniapp/redirect`,
чтобы статические файлы мини-приложения и страницы редиректа отдавались без дополнительной сборки. Замените
`/root/remnawave-bedolaga-telegram-bot` в примере на реальный путь до установленного репозитория.
- Caddy автоматически выпустит TLS-сертификаты через ACME, если DNS настроен правильно.
4. Запустите прокси и убедитесь, что он присоединился к сети бота:
```bash
docker compose -f docker-compose.caddy.yml up -d
docker network inspect bot_network | grep remnawave_caddy
```
5. Проверьте журналы Caddy и бота, выполните тестовый запрос `curl https://hooks.example.com/webhook` (должен отвечать 405/200
в зависимости от webhook).
### 4. Ручная настройка nginx в Docker
1. Создайте каталог `/opt/nginx-remnawave` и поместите туда `docker-compose.nginx.yml`:
```yaml
services:
nginx:
image: nginx:1.25-alpine
container_name: remnawave_nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/ssl/private:ro
- ./miniapp:/var/www/remnawave-miniapp:ro
networks:
- bot_network
networks:
bot_network:
external: true
```
Сертификаты можно выпускать вручную (certbot) и складывать в `./certs` либо использовать внешний контейнер `certbot`.
2. Пример `nginx.conf` с двумя серверными блоками:
```nginx
events {}
http {
include /etc/nginx/mime.types;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
upstream remnawave_bot_hooks {
server remnawave_bot:8081;
}
upstream remnawave_bot_yookassa {
server remnawave_bot:8082;
}
upstream remnawave_bot_api {
server remnawave_bot:8080;
}
server {
listen 80;
listen 443 ssl http2;
server_name hooks.example.com;
ssl_certificate /etc/ssl/private/hooks.fullchain.pem;
ssl_certificate_key /etc/ssl/private/hooks.privkey.pem;
location = /webhook { proxy_pass http://remnawave_bot_hooks; }
location /tribute-webhook { proxy_pass http://remnawave_bot_hooks; }
location /cryptobot-webhook { proxy_pass http://remnawave_bot_hooks; }
location /mulenpay-webhook { proxy_pass http://remnawave_bot_hooks; }
location /pal24-webhook { proxy_pass http://remnawave_bot:8084; }
location /yookassa-webhook { proxy_pass http://remnawave_bot_yookassa; }
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
server {
listen 80;
listen 443 ssl http2;
server_name miniapp.example.com;
ssl_certificate /etc/ssl/private/miniapp.fullchain.pem;
ssl_certificate_key /etc/ssl/private/miniapp.privkey.pem;
root /var/www/remnawave-miniapp;
index index.html;
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
try_files $uri =404;
}
location / {
try_files $uri /index.html =404;
}
location /miniapp/ {
proxy_pass http://remnawave_bot_api/miniapp/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-API-Key "КЛЮЧ-WEBAPI";
}
}
}
```
3. Запустите прокси и убедитесь, что он в сети `bot_network`:
```bash
docker compose -f docker-compose.nginx.yml up -d
docker network inspect bot_network | grep remnawave_nginx
```
4. После запуска перепроверьте, что `curl -I https://hooks.example.com/webhook` возвращает HTTP-ответ 405/200, а `curl -H "X-AP
I-Key: ..." https://miniapp.example.com/miniapp/health` отвечает 200.
### 5. Чек-лист проверки
- [ ] DNS-записи указывают на сервер, сертификаты валидны.
- [ ] Контейнер прокси присоединён к `bot_network` и видит сервис `remnawave_bot`.
- [ ] `WEBHOOK_URL` и `MINIAPP_CUSTOM_URL` в `.env` указывают на HTTPS-домены.
- [ ] В логах бота нет ошибок `connection refused` или `invalid host header` при обращении из прокси.
- [ ] Триггеры оплаты (Tribute, YooKassa и др.) успешно получают HTTP 200 при тестовых запросах на свои вебхуки.
---
## ⚙️ Конфигурация
### 🔧 Основные параметры
@@ -108,6 +412,14 @@ docker compose logs
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
### 🌐 Интеграция веб-админки
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
### 📱 Telegram Mini App с подпиской
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
@@ -331,6 +643,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -382,6 +696,7 @@ REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -396,6 +711,7 @@ TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -446,6 +762,7 @@ YOOKASSA_PAYMENT_SUBJECT=service
# Webhook настройки
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
@@ -525,6 +842,10 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
@@ -578,6 +899,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
+216 -146
View File
@@ -1,163 +1,233 @@
{
"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": "App Store [EU]",
"ru": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"ru": "App Store [RU]"
"config": {},
"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": "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": "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。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
],
"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.",
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"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.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к 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": "Google Play",
"ru": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"ru": "Скачать APK"
],
"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": "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": "打开应用并连接到服务器"
}
],
"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 и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Откройте приложение и подключитесь к серверу"
}
}
}
],
"pc": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
"buttonText": {
"en": "Mac Os",
"ru": "Mac Os"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Windows",
"ru": "Windows"
],
"macos": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "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": "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。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
}
}
],
"tv": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
],
"windows": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Download",
"ru": "Скачать"
}
}
],
"description": {
"en": "Download and install Happ.",
"ru": "Скачайте и установите Happ"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
],
"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 и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
]
}
],
"androidTV": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
}
],
"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 и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
],
"linux": [],
"appleTV": []
}
}
+12
View File
@@ -33,13 +33,19 @@ from app.handlers.admin import (
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -135,14 +141,20 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
admin_backup.register_handlers(dp)
admin_system_logs.register_handlers(dp)
admin_welcome_text.register_welcome_text_handlers(dp)
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
admin_pricing.register_handlers(dp)
admin_privacy_policy.register_handlers(dp)
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+167 -16
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
import os
import re
@@ -38,7 +40,7 @@ class Settings(BaseSettings):
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
DATABASE_URL: str = ""
DATABASE_URL: Optional[str] = None
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
@@ -53,8 +55,8 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str = ""
REMNAWAVE_API_KEY: str = ""
REMNAWAVE_API_URL: Optional[str] = None
REMNAWAVE_API_KEY: Optional[str] = None
REMNAWAVE_SECRET_KEY: Optional[str] = None
REMNAWAVE_USERNAME: Optional[str] = None
@@ -66,10 +68,12 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str = ""
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -128,8 +132,9 @@ class Settings(BaseSettings):
REFERRED_USER_REWARD: int = 0
AUTOPAY_WARNING_DAYS: str = "3,1"
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
DEFAULT_AUTOPAY_ENABLED: bool = False
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
MONITORING_INTERVAL: int = 60
@@ -147,6 +152,7 @@ class Settings(BaseSettings):
TRIBUTE_API_KEY: Optional[str] = None
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_HOST: str = "0.0.0.0"
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
@@ -156,9 +162,10 @@ class Settings(BaseSettings):
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
@@ -206,9 +213,16 @@ class Settings(BaseSettings):
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
PAL24_REQUEST_TIMEOUT: int = 30
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
@@ -224,6 +238,7 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
@@ -231,6 +246,19 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
WEB_API_PORT: int = 8080
WEB_API_WORKERS: int = 1
WEB_API_ALLOWED_ORIGINS: str = "*"
WEB_API_DOCS_ENABLED: bool = False
WEB_API_TITLE: str = "Remnawave Bot Admin API"
WEB_API_VERSION: str = "1.0.0"
WEB_API_DEFAULT_TOKEN: Optional[str] = None
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
@@ -251,6 +279,9 @@ class Settings(BaseSettings):
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
@field_validator('SERVER_STATUS_MODE', mode='before')
@classmethod
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
@@ -383,6 +414,14 @@ class Settings(BaseSettings):
"password": self.REMNAWAVE_PASSWORD,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
def get_pal24_sbp_button_text(self, fallback: str) -> str:
value = (self.PAL24_SBP_BUTTON_TEXT or "").strip()
return value or fallback
def get_pal24_card_button_text(self, fallback: str) -> str:
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
return value or fallback
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
@@ -426,6 +465,15 @@ class Settings(BaseSettings):
return [3, 1]
except (ValueError, AttributeError):
return [3, 1]
def is_autopay_enabled_by_default(self) -> bool:
value = getattr(self, "DEFAULT_AUTOPAY_ENABLED", True)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def get_available_languages(self) -> List[str]:
try:
@@ -437,7 +485,10 @@ class Settings(BaseSettings):
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks // 100
return f"{rubles}"
@@ -489,9 +540,68 @@ class Settings(BaseSettings):
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]:
def _clean(value: Optional[str]) -> Optional[str]:
if value is None:
return None
value_str = str(value).strip()
return value_str or None
name_en = _clean(self.MINIAPP_SERVICE_NAME_EN)
name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU)
desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN)
desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU)
default_name = name_en or name_ru or "RemnaWave VPN"
default_description = desc_en or desc_ru or "Secure & Fast Connection"
return {
"service_name": {
"default": default_name,
"en": name_en,
"ru": name_ru,
},
"service_description": {
"default": default_description,
"en": desc_en,
"ru": desc_ru,
},
}
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
def build_external_admin_token(self, bot_username: str) -> str:
"""Генерирует детерминированный и криптографически стойкий токен внешней админки."""
normalized = (bot_username or "").strip().lstrip("@").lower()
if not normalized:
raise ValueError("Bot username is required to build external admin token")
secret = (self.BOT_TOKEN or "").strip()
if not secret:
raise ValueError("Bot token is required to build external admin token")
digest = hmac.new(
key=secret.encode("utf-8"),
msg=f"remnawave.external_admin::{normalized}".encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
return digest[:48]
def get_external_admin_token(self) -> Optional[str]:
token = (self.EXTERNAL_ADMIN_TOKEN or "").strip()
return token or None
def get_external_admin_bot_id(self) -> Optional[int]:
try:
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
logging.getLogger(__name__).warning(
"Некорректный идентификатор бота для внешней админки: %s",
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
)
return None
def is_traffic_selectable(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
@@ -556,6 +666,13 @@ class Settings(BaseSettings):
def is_happ_download_button_enabled(self) -> bool:
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
def should_hide_subscription_link(self) -> bool:
"""Returns True when subscription links must be hidden from the interface."""
if self.is_happ_cryptolink_mode():
return False
return self.HIDE_SUBSCRIPTION_LINK
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
return template or None
@@ -954,7 +1071,25 @@ class Settings(BaseSettings):
def get_server_status_request_timeout(self) -> int:
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
def is_web_api_enabled(self) -> bool:
return bool(self.WEB_API_ENABLED)
def get_web_api_allowed_origins(self) -> list[str]:
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
origins = [origin.strip() for origin in raw if origin.strip()]
return origins or ["*"]
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
if self.WEB_API_DOCS_ENABLED:
return {
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json",
}
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
def get_support_system_mode(self) -> str:
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
return mode if mode in {"tickets", "contact", "both"} else "both"
@@ -974,15 +1109,31 @@ class Settings(BaseSettings):
settings = Settings()
PERIOD_PRICES = {
14: settings.PRICE_14_DAYS,
30: settings.PRICE_30_DAYS,
60: settings.PRICE_60_DAYS,
90: settings.PRICE_90_DAYS,
180: settings.PRICE_180_DAYS,
360: settings.PRICE_360_DAYS,
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
30: "PRICE_30_DAYS",
60: "PRICE_60_DAYS",
90: "PRICE_90_DAYS",
180: "PRICE_180_DAYS",
360: "PRICE_360_DAYS",
}
def refresh_period_prices() -> None:
"""Rebuild cached period price mapping using the latest settings."""
PERIOD_PRICES.clear()
PERIOD_PRICES.update(
{
days: getattr(settings, field_name, 0)
for days, field_name in _PERIOD_PRICE_FIELDS.items()
}
)
PERIOD_PRICES: Dict[int, int] = {}
refresh_period_prices()
def get_traffic_prices() -> Dict[int, int]:
packages = settings.get_traffic_packages()
return {package["gb"]: package["price"] for package in packages}
+6 -2
View File
@@ -32,6 +32,7 @@ async def create_campaign(
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -43,7 +44,7 @@ async def create_campaign(
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=True,
is_active=is_active,
)
db.add(campaign)
@@ -135,7 +136,10 @@ async def update_campaign(
"is_active",
}
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if not update_data:
return campaign
+164 -4
View File
@@ -1,11 +1,18 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Optional
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
async def upsert_discount_offer(
db: AsyncSession,
@@ -16,6 +23,8 @@ async def upsert_discount_offer(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -37,6 +46,8 @@ async def upsert_discount_offer(
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
offer.effect_type = effect_type
offer.extra_data = extra_data
else:
offer = DiscountOffer(
user_id=user_id,
@@ -46,6 +57,8 @@ async def upsert_discount_offer(
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
effect_type=effect_type,
extra_data=extra_data,
)
db.add(offer)
@@ -56,16 +69,103 @@ async def upsert_discount_offer(
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer).where(DiscountOffer.id == offer_id)
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
async def list_discount_offers(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 50,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.order_by(DiscountOffer.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
if user_id is not None:
stmt = stmt.where(DiscountOffer.user_id == user_id)
if notification_type:
stmt = stmt.where(DiscountOffer.notification_type == notification_type)
if is_active is not None:
stmt = stmt.where(DiscountOffer.is_active == is_active)
result = await db.execute(stmt)
return int(result.scalar() or 0)
async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
try:
await log_promo_offer_action(
db,
user_id=offer.user_id,
offer_id=offer.id,
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
details=details,
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
return offer
@@ -82,9 +182,69 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
return 0
count = 0
log_payloads = []
for offer in offers:
offer.is_active = False
count += 1
log_payloads.append(
{
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
return count
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.claimed_at.isnot(None),
)
.order_by(DiscountOffer.claimed_at.desc())
)
if source:
stmt = stmt.where(DiscountOffer.notification_type == source)
result = await db.execute(stmt)
return result.scalars().first()
+150
View File
@@ -0,0 +1,150 @@
import logging
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
setting = await get_faq_setting(db, language)
if setting:
setting.is_enabled = bool(enabled)
setting.updated_at = datetime.utcnow()
else:
setting = FaqSetting(
language=language,
is_enabled=bool(enabled),
)
db.add(setting)
await db.commit()
await db.refresh(setting)
logger.info(
"✅ Статус FAQ для языка %s обновлен: %s",
language,
"enabled" if setting.is_enabled else "disabled",
)
return setting
async def upsert_faq_setting(db: AsyncSession, language: str, enabled: bool) -> FaqSetting:
return await set_faq_enabled(db, language, enabled)
async def get_faq_pages(
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
) -> list[FaqPage]:
query = select(FaqPage).where(FaqPage.language == language)
if not include_inactive:
query = query.where(FaqPage.is_active.is_(True))
query = query.order_by(FaqPage.display_order.asc(), FaqPage.id.asc())
result = await db.execute(query)
pages = list(result.scalars().all())
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
async def create_faq_page(
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
page = FaqPage(
language=language,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
db.add(page)
await db.commit()
await db.refresh(page)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
if content is not None:
page.content = content
if display_order is not None:
page.display_order = display_order
if is_active is not None:
page.is_active = bool(is_active)
page.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(page)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
db: AsyncSession,
pages: Iterable[tuple[int, int]],
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
from typing import Optional, Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
async def count_main_menu_buttons(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(MainMenuButton))
return int(result.scalar() or 0)
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
if offset is not None:
stmt = stmt.offset(offset)
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
return result.scalar_one_or_none()
async def get_next_display_order(db: AsyncSession) -> int:
result = await db.execute(select(func.max(MainMenuButton.display_order)))
current_max = result.scalar()
return (int(current_max) if current_max is not None else 0) + 1
def _enum_value(value, enum_cls):
if value is None:
return None
if isinstance(value, enum_cls):
return value.value
return str(value)
async def create_main_menu_button(
db: AsyncSession,
*,
text: str,
action_type: MainMenuButtonActionType | str,
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: Optional[int] = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
button = MainMenuButton(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
db.add(button)
await db.commit()
await db.refresh(button)
return button
async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: Optional[str] = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: Optional[str] = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if text is not None:
button.text = text
if action_type is not None:
button.action_type = _enum_value(action_type, MainMenuButtonActionType)
if action_value is not None:
button.action_value = action_value
if visibility is not None:
button.visibility = _enum_value(visibility, MainMenuButtonVisibility)
if is_active is not None:
button.is_active = bool(is_active)
if display_order is not None:
button.display_order = int(display_order)
await db.commit()
await db.refresh(button)
return button
async def delete_main_menu_button(db: AsyncSession, button: MainMenuButton) -> None:
await db.delete(button)
await db.commit()
async def reorder_main_menu_buttons(
db: AsyncSession,
ordered_ids: Sequence[int],
) -> None:
if not ordered_ids:
return
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
buttons = result.scalars().all()
for button in buttons:
desired_order = order_map.get(button.id)
if desired_order is not None:
button.display_order = desired_order
await db.commit()
+14
View File
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
)
)
await db.commit()
async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
)
)
await db.commit()
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
async def upsert_privacy_policy(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
return policy
async def set_privacy_policy_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
policy = await get_privacy_policy(db, language)
if policy:
policy.is_enabled = bool(enabled)
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(policy)
await db.commit()
await db.refresh(policy)
logger.info(
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
"enabled" if policy.is_enabled else "disabled",
)
return policy
+83 -3
View File
@@ -30,20 +30,54 @@ logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
result = await db.execute(
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
result = await db.execute(
select(PromoGroup)
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
.order_by(PromoGroup.auto_assign_total_spent_kopeks, PromoGroup.id)
)
return result.scalars().all()
async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
result = await db.execute(
select(func.count(PromoGroup.id))
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
.where(PromoGroup.auto_assign_total_spent_kopeks > 0)
)
return bool(result.scalar_one())
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
async def count_promo_groups(db: AsyncSession) -> int:
result = await db.execute(select(func.count(PromoGroup.id)))
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
@@ -61,6 +95,7 @@ async def create_promo_group(
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
@@ -70,6 +105,9 @@ async def create_promo_group(
else None
)
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
@@ -78,16 +116,26 @@ async def create_promo_group(
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=False,
is_default=should_be_default,
)
db.add(promo_group)
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
promo_group.name,
promo_group.is_default,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
@@ -110,6 +158,7 @@ async def update_promo_group(
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -127,6 +176,36 @@ async def update_promo_group(
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
if is_default is not None:
if is_default:
group.is_default = True
await db.flush()
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
@@ -148,6 +227,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
entry = PromoOfferLog(
user_id=user_id,
offer_id=offer_id,
action=action,
source=source,
percent=percent,
effect_type=effect_type,
details=(details or {}).copy(),
)
db.add(entry)
if commit:
try:
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception("Failed to commit promo offer log entry")
raise
return entry
async def list_promo_offer_logs(
db: AsyncSession,
offset: int = 0,
limit: int = 20,
*,
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
selectinload(PromoOfferLog.user),
selectinload(PromoOfferLog.offer),
)
.order_by(PromoOfferLog.created_at.desc())
.offset(offset)
.limit(limit)
)
if user_id is not None:
stmt = stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
stmt = stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
stmt = stmt.where(PromoOfferLog.action == action)
if source:
stmt = stmt.where(PromoOfferLog.source == source)
result = await db.execute(stmt)
logs = result.scalars().all()
count_stmt = select(func.count(PromoOfferLog.id))
if user_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.user_id == user_id)
if offer_id is not None:
count_stmt = count_stmt.where(PromoOfferLog.offer_id == offer_id)
if action:
count_stmt = count_stmt.where(PromoOfferLog.action == action)
if source:
count_stmt = count_stmt.where(PromoOfferLog.source == source)
total = (await db.execute(count_stmt)).scalar() or 0
return logs, total
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
if should_update and new_message:
existing.message_text = new_message
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
templates.append(existing)
continue
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
db.add(template)
await db.flush()
templates.append(template)
await db.commit()
return templates
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name
if message_text is not None:
template.message_text = message_text
if button_text is not None:
template.button_text = button_text
if valid_hours is not None:
template.valid_hours = valid_hours
if discount_percent is not None:
template.discount_percent = discount_percent
if bonus_amount_kopeks is not None:
template.bonus_amount_kopeks = bonus_amount_kopeks
if active_discount_hours is not None:
template.active_discount_hours = active_discount_hours
if test_duration_hours is not None:
template.test_duration_hours = test_duration_hours
if test_squad_uuids is not None:
template.test_squad_uuids = list(test_squad_uuids)
if is_active is not None:
template.is_active = is_active
template.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(template)
return template
+79
View File
@@ -0,0 +1,79 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
async def get_public_offer(db: AsyncSession, language: str) -> Optional[PublicOffer]:
result = await db.execute(
select(PublicOffer).where(PublicOffer.language == language)
)
return result.scalar_one_or_none()
async def upsert_public_offer(
db: AsyncSession,
language: str,
content: str,
*,
enable_if_new: bool = True,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.content = content or ""
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Публичная оферта для языка %s обновлена (ID: %s)",
language,
offer.id,
)
return offer
async def set_public_offer_enabled(
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
offer = await get_public_offer(db, language)
if offer:
offer.is_enabled = bool(enabled)
offer.updated_at = datetime.utcnow()
else:
offer = PublicOffer(
language=language,
content="",
is_enabled=bool(enabled),
)
db.add(offer)
await db.commit()
await db.refresh(offer)
logger.info(
"✅ Статус публичной оферты для языка %s обновлен: %s",
language,
"enabled" if offer.is_enabled else "disabled",
)
return offer
+196 -14
View File
@@ -1,11 +1,23 @@
import logging
import random
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import select, and_, func, update, delete, text
from sqlalchemy import (
select,
and_,
func,
update,
delete,
text,
or_,
cast,
String,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
@@ -27,6 +39,7 @@ async def create_server_squad(
description: str = None,
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -59,6 +72,7 @@ async def create_server_squad(
description=description,
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
allowed_promo_groups=promo_groups,
)
@@ -188,7 +202,7 @@ async def update_server_squad(
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order'
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -232,10 +246,10 @@ async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
disabled = 0
removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
@@ -265,19 +279,187 @@ async def sync_with_remnawave(
)
created += 1
for uuid, server in existing_servers.items():
if uuid not in remnawave_uuids and server.is_available:
server.is_available = False
disabled += 1
removed_servers = [
server for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
removed_uuids = {server.squad_uuid for server in removed_servers}
subscription_ids_result = await db.execute(
select(SubscriptionServer.subscription_id)
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
"🗑️ Удаляется сервер %s (UUID: %s)",
server.display_name,
server.squad_uuid,
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscriptions_to_update: dict[int, Subscription] = {}
if subscription_ids:
subscriptions_result = await db.execute(
select(Subscription).where(Subscription.id.in_(subscription_ids))
)
for subscription in subscriptions_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
for squad_uuid in removed_uuids:
if not squad_uuid:
continue
extra_result = await db.execute(
select(Subscription).where(
text("connected_squads::text LIKE :uuid_pattern")
),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
for subscription in extra_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
cleaned_subscriptions = 0
for subscription in subscriptions_to_update.values():
current_squads = list(subscription.connected_squads or [])
if not current_squads:
continue
filtered_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
]
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
"🧹 Обновлены подписки после удаления серверов: %s",
cleaned_subscriptions,
)
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
return created, updated, disabled
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
return created, updated, removed
async def get_server_connected_users(
db: AsyncSession,
server_id: int
) -> List[User]:
server_uuid_result = await db.execute(
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
)
server_uuid = server_uuid_result.scalar_one_or_none()
connection_filters = [SubscriptionServer.id.isnot(None)]
if server_uuid:
connection_filters.append(
cast(Subscription.connected_squads, String).like(
f'%"{server_uuid}"%'
)
)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(
SubscriptionServer,
and_(
SubscriptionServer.subscription_id == Subscription.id,
SubscriptionServer.server_squad_id == server_id,
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.order_by(User.id)
)
return result.scalars().unique().all()
async def get_trial_eligible_server_squads(
db: AsyncSession,
include_unavailable: bool = False,
) -> List[ServerSquad]:
query = select(ServerSquad).where(ServerSquad.is_trial_eligible.is_(True))
result = await db.execute(query)
squads = result.scalars().unique().all()
if include_unavailable:
return squads
preferred_squads: List[ServerSquad] = []
fallback_squads: List[ServerSquad] = []
for squad in squads:
current_users = squad.current_users or 0
is_full = squad.max_users is not None and current_users >= squad.max_users
if is_full:
continue
if squad.is_available:
preferred_squads.append(squad)
else:
fallback_squads.append(squad)
if preferred_squads:
return preferred_squads
if fallback_squads:
return fallback_squads
return squads
async def choose_random_trial_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
squads = await get_trial_eligible_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
def _generate_display_name(original_name: str) -> str:
country_names = {
'NL': '🇳🇱 Нидерланды',
'DE': '🇩🇪 Германия',
+196 -16
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from typing import Iterable, Optional, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -47,10 +47,31 @@ async def create_trial_subscription(
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
squad_uuid = squad_uuid or settings.TRIAL_SQUAD_UUID
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if squad_uuid:
logger.debug(
"Выбран сквад %s для триальной подписки пользователя %s",
squad_uuid,
user_id,
)
except Exception as error:
logger.error(
"Не удалось получить сквад для триальной подписки пользователя %s: %s",
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -59,14 +80,43 @@ async def create_trial_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else []
connected_squads=[squad_uuid] if squad_uuid else [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(f"🎁 Создана триальная подписка для пользователя {user_id}")
if squad_uuid:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, [squad_uuid])
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для триального сквада %s",
squad_uuid,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика (сквад %s)",
squad_uuid,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей для триального сквада %s: %s",
squad_uuid,
error,
)
return subscription
@@ -74,9 +124,10 @@ async def create_paid_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -89,7 +140,9 @@ async def create_paid_subscription(
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or []
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
@@ -97,6 +150,36 @@ async def create_paid_subscription(
await db.refresh(subscription)
logger.info(f"💎 Создана платная подписка для пользователя {user_id}")
squad_uuids = list(connected_squads or [])
if update_server_counters and squad_uuids:
try:
from app.database.crud.server_squad import (
get_server_ids_by_uuids,
add_user_to_servers,
)
server_ids = await get_server_ids_by_uuids(db, squad_uuids)
if server_ids:
await add_user_to_servers(db, server_ids)
logger.info(
"📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
else:
logger.warning(
"⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)",
user_id,
squad_uuids,
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s",
user_id,
error,
)
return subscription
@@ -110,19 +193,46 @@ async def extend_subscription(
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
"📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s",
abs(days),
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.status == SubscriptionStatus.EXPIRED.value:
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
@@ -203,6 +313,64 @@ async def remove_subscription_squad(
return subscription
async def decrement_subscription_server_counts(
db: AsyncSession,
subscription: Optional[Subscription],
*,
subscription_servers: Optional[Iterable[SubscriptionServer]] = None,
) -> None:
"""Decrease server counters linked to the provided subscription."""
if not subscription:
return
server_ids: set[int] = set()
if subscription_servers is not None:
for sub_server in subscription_servers:
if sub_server and sub_server.server_squad_id is not None:
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
"⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s",
subscription.id,
error,
)
connected_squads = list(subscription.connected_squads or [])
if connected_squads:
try:
from app.database.crud.server_squad import get_server_ids_by_uuids
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
server_ids.update(squad_server_ids)
except Exception as error:
logger.error(
"⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s",
subscription.id,
error,
)
if not server_ids:
return
try:
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
except Exception as error:
logger.error(
"⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s",
list(server_ids),
subscription.id,
error,
)
async def update_subscription_autopay(
db: AsyncSession,
subscription: Subscription,
@@ -966,7 +1134,9 @@ async def create_subscription(
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = ""
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
if end_date is None:
@@ -986,7 +1156,17 @@ async def create_subscription(
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
+1 -6
View File
@@ -118,12 +118,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
+463 -48
View File
@@ -2,14 +2,27 @@ import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import IntegrityError
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
Transaction,
PromoGroup,
PaymentMethod,
TransactionType,
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -76,6 +89,37 @@ async def create_unique_referral_code(db: AsyncSession) -> str:
return f"ref{timestamp}"
async def _sync_users_sequence(db: AsyncSession) -> None:
"""Ensure the users.id sequence matches the current max ID."""
await db.execute(
text(
"SELECT setval('users_id_seq', "
"COALESCE((SELECT MAX(id) FROM users), 0) + 1, false)"
)
)
await db.commit()
logger.warning(
"🔄 Последовательность users_id_seq была синхронизирована с текущим максимумом id"
)
async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
default_group = await get_default_promo_group(db)
if default_group:
return default_group
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
db.add(default_group)
await db.flush()
return default_group
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -90,44 +134,61 @@ async def create_user(
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await get_default_promo_group(db)
if not default_group:
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
attempts = 3
for attempt in range(1, attempts + 1):
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(default_group)
await db.flush()
promo_group_id = default_group.id
db.add(user)
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
if default_group:
user.promo_group = default_group
try:
await db.commit()
await db.refresh(user)
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
user.promo_group = default_group
logger.info(
f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}"
)
return user
return user
except IntegrityError as exc:
await db.rollback()
if (
isinstance(getattr(exc, "orig", None), Exception)
and "users_pkey" in str(exc.orig)
and attempt < attempts
):
logger.warning(
"⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. "
"Выполняем повторную синхронизацию (попытка %s/%s)",
telegram_id,
attempt,
attempts,
)
await _sync_users_sequence(db)
continue
raise
raise RuntimeError("Не удалось создать пользователя после синхронизации последовательности")
async def update_user(
@@ -136,7 +197,10 @@ async def update_user(
**kwargs
) -> User:
from app.utils.validators import sanitize_telegram_name
for field, value in kwargs.items():
if field in ("first_name", "last_name"):
value = sanitize_telegram_name(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -205,10 +269,14 @@ async def add_user_balance_by_id(
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
@@ -216,18 +284,100 @@ async def subtract_user_balance(
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
try:
current_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
current_percent = 0
if current_percent > 0:
source = getattr(user, "promo_offer_discount_source", None)
log_context = {
"offer_id": None,
"percent": current_percent,
"source": source,
"effect_type": None,
"details": {
"reason": "manual_charge",
"description": description,
"amount_kopeks": amount_kopeks,
},
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_context["offer_id"] = offer.id
log_context["effect_type"] = offer.effect_type
if not log_context["percent"] and offer.discount_percent:
log_context["percent"] = offer.discount_percent
if user.balance_kopeks < amount_kopeks:
logger.error(f" ❌ НЕДОСТАТОЧНО СРЕДСТВ!")
return False
try:
old_balance = user.balance_kopeks
user.balance_kopeks -= amount_kopeks
if consume_promo_offer and getattr(user, "promo_offer_discount_percent", 0):
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
)
if consume_promo_offer and log_context:
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_context.get("offer_id"),
action="consumed",
source=log_context.get("source"),
percent=log_context.get("percent"),
effect_type=log_context.get("effect_type"),
details=log_context.get("details"),
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer consumption log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer consumption log failure: %s",
rollback_error,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
@@ -237,13 +387,108 @@ async def subtract_user_balance(
return False
async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(User).where(
User.promo_offer_discount_percent > 0,
User.promo_offer_discount_expires_at.isnot(None),
User.promo_offer_discount_expires_at <= now,
)
)
users = result.scalars().all()
if not users:
return 0
log_payloads: List[Dict[str, object]] = []
for user in users:
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
percent = 0
source = getattr(user, "promo_offer_discount_source", None)
offer_id = None
effect_type = None
if source:
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s",
user.id,
lookup_error,
)
offer = None
if offer:
offer_id = offer.id
effect_type = offer.effect_type
if not percent and offer.discount_percent:
percent = offer.discount_percent
log_payloads.append(
{
"user_id": user.id,
"offer_id": offer_id,
"source": source,
"percent": percent,
"effect_type": effect_type,
}
)
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = now
await db.commit()
for payload in log_payloads:
user_id = payload.get("user_id")
if not user_id:
continue
try:
await log_promo_offer_action(
db,
user_id=user_id,
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to log promo offer expiration for user %s: %s",
user_id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer expiration log failure: %s",
rollback_error,
)
return len(users)
async def get_users_list(
db: AsyncSession,
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None,
order_by_balance: bool = False
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
@@ -263,10 +508,71 @@ async def get_users_list(
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
# Сортировка по балансу в порядке убывания, если order_by_balance=True
if order_by_balance:
query = query.order_by(User.balance_kopeks.desc())
sort_flags = [
order_by_balance,
order_by_traffic,
order_by_last_activity,
order_by_total_spent,
order_by_purchase_count,
]
if sum(int(flag) for flag in sort_flags) > 1:
logger.debug(
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
)
transactions_stats = None
if order_by_total_spent or order_by_purchase_count:
from app.database.models import Transaction
transactions_stats = (
select(
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(Transaction.is_completed.is_(True))
.group_by(Transaction.user_id)
.subquery()
)
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
if order_by_traffic:
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
elif order_by_total_spent:
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_purchase_count:
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_balance:
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
elif order_by_last_activity:
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
else:
query = query.order_by(User.created_at.desc())
@@ -304,6 +610,62 @@ async def get_users_count(
return result.scalar()
async def get_users_spending_stats(
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
if not user_ids:
return {}
from app.database.models import Transaction
stats_query = (
select(
Transaction.user_id,
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
)
.group_by(Transaction.user_id)
)
result = await db.execute(stats_query)
rows = result.all()
return {
row.user_id: {
"total_spent": int(row.total_spent or 0),
"purchase_count": int(row.purchase_count or 0),
}
for row in rows
}
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
@@ -317,6 +679,59 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
return result.scalars().all()
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
now = datetime.utcnow()
base_query = (
select(User)
.options(selectinload(User.subscription))
.where(User.status == UserStatus.ACTIVE.value)
)
if segment == "no_subscription":
query = (
base_query.outerjoin(Subscription, Subscription.user_id == User.id)
.where(Subscription.id.is_(None))
)
else:
query = base_query.join(Subscription)
if segment == "paid_active":
query = query.where(
Subscription.is_trial == False, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "paid_expired":
query = query.where(
Subscription.is_trial == False, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
elif segment == "trial_active":
query = query.where(
Subscription.is_trial == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > now,
)
elif segment == "trial_expired":
query = query.where(
Subscription.is_trial == True, # noqa: E712
or_(
Subscription.status == SubscriptionStatus.EXPIRED.value,
Subscription.end_date <= now,
),
)
else:
logger.warning("Неизвестный сегмент для промо: %s", segment)
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
threshold_date = datetime.utcnow() - timedelta(days=months * 30)
+106
View File
@@ -0,0 +1,106 @@
"""CRUD операции для токенов административного веб-API."""
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WebApiToken
async def list_tokens(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[WebApiToken]:
query = select(WebApiToken)
if not include_inactive:
query = query.where(WebApiToken.is_active.is_(True))
query = query.order_by(WebApiToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
return await db.get(WebApiToken, token_id)
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
query = select(WebApiToken).where(
WebApiToken.token_hash == token_hash
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
name: str,
token_hash: str,
token_prefix: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
) -> WebApiToken:
token = WebApiToken(
name=name,
token_hash=token_hash,
token_prefix=token_prefix,
description=description,
expires_at=expires_at,
created_by=created_by,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: WebApiToken,
**kwargs,
) -> WebApiToken:
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def set_tokens_active_status(
db: AsyncSession,
token_ids: Iterable[int],
*,
is_active: bool,
) -> None:
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
await db.delete(token)
__all__ = [
"list_tokens",
"get_token_by_id",
"get_token_by_hash",
"create_token",
"update_token",
"set_tokens_active_status",
"delete_token",
]
+2 -2
View File
@@ -10,7 +10,7 @@ from app.database.models import Base
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.DATABASE_URL,
settings.get_database_url(),
poolclass=NullPool,
echo=settings.DEBUG,
future=True
@@ -48,4 +48,4 @@ async def init_db():
async def close_db():
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
logger.info("✅ Подключение к базе данных закрыто")
+190 -8
View File
@@ -79,6 +79,17 @@ class PaymentMethod(Enum):
PAL24 = "pal24"
MANUAL = "manual"
class MainMenuButtonActionType(Enum):
URL = "url"
MINI_APP = "mini_app"
class MainMenuButtonVisibility(Enum):
ALL = "all"
ADMINS = "admins"
SUBSCRIBERS = "subscribers"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -386,9 +397,13 @@ class User(Base):
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
discount_offers = relationship("DiscountOffer", back_populates="user")
promo_offer_logs = relationship("PromoOfferLog", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
@@ -453,6 +468,7 @@ class Subscription(Base):
user = relationship("User", back_populates="subscription")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
temporary_accesses = relationship("SubscriptionTemporaryAccess", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -571,8 +587,7 @@ class Subscription(Base):
return 0.0
def extend_subscription(self, days: int):
from datetime import timedelta, datetime
if self.end_date > datetime.utcnow():
self.end_date = self.end_date + timedelta(days=days)
else:
@@ -740,9 +755,9 @@ class Squad(Base):
class ServiceRule(Base):
__tablename__ = "service_rules"
id = Column(Integer, primary_key=True, index=True)
order = Column(Integer, default=0)
title = Column(String(255), nullable=False)
@@ -756,9 +771,54 @@ class ServiceRule(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PrivacyPolicy(Base):
__tablename__ = "privacy_policies"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class PublicOffer(Base):
__tablename__ = "public_offers"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
content = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqSetting(Base):
__tablename__ = "faq_settings"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, unique=True)
is_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class FaqPage(Base):
__tablename__ = "faq_pages"
id = Column(Integer, primary_key=True, index=True)
language = Column(String(10), nullable=False, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(255), unique=True, nullable=False)
value = Column(Text, nullable=True)
@@ -812,11 +872,73 @@ class DiscountOffer(Base):
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
effect_type = Column(String(50), nullable=False, default="percent_discount")
extra_data = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="discount_offers")
subscription = relationship("Subscription", back_populates="discount_offers")
logs = relationship("PromoOfferLog", back_populates="offer")
class PromoOfferTemplate(Base):
__tablename__ = "promo_offer_templates"
__table_args__ = (
Index("ix_promo_offer_templates_type", "offer_type"),
)
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
offer_type = Column(String(50), nullable=False)
message_text = Column(Text, nullable=False)
button_text = Column(String(255), nullable=False)
valid_hours = Column(Integer, nullable=False, default=24)
discount_percent = Column(Integer, nullable=False, default=0)
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
active_discount_hours = Column(Integer, nullable=True)
test_duration_hours = Column(Integer, nullable=True)
test_squad_uuids = Column(JSON, default=list)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User")
class SubscriptionTemporaryAccess(Base):
__tablename__ = "subscription_temporary_access"
id = Column(Integer, primary_key=True, index=True)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="CASCADE"), nullable=False)
squad_uuid = Column(String(255), nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=func.now())
deactivated_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
was_already_connected = Column(Boolean, default=False, nullable=False)
subscription = relationship("Subscription", back_populates="temporary_accesses")
offer = relationship("DiscountOffer")
class PromoOfferLog(Base):
__tablename__ = "promo_offer_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True, index=True)
action = Column(String(50), nullable=False)
source = Column(String(100), nullable=True)
percent = Column(Integer, nullable=True)
effect_type = Column(String(50), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", back_populates="promo_offer_logs")
offer = relationship("DiscountOffer", back_populates="logs")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
@@ -852,6 +974,7 @@ class ServerSquad(Base):
country_code = Column(String(5), nullable=True)
is_available = Column(Boolean, default=True)
is_trial_eligible = Column(Boolean, default=False, nullable=False)
price_kopeks = Column(Integer, default=0)
@@ -1091,7 +1214,7 @@ class Ticket(Base):
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
@@ -1118,6 +1241,65 @@ class TicketMessage(Base):
@property
def is_admin_message(self) -> bool:
return self.is_from_admin
def __repr__(self):
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
class WebApiToken(Base):
__tablename__ = "web_api_tokens"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
token_hash = Column(String(128), nullable=False, unique=True, index=True)
token_prefix = Column(String(32), nullable=False, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
last_used_ip = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(String(255), nullable=True)
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
class MainMenuButton(Base):
__tablename__ = "main_menu_buttons"
id = Column(Integer, primary_key=True, index=True)
text = Column(String(64), nullable=False)
action_type = Column(String(20), nullable=False)
action_value = Column(Text, nullable=False)
visibility = Column(String(20), nullable=False, default=MainMenuButtonVisibility.ALL.value)
is_active = Column(Boolean, nullable=False, default=True)
display_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index("ix_main_menu_buttons_order", "display_order", "id"),
)
@property
def action_type_enum(self) -> MainMenuButtonActionType:
try:
return MainMenuButtonActionType(self.action_type)
except ValueError:
return MainMenuButtonActionType.URL
@property
def visibility_enum(self) -> MainMenuButtonVisibility:
try:
return MainMenuButtonVisibility(self.visibility)
except ValueError:
return MainMenuButtonVisibility.ALL
def __repr__(self) -> str:
return (
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)
File diff suppressed because it is too large Load Diff
-1
View File
@@ -4,7 +4,6 @@ import ssl
import base64
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
+128 -28
View File
@@ -1,8 +1,9 @@
import base64
import hashlib
import hmac
import logging
import json
from typing import Optional
from typing import Optional, Iterable
from aiohttp import web
from aiogram import Bot
@@ -65,20 +66,37 @@ class WebhookServer:
self.site = web.TCPSite(
self.runner,
host='0.0.0.0',
host=settings.TRIBUTE_WEBHOOK_HOST,
port=settings.TRIBUTE_WEBHOOK_PORT
)
await self.site.start()
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
logger.info(
"Webhook сервер запущен на %s:%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
)
logger.info(
"Tribute webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.TRIBUTE_WEBHOOK_PATH,
)
if settings.is_mulenpay_enabled():
logger.info(
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
"Mulen Pay webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.MULENPAY_WEBHOOK_PATH,
)
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(
"CryptoBot webhook URL: http://%s:%s%s",
settings.TRIBUTE_WEBHOOK_HOST,
settings.TRIBUTE_WEBHOOK_PORT,
settings.CRYPTOBOT_WEBHOOK_PATH,
)
except Exception as e:
logger.error(f"Ошибка запуска webhook сервера: {e}")
@@ -111,14 +129,18 @@ class WebhookServer:
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
logger.info(f"MulenPay webhook headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
# Временно отключаем проверку подписи для отладки
# TODO: Включить обратно после настройки MulenPay
if not self._verify_mulenpay_signature(request, raw_body):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
logger.warning("MulenPay webhook signature verification failed, but processing anyway for debugging")
# return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
@@ -128,22 +150,36 @@ class WebhookServer:
payment_service = PaymentService(self.bot)
async for db in get_db():
# Получаем соединение с БД
db_generator = get_db()
db = await db_generator.__anext__()
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
break
await db_generator.__anext__()
except StopAsyncIteration:
pass
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
@@ -151,28 +187,92 @@ class WebhookServer:
logger.error("Mulen Pay secret key is not configured")
return False
signature = request.headers.get('X-MulenPay-Signature')
# Логируем все заголовки для отладки
logger.info("MulenPay webhook headers for signature verification:")
for header_name, header_value in request.headers.items():
if any(keyword in header_name.lower() for keyword in ['signature', 'sign', 'token', 'auth']):
logger.info(f" {header_name}: {header_value}")
signature = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Signature',
'X-Mulenpay-Signature',
'X-MULENPAY-SIGNATURE',
'X-MulenPay-Webhook-Signature',
'X-Mulenpay-Webhook-Signature',
'X-MULENPAY-WEBHOOK-SIGNATURE',
'X-Signature',
'Signature',
'X-MulenPay-Sign',
'X-Mulenpay-Sign',
'X-MULENPAY-SIGN',
'MulenPay-Signature',
'Mulenpay-Signature',
'MULENPAY-SIGNATURE',
'signature',
'sign',
)
)
if signature:
expected_signature = hmac.new(
normalized_signature = signature
if normalized_signature.lower().startswith('sha256='):
normalized_signature = normalized_signature.split('=', 1)[1].strip()
hmac_digest = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).hexdigest()
).digest()
expected_hex_signature = hmac_digest.hex()
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
if hmac.compare_digest(signature.strip().lower(), expected_signature.lower()):
normalized_signature_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
return True
normalized_signature_no_padding = normalized_signature.rstrip('=')
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
return True
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
return False
authorization_header = request.headers.get('Authorization')
if authorization_header and authorization_header.startswith('Bearer '):
token = authorization_header.split(' ', 1)[1].strip()
if hmac.compare_digest(token, secret_key):
if authorization_header:
scheme, _, value = authorization_header.partition(' ')
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in ('bearer', 'token'):
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный Bearer токен Mulen Pay webhook")
return False
fallback_token = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Token',
'X-Mulenpay-Token',
'X-Webhook-Token',
)
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.info(
"Mulen Pay webhook headers received: %s",
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись Mulen Pay webhook")
return False
+13 -4
View File
@@ -218,15 +218,24 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"✅ YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"🎯 YooKassa webhook URL: http://0.0.0.0:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
logger.info(
" YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
)
logger.info(
"🎯 YooKassa webhook URL: http://%s:%s%s",
settings.YOOKASSA_WEBHOOK_HOST,
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40 -30
View File
@@ -71,10 +71,10 @@ async def show_users_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"👥 **Управление пользователями и подписками**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -89,10 +89,10 @@ async def show_promo_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"💰 **Промокоды и статистика**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -107,10 +107,10 @@ async def show_communications_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"📨 **Коммуникации**\n\n"
"Управление рассылками и текстами интерфейса:",
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -128,16 +128,19 @@ async def show_support_submenu(
# Moderators have access only to tickets and not to settings
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
from app.keyboards.admin import get_admin_support_submenu_keyboard
kb = get_admin_support_submenu_keyboard(db_user.language)
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
if is_moderator_only
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
),
reply_markup=kb,
parse_mode="Markdown"
)
@@ -150,12 +153,14 @@ async def show_moderator_panel(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🧑‍⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ <b>Модерация поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
parse_mode="HTML",
reply_markup=kb
)
@@ -169,6 +174,7 @@ async def show_support_audit(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
@@ -186,18 +192,22 @@ async def show_support_audit(
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
lines = ["🧾 <b>Аудит модераторов</b>", ""]
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
if not logs:
lines.append("Пока пусто")
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
else:
for log in logs:
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
role = (
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
if getattr(log, 'is_moderator', False)
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
)
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
'close_ticket': 'Закрытие тикета',
'block_user_timed': 'Блокировка (время)',
'block_user_perm': 'Блокировка (навсегда)',
'unblock_user': 'Снятие блока',
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
@@ -219,7 +229,7 @@ async def show_support_audit(
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
@@ -234,10 +244,10 @@ async def show_settings_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"⚙️ **Настройки системы**\n\n"
"Управление Remnawave, мониторингом и другими настройками:",
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -252,10 +262,10 @@ async def show_system_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🛠️ **Системные функции**\n\n"
"Отчеты, обновления, резервные копии и системные операции:",
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
+8 -8
View File
@@ -21,8 +21,8 @@ from app.keyboards.admin import (
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
BROADCAST_BUTTON_LABELS
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
get_broadcast_button_config, get_broadcast_button_labels
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
@@ -31,10 +31,8 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
BUTTON_CONFIG = BROADCAST_BUTTONS
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
@@ -45,16 +43,17 @@ def get_updated_message_buttons_selector_keyboard(selected_buttons: list, langua
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
button_config_map = get_broadcast_button_config(language)
for row in BUTTON_ROWS:
row_buttons: list[types.InlineKeyboardButton] = []
for button_key in row:
if button_key not in selected_buttons:
continue
button_config = BUTTON_CONFIG[button_key]
button_config = button_config_map[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
@@ -628,7 +627,8 @@ async def confirm_button_selection(
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
button_labels = get_broadcast_button_labels(db_user.language)
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
if selected_names:
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
else:
@@ -745,7 +745,7 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
try:
+64 -9
View File
@@ -37,6 +37,9 @@ def _build_notification_settings_view(language: str):
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
trial_channel_status = _format_toggle(
config["trial_channel_unsubscribed"].get("enabled", True)
)
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
@@ -45,6 +48,7 @@ def _build_notification_settings_view(language: str):
"🔔 <b>Уведомления пользователям</b>\n\n"
f"• 1 час после триала: {trial_1h_status}\n"
f"• 24 часа после триала: {trial_24h_status}\n"
f"• Отписка от канала: {trial_channel_status}\n"
f"• 1 день после истечения: {expired_1d_status}\n"
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
f"{third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
@@ -57,6 +61,8 @@ def _build_notification_settings_view(language: str):
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
@@ -153,6 +159,36 @@ def _build_notification_preview_message(language: str, notification_type: str):
],
]
)
elif notification_type == "trial_channel_unsubscribed":
template = texts.get(
"TRIAL_CHANNEL_UNSUBSCRIBED",
(
"🚫 <b>Доступ приостановлен</b>\n\n"
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
),
)
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data="sub_channel_check",
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
elif notification_type == "expired_1d":
template = texts.get(
"SUBSCRIPTION_EXPIRED_1D",
@@ -190,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
@@ -237,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
@@ -368,7 +400,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
🔧 Выберите действие:
"""
keyboard = get_monitoring_keyboard()
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
keyboard = get_monitoring_keyboard(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -461,6 +494,27 @@ async def preview_trial_24h_notification(callback: CallbackQuery):
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
@admin_required
async def preview_trial_channel_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial channel preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
@admin_required
async def toggle_expired_1d_notification(callback: CallbackQuery):
@@ -533,6 +587,7 @@ async def preview_all_notifications(callback: CallbackQuery):
for notification_type in [
"trial_inactive_1h",
"trial_inactive_24h",
"trial_channel_unsubscribed",
"expired_1d",
"expired_2d",
"expired_nd",
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.privacy_policy_service import PrivacyPolicyService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
normalized_language = PrivacyPolicyService.normalize_language(db_user.language)
has_content = bool(policy and policy.content and policy.content.strip())
description = texts.t(
"ADMIN_PRIVACY_POLICY_DESCRIPTION",
"Политика конфиденциальности отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED",
"⚠️ Показ политики выключен или текст отсутствует.",
)
if policy and policy.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED",
"✅ Политика активна и показывается пользователям.",
)
elif policy and policy.is_enabled:
status_text = texts.t(
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY",
"⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(policy, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PRIVACY_POLICY_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = policy.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PRIVACY_POLICY_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
actions_prompt = texts.t(
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_privacy_policy_view",
)
])
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON",
"✅ Включить показ",
)
if policy and policy.is_enabled:
toggle_text = texts.t(
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_privacy_policy_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), policy
@admin_required
@error_handler
async def show_privacy_policy_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_policy = await PrivacyPolicyService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ политики конфиденциальности: %s",
db_user.telegram_id,
"enabled" if updated_policy.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PRIVACY_POLICY_ENABLED", "✅ Политика включена")
if updated_policy.is_enabled
else texts.t("ADMIN_PRIVACY_POLICY_DISABLED", "🚫 Политика отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if policy and policy.content:
preview = policy.content.strip()[:400]
if len(policy.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT",
"Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PRIVACY_POLICY_EDIT_TITLE', 'Редактирование политики')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_privacy_policy_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRIVACY_POLICY_CANCEL", "❌ Отмена"),
callback_data="admin_privacy_policy_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_privacy_policy)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def process_privacy_policy_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_TOO_LONG",
"❌ Текст политики слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PrivacyPolicyService.save_policy(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст политики конфиденциальности (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PRIVACY_POLICY_SAVED",
"✅ Политика конфиденциальности обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
policy = await PrivacyPolicyService.get_policy(
db,
db_user.language,
fallback=False,
)
if not policy or not policy.content or not policy.content.strip():
await callback.answer(
texts.t(
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT",
"Текст политики пока не задан.",
),
show_alert=True,
)
return
content = policy.content.strip()
truncated = False
max_length = 3800
if len(content) > max_length:
content = content[: max_length - 3] + "..."
truncated = True
header = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TITLE",
"👀 <b>Текущий текст политики</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_privacy_policy_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{content}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_privacy_policy_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_privacy_policy.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_privacy_policy_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRIVACY_POLICY_BACK_BUTTON",
"⬅️ К настройкам политики",
),
callback_data="admin_privacy_policy",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_privacy_policy_management,
F.data == "admin_privacy_policy",
)
dp.callback_query.register(
toggle_privacy_policy,
F.data == "admin_privacy_policy_toggle",
)
dp.callback_query.register(
start_edit_privacy_policy,
F.data == "admin_privacy_policy_edit",
)
dp.callback_query.register(
cancel_edit_privacy_policy,
F.data == "admin_privacy_policy_cancel",
)
dp.callback_query.register(
view_privacy_policy,
F.data == "admin_privacy_policy_view",
)
dp.callback_query.register(
show_privacy_policy_html_help,
F.data == "admin_privacy_policy_help",
)
dp.message.register(
process_privacy_policy_edit,
AdminStates.editing_privacy_policy,
)
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
import html
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.services.public_offer_service import PublicOfferService
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.validators import validate_html_tags, get_html_help_text
logger = logging.getLogger(__name__)
def _format_timestamp(value: datetime | None) -> str:
if not value:
return ""
try:
return value.strftime("%d.%m.%Y %H:%M")
except Exception:
return ""
async def _build_overview(
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
normalized_language = PublicOfferService.normalize_language(db_user.language)
has_content = bool(offer and offer.content and offer.content.strip())
description = texts.t(
"ADMIN_PUBLIC_OFFER_DESCRIPTION",
"Публичная оферта отображается в разделе «Инфо».",
)
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED",
"⚠️ Показ оферты выключен или текст отсутствует.",
)
if offer and offer.is_enabled and has_content:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED",
"✅ Оферта активна и показывается пользователям.",
)
elif offer and offer.is_enabled:
status_text = texts.t(
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY",
"⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
)
updated_at = _format_timestamp(getattr(offer, "updated_at", None))
updated_block = ""
if updated_at:
updated_block = texts.t(
"ADMIN_PUBLIC_OFFER_UPDATED_AT",
"Последнее обновление: {timestamp}",
).format(timestamp=updated_at)
preview_block = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY",
"Текст ещё не задан.",
)
if has_content:
preview_title = texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE",
"<b>Превью текста:</b>",
)
preview_raw = offer.content.strip()
preview_trimmed = preview_raw[:400]
if len(preview_raw) > 400:
preview_trimmed += "..."
preview_block = (
f"{preview_title}\n"
f"<code>{html.escape(preview_trimmed)}</code>"
)
language_block = texts.t(
"ADMIN_PUBLIC_OFFER_LANGUAGE",
"Язык: <code>{lang}</code>",
).format(lang=normalized_language)
header = texts.t(
"ADMIN_PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
actions_prompt = texts.t(
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT",
"Выберите действие:",
)
message_parts = [
header,
description,
language_block,
status_text,
]
if updated_block:
message_parts.append(updated_block)
message_parts.append(preview_block)
message_parts.append(actions_prompt)
overview_text = "\n\n".join(part for part in message_parts if part)
buttons: list[list[types.InlineKeyboardButton]] = []
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
])
if has_content:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON",
"👀 Просмотреть текущий текст",
),
callback_data="admin_public_offer_view",
)
])
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON",
"✅ Включить показ",
)
if offer and offer.is_enabled:
toggle_text = texts.t(
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON",
"🚫 Отключить показ",
)
buttons.append([
types.InlineKeyboardButton(
text=toggle_text,
callback_data="admin_public_offer_toggle",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_submenu_settings",
)
])
return overview_text, types.InlineKeyboardMarkup(inline_keyboard=buttons), offer
@admin_required
@error_handler
async def show_public_offer_management(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
updated_offer = await PublicOfferService.toggle_enabled(db, db_user.language)
logger.info(
"Админ %s переключил показ публичной оферты: %s",
db_user.telegram_id,
"enabled" if updated_offer.is_enabled else "disabled",
)
status_message = (
texts.t("ADMIN_PUBLIC_OFFER_ENABLED", "✅ Оферта включена")
if updated_offer.is_enabled
else texts.t("ADMIN_PUBLIC_OFFER_DISABLED", "🚫 Оферта отключена")
)
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(status_message)
@admin_required
@error_handler
async def start_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
current_preview = ""
if offer and offer.content:
preview = offer.content.strip()[:400]
if len(offer.content.strip()) > 400:
preview += "..."
current_preview = (
texts.t(
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW",
"Текущий текст (превью):",
)
+ f"\n<code>{html.escape(preview)}</code>\n\n"
)
prompt = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT",
"Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
)
hint = texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_HINT",
"Используйте /html_help для справки по тегам.",
)
message_text = (
f"📝 <b>{texts.t('ADMIN_PUBLIC_OFFER_EDIT_TITLE', 'Редактирование оферты')}</b>\n\n"
f"{current_preview}{prompt}\n\n{hint}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_HTML_HELP",
"️ HTML помощь",
),
callback_data="admin_public_offer_help",
)
],
[
types.InlineKeyboardButton(
text=texts.t("ADMIN_PUBLIC_OFFER_CANCEL", "❌ Отмена"),
callback_data="admin_public_offer_cancel",
)
],
]
)
await callback.message.edit_text(message_text, reply_markup=keyboard)
await state.set_state(AdminStates.editing_public_offer)
await callback.answer()
@admin_required
@error_handler
async def cancel_edit_public_offer(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
overview_text, markup, _ = await _build_overview(db_user, db)
await callback.message.edit_text(
overview_text,
reply_markup=markup,
)
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED",
"Редактирование оферты отменено.",
)
)
@admin_required
@error_handler
async def process_public_offer_edit(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
new_text = message.text or ""
if len(new_text) > 4000:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_TOO_LONG",
"❌ Текст оферты слишком длинный. Максимум 4000 символов.",
)
)
return
is_valid, error_message = validate_html_tags(new_text)
if not is_valid:
await message.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_HTML_ERROR",
"❌ Ошибка в HTML: {error}",
).format(error=error_message)
)
return
await PublicOfferService.save_offer(db, db_user.language, new_text)
logger.info(
"Админ %s обновил текст публичной оферты (%d символов)",
db_user.telegram_id,
len(new_text),
)
await state.clear()
success_text = texts.t(
"ADMIN_PUBLIC_OFFER_SAVED",
"✅ Публичная оферта обновлена.",
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
]
]
)
await message.answer(success_text, reply_markup=reply_markup)
@admin_required
@error_handler
async def view_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
offer = await PublicOfferService.get_offer(
db,
db_user.language,
fallback=False,
)
if not offer or not offer.content or not offer.content.strip():
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
content = offer.content.strip()
max_length = 3800
pages = PublicOfferService.split_content_into_pages(
content,
max_length=max_length,
)
if not pages:
await callback.answer(
texts.t(
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT",
"Текст оферты пока не задан.",
),
show_alert=True,
)
return
preview = pages[0]
truncated = len(pages) > 1
header = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TITLE",
"👀 <b>Текущий текст оферты</b>",
)
note = ""
if truncated:
note = texts.t(
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED",
"\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON",
"✏️ Изменить текст",
),
callback_data="admin_public_offer_edit",
)
],
]
)
await callback.message.edit_text(
f"{header}\n\n{preview}{note}",
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def show_public_offer_html_help(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
help_text = get_html_help_text()
current_state = await state.get_state()
buttons: list[list[types.InlineKeyboardButton]] = []
if current_state == AdminStates.editing_public_offer.state:
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT",
"⬅️ Назад к редактированию",
),
callback_data="admin_public_offer_edit",
)
])
buttons.append([
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PUBLIC_OFFER_BACK_BUTTON",
"⬅️ К настройкам оферты",
),
callback_data="admin_public_offer",
)
])
await callback.message.edit_text(
help_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_public_offer_management,
F.data == "admin_public_offer",
)
dp.callback_query.register(
toggle_public_offer,
F.data == "admin_public_offer_toggle",
)
dp.callback_query.register(
start_edit_public_offer,
F.data == "admin_public_offer_edit",
)
dp.callback_query.register(
cancel_edit_public_offer,
F.data == "admin_public_offer_cancel",
)
dp.callback_query.register(
view_public_offer,
F.data == "admin_public_offer_view",
)
dp.callback_query.register(
show_public_offer_html_help,
F.data == "admin_public_offer_help",
)
dp.message.register(
process_public_offer_edit,
AdminStates.editing_public_offer,
)
+197 -4
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
get_server_connected_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
@@ -33,6 +35,8 @@ def _build_server_edit_view(server):
else "Не выбраны"
)
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -49,6 +53,7 @@ def _build_server_edit_view(server):
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
Промогруппы: {promo_groups_text}
Выдача триала: {trial_status}
<b>Описание:</b>
{server.description or 'Не указано'}
@@ -73,6 +78,17 @@ def _build_server_edit_view(server):
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🎁 Выдавать сквад" if not server.is_trial_eligible else "🚫 Не выдавать сквад",
callback_data=f"admin_server_trial_{server.id}",
),
],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
@@ -164,7 +180,7 @@ async def show_servers_menu(
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
]
@@ -276,7 +292,7 @@ async def sync_servers_with_remnawave(
)
return
created, updated, disabled = await sync_with_remnawave(db, squads)
created, updated, removed = await sync_with_remnawave(db, squads)
await cache.delete_pattern("available_countries*")
@@ -286,7 +302,7 @@ async def sync_servers_with_remnawave(
📊 <b>Результаты:</b>
Создано новых серверов: {created}
Обновлено существующих: {updated}
Отключено неактивных: {disabled}
Удалено отсутствующих: {removed}
Всего обработано: {len(squads)}
Новые серверы созданы как недоступные.
@@ -314,7 +330,7 @@ async def sync_servers_with_remnawave(
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@@ -343,6 +359,149 @@ async def show_server_edit_menu(
await callback.answer()
@admin_required
@error_handler
async def show_server_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
payload = callback.data.split("admin_server_users_", 1)[-1]
payload_parts = payload.split("_")
server_id = int(payload_parts[0])
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
page = max(page, 1)
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
users = await get_server_connected_users(db, server_id)
total_users = len(users)
page_size = 10
total_pages = max((total_users + page_size - 1) // page_size, 1)
if page > total_pages:
page = total_pages
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_users = users[start_index:end_index]
safe_name = html.escape(server.display_name or "")
safe_uuid = html.escape(server.squad_uuid or "")
header = [
"🌐 <b>Пользователи сервера</b>",
"",
f"• Сервер: {safe_name}",
f"• UUID: <code>{safe_uuid}</code>",
f"• Подключений: {total_users}",
]
if total_pages > 1:
header.append(f"• Страница: {page}/{total_pages}")
header.append("")
text = "\n".join(header)
def _get_status_icon(status_text: str) -> str:
if not status_text:
return ""
parts = status_text.split(" ", 1)
return parts[0] if parts else status_text
if users:
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
lines.append(f"{index}. {safe_user_name}")
text += "\n" + "\n".join(lines)
else:
text += "Пользователи не найдены."
keyboard: list[list[types.InlineKeyboardButton]] = []
for user in page_users:
display_name = user.full_name
if len(display_name) > 30:
display_name = display_name[:27] + "..."
subscription_status = (
user.subscription.status_display
if user.subscription
else "❌ Нет подписки"
)
status_icon = _get_status_icon(subscription_status)
if status_icon:
button_text = f"{status_icon} {display_name}"
else:
button_text = display_name
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if total_pages > 1:
navigation_buttons: list[types.InlineKeyboardButton] = []
if page > 1:
navigation_buttons.append(
types.InlineKeyboardButton(
text="⬅️ Предыдущая",
callback_data=f"admin_server_users_{server_id}_{page - 1}",
)
)
navigation_buttons.append(
types.InlineKeyboardButton(
text=f"Стр. {page}/{total_pages}",
callback_data=f"admin_server_users_{server_id}_{page}",
)
)
if page < total_pages:
navigation_buttons.append(
types.InlineKeyboardButton(
text="Следующая ➡️",
callback_data=f"admin_server_users_{server_id}_{page + 1}",
)
)
keyboard.append(navigation_buttons)
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К списку", callback_data="admin_servers_list"
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def toggle_server_availability(
@@ -377,6 +536,38 @@ async def toggle_server_availability(
)
@admin_required
@error_handler
async def toggle_server_trial_assignment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
server_id = int(callback.data.split('_')[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = "будет выдаваться" if new_status else "перестанет выдаваться"
await callback.answer(f"✅ Сквад {status_text} в триал")
server = await get_server_squad_by_id(db, server_id)
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
@admin_required
@error_handler
async def start_server_edit_price(
@@ -1105,6 +1296,8 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(toggle_server_trial_assignment, F.data.startswith("admin_server_trial_"))
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
-56
View File
@@ -4,7 +4,6 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.config import settings
from app.states import AdminStates
from app.database.models import User
from app.keyboards.admin import get_admin_subscriptions_keyboard
@@ -87,10 +86,6 @@ async def show_subscriptions_menu(
],
[
types.InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats"),
types.InlineKeyboardButton(text="💰 Настройки цен", callback_data="admin_subs_pricing")
],
[
types.InlineKeyboardButton(text="🌐 Управление серверами", callback_data="admin_servers"),
types.InlineKeyboardButton(text="🌍 География", callback_data="admin_subs_countries")
],
[
@@ -277,56 +272,6 @@ async def show_subscriptions_stats(
@admin_required
@error_handler
async def show_pricing_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
<b>Настройки цен</b>
<b>Периоды подписки:</b>
- 14 дней: {settings.format_price(settings.PRICE_14_DAYS)}
- 30 дней: {settings.format_price(settings.PRICE_30_DAYS)}
- 60 дней: {settings.format_price(settings.PRICE_60_DAYS)}
- 90 дней: {settings.format_price(settings.PRICE_90_DAYS)}
- 180 дней: {settings.format_price(settings.PRICE_180_DAYS)}
- 360 дней: {settings.format_price(settings.PRICE_360_DAYS)}
<b>Трафик-пакеты:</b>
- 5 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_5GB)}
- 10 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_10GB)}
- 25 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_25GB)}
- 50 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_50GB)}
- 100 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_100GB)}
- 250 ГБ: {settings.format_price(settings.PRICE_TRAFFIC_250GB)}
<b>Дополнительно:</b>
- За устройство: {settings.format_price(settings.PRICE_PER_DEVICE)}
"""
keyboard = [
# [
# types.InlineKeyboardButton(text="📅 Периоды", callback_data="admin_edit_period_prices"),
# types.InlineKeyboardButton(text="📈 Трафик", callback_data="admin_edit_traffic_prices")
# ],
# [
# types.InlineKeyboardButton(text="📱 Устройства", callback_data="admin_edit_device_price")
# ],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def show_countries_management(
callback: types.CallbackQuery,
db_user: User,
@@ -487,7 +432,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_subscriptions_list, F.data == "admin_subs_list")
dp.callback_query.register(show_expiring_subscriptions, F.data == "admin_subs_expiring")
dp.callback_query.register(show_subscriptions_stats, F.data == "admin_subs_stats")
dp.callback_query.register(show_pricing_settings, F.data == "admin_subs_pricing")
dp.callback_query.register(show_countries_management, F.data == "admin_subs_countries")
dp.callback_query.register(send_expiry_reminders, F.data == "admin_send_expiry_reminders")
+110 -79
View File
@@ -4,6 +4,7 @@ import html
import contextlib
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
@@ -28,33 +29,63 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
rows: list[list[types.InlineKeyboardButton]] = []
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
def mode_button(label_key: str, default: str, active: bool) -> str:
prefix = "🔘" if active else ""
return f"{prefix} {texts.t(label_key, default)}"
rows.append([
types.InlineKeyboardButton(
text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
text=(
f"{'' if menu_enabled else '🚫'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
),
callback_data="admin_support_toggle_menu"
)
])
rows.append([
types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
callback_data="admin_support_mode_tickets"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
callback_data="admin_support_mode_contact"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
callback_data="admin_support_mode_both"
),
])
rows.append([
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
callback_data="admin_support_edit_desc"
)
])
# Notifications block
rows.append([
types.InlineKeyboardButton(
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
text=(
f"{'🔔' if admin_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
f"{status_enabled if admin_notif else status_disabled}"
),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
text=(
f"{'🔔' if user_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
f"{status_enabled if user_notif else status_disabled}"
),
callback_data="admin_support_toggle_user_notifications"
)
])
@@ -62,13 +93,17 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
# SLA block
rows.append([
types.InlineKeyboardButton(
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
text=(
f"{'' if sla_enabled else '⏹️'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
f"{status_enabled if sla_enabled else status_disabled}"
),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
text=f"⏳ Время SLA: {sla_minutes} мин",
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
callback_data="admin_support_set_sla_minutes"
)
])
@@ -78,15 +113,18 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count),
callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
text="➕ Назначить модератора", callback_data="admin_support_add_moderator"
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
text="➖ Удалить модератора", callback_data="admin_support_remove_moderator"
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
callback_data="admin_support_remove_moderator"
)
])
@@ -107,8 +145,8 @@ async def show_support_settings(
texts = get_texts(db_user.language)
desc = SupportSettingsService.get_support_info_text(db_user.language)
await callback.message.edit_text(
"🛟 <b>Настройки поддержки</b>\n\n" +
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
desc,
reply_markup=_get_support_settings_keyboard(db_user.language),
parse_mode="HTML"
@@ -152,24 +190,6 @@ async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSess
await show_support_settings(callback, db_user, db)
from app.states import SupportSettingsStates
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
# we'll manage separate state below
from aiogram.fsm.state import State, StatesGroup
class SupportAdvancedStates(StatesGroup):
waiting_for_sla_minutes = State()
waiting_for_moderator_id = State()
@@ -178,11 +198,15 @@ class SupportAdvancedStates(StatesGroup):
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
texts.t(
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
@@ -192,63 +216,53 @@ async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
await message.answer("❌ Введите корректное число минут (1-1440)")
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
texts.t(
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
@admin_required
@error_handler
async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
return
if SupportSettingsService.add_moderator(tid):
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
else:
await message.answer("❌ Не удалось сохранить")
await state.clear()
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
texts.t(
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
@@ -260,24 +274,32 @@ async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, d
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
return
ok = False
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
msg = "✅ Модератор удалён" if ok else "❌ Не удалось удалить"
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
)
else:
ok = SupportSettingsService.add_moderator(tid)
msg = "✅ Пользователь назначен модератором" if ok else "❌ Не удалось назначить"
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@@ -285,13 +307,17 @@ async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSe
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
moderators = SupportSettingsService.get_moderators()
if not moderators:
await callback.answer("Список пуст", show_alert=True)
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
return
text = "🧑‍⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
text = (
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ <b>Модераторы</b>") +
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
)
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@@ -328,7 +354,10 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
kb_rows: list[list[types.InlineKeyboardButton]] = []
kb_rows.append([
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
callback_data="admin_support_send_desc"
)
])
# Подготовим блок контакта (отдельным инлайном)
from app.config import settings
@@ -338,19 +367,19 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
])
text_parts = [
"📝 <b>Редактирование описания поддержки</b>",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
"",
"Текущее описание:",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
"",
f"<code>{html.escape(current_desc_plain)}</code>",
]
if support_contact_display:
text_parts += [
"",
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
f"<code>{html.escape(support_contact_display)}</code>",
"",
"Добавьте в описание при необходимости.",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
]
await callback.message.edit_text(
"\n".join(text_parts),
@@ -364,24 +393,26 @@ async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: Asyn
@admin_required
@error_handler
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
new_text = message.html_text or message.text
SupportSettingsService.set_support_info_text(db_user.language, new_text)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer("✅ Описание обновлено.", reply_markup=markup)
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
@admin_required
@error_handler
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# send plain text for easy copying
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
# attach delete button to the sent message
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
if len(current_desc_plain) <= 4000:
await callback.message.answer(current_desc_plain, reply_markup=markup)
@@ -393,7 +424,7 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
is_last = (chunk + 4000) >= len(current_desc_plain)
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
chunk += 4000
await callback.answer("Текст отправлен ниже")
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
@error_handler
@@ -403,15 +434,15 @@ async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
texts = get_texts(db_user.language if db_user else 'ru')
if not may_delete:
texts = get_texts(db_user.language if db_user else 'ru')
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
with contextlib.suppress(Exception):
await callback.answer("Сообщение удалено")
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
def register_handlers(dp: Dispatcher):
+165
View File
@@ -0,0 +1,165 @@
import logging
from datetime import datetime
from html import escape
from pathlib import Path
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
LOG_PREVIEW_LIMIT = 2300
def _resolve_log_path() -> Path:
log_path = Path(settings.LOG_FILE)
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
return log_path
def _format_preview_block(text: str) -> str:
escaped_text = escape(text) if text else ""
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
def _build_logs_message(log_path: Path) -> str:
if not log_path.exists():
message = (
"🧾 <b>Системные логи</b>\n\n"
f"Файл <code>{log_path}</code> пока не создан.\n"
"Логи появятся автоматически после первой записи."
)
return message
try:
content = log_path.read_text(encoding="utf-8", errors="ignore")
except Exception as error: # pragma: no cover - защита от проблем чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
message = (
"❌ <b>Ошибка чтения логов</b>\n\n"
f"Не удалось прочитать файл <code>{log_path}</code>."
)
return message
total_length = len(content)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime)
if not content:
preview_text = "Лог-файл пуст."
truncated = False
else:
preview_text = content[-LOG_PREVIEW_LIMIT:]
truncated = total_length > LOG_PREVIEW_LIMIT
details_lines = [
"🧾 <b>Системные логи</b>",
"",
f"📁 <b>Файл:</b> <code>{log_path}</code>",
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
f"🧮 <b>Размер:</b> {total_length} символов",
(
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
if truncated
else "📄 Показано все содержимое файла."
),
"",
_format_preview_block(preview_text),
]
return "\n".join(details_lines)
def _get_logs_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
]
)
@admin_required
@error_handler
async def show_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
@admin_required
@error_handler
async def refresh_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("🔄 Обновлено")
@admin_required
@error_handler
async def download_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
if not log_path.exists() or not log_path.is_file():
await callback.answer("❌ Лог-файл не найден", show_alert=True)
return
try:
await callback.answer("⬇️ Отправляю лог...")
document = FSInputFile(log_path)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
caption = (
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
f"📁 Путь: <code>{log_path}</code>\n"
f"🕒 Обновлен: {updated_at}"
)
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
except Exception as error: # pragma: no cover - защита от ошибок отправки
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
await callback.message.answer(
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
"Проверьте журналы приложения или повторите попытку позже.",
parse_mode="HTML",
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_system_logs,
F.data == "admin_system_logs",
)
dp.callback_query.register(
refresh_system_logs,
F.data == "admin_system_logs_refresh",
)
dp.callback_query.register(
download_system_logs,
F.data == "admin_system_logs_download",
)
+95 -4
View File
@@ -79,12 +79,16 @@ async def show_admin_tickets(
ticket_data = []
for ticket in tickets:
user_name = ticket.user.full_name if ticket.user else "Unknown"
username = ticket.user.username if ticket.user else None
telegram_id = ticket.user.telegram_id if ticket.user else None
ticket_data.append({
'id': ticket.id,
'title': ticket.title,
'status_emoji': ticket.status_emoji,
'priority_emoji': ticket.priority_emoji,
'user_name': user_name,
'username': username,
'telegram_id': telegram_id,
'is_closed': ticket.is_closed,
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
})
@@ -169,9 +173,13 @@ async def view_admin_ticket(
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
ticket_text += f"📱 Username: @{username_display}\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -201,6 +209,33 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
# Кнопка открытия профиля пользователя в админке
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}"
)
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id:
buttons_row = []
# DM: при наличии username используем tg://resolve, иначе fallback по ID
if ticket.user.username:
pm_url = f"tg://resolve?domain={ticket.user.username}"
else:
pm_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
# Профиль: по ID
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
if has_photos:
try:
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
@@ -445,6 +480,17 @@ async def close_admin_ticket(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
# обогатим details контактами пользователя тикета
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -453,7 +499,7 @@ async def close_admin_ticket(
action="close_ticket",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -645,6 +691,31 @@ async def handle_admin_block_duration_input(
ticket_text += "📎 Вложение: фото\n\n"
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
# Кнопка открытия профиля пользователя в админке
try:
if updated.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{updated.user.id}_from_ticket_{updated.id}"
)
kb.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id:
buttons_row = []
if updated.user.username:
pm_url = f"tg://resolve?domain={updated.user.username}"
else:
pm_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
kb.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
if has_photos:
try:
@@ -699,6 +770,16 @@ async def unblock_user_in_ticket(
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -707,7 +788,7 @@ async def unblock_user_in_ticket(
action="unblock_user",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -741,6 +822,16 @@ async def block_user_permanently(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -749,7 +840,7 @@ async def block_user_permanently(
action="block_user_perm",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
+1013 -112
View File
File diff suppressed because it is too large Load Diff
+30 -31
View File
@@ -3,7 +3,6 @@ from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.states import AdminStates
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
@@ -44,16 +43,16 @@ async def show_welcome_text_panel(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -89,11 +88,11 @@ async def show_current_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
is_enabled = settings['is_enabled']
if not settings['id']:
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
is_enabled = welcome_settings['is_enabled']
if not welcome_settings['id']:
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
@@ -121,7 +120,7 @@ async def show_placeholders_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
@@ -137,7 +136,7 @@ async def show_placeholders_help(
await callback.message.edit_text(
help_text,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -149,12 +148,12 @@ async def show_formatting_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -167,8 +166,8 @@ async def start_edit_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
@@ -206,9 +205,9 @@ async def process_welcome_text_edit(
success = await set_welcome_text(db, new_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
@@ -219,14 +218,14 @@ async def process_welcome_text_edit(
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await state.clear()
@@ -242,9 +241,9 @@ async def reset_welcome_text(
success = await set_welcome_text(db, default_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\n\n"
@@ -252,14 +251,14 @@ async def reset_welcome_text(
f"Стандартный текст:\n"
f"<code>{default_text}</code>\n\n"
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await callback.answer()
@@ -281,14 +280,14 @@ async def show_preview_welcome_text(
test_user = TestUser()
preview_text = await get_welcome_text_for_user(db, test_user)
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
if preview_text:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
@@ -296,7 +295,7 @@ async def show_preview_welcome_text(
f"👁️ Предварительный просмотр\n\n"
f"🔴 Приветственные сообщения отключены.\n"
f"Новые пользователи не будут получать приветственный текст после регистрации.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
+166 -37
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -386,9 +387,9 @@ async def start_pal24_payment(
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
"Оплата проходит через защищенную платформу PayPalych."
"Оплата проходит через систему быстрых платежей PayPalych."
),
)
@@ -1002,7 +1003,7 @@ async def process_pal24_payment_amount(
language=db_user.language,
)
if not payment_result or not payment_result.get("link_url"):
if not payment_result:
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
@@ -1012,46 +1013,146 @@ async def process_pal24_payment_amount(
await state.clear()
return
link_url = payment_result.get("link_url")
sbp_url = (
payment_result.get("sbp_url")
or payment_result.get("transfer_url")
)
card_url = payment_result.get("card_url")
fallback_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
if not (sbp_url or card_url or fallback_url):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
"❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
if not sbp_url:
sbp_url = fallback_url
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
url=link_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
pay_buttons: list[list[types.InlineKeyboardButton]] = []
steps: list[str] = []
step_counter = 1
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_url:
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_url and card_url != sbp_url:
pay_buttons.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(card_button_text))
)
step_counter += 1
if not pay_buttons and fallback_url:
pay_buttons.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=fallback_url,
)
]
)
steps.append(
texts.t(
"PAL24_INSTRUCTION_BUTTON",
"{step}. Нажмите кнопку «{button}»",
).format(step=step_counter, button=html.escape(sbp_button_text))
)
step_counter += 1
follow_template = texts.t(
"PAL24_INSTRUCTION_FOLLOW",
"{step}. Следуйте подсказкам платёжной системы",
)
steps.append(follow_template.format(step=step_counter))
step_counter += 1
confirm_template = texts.t(
"PAL24_INSTRUCTION_CONFIRM",
"{step}. Подтвердите перевод",
)
steps.append(confirm_template.format(step=step_counter))
step_counter += 1
success_template = texts.t(
"PAL24_INSTRUCTION_COMPLETE",
"{step}. Средства зачислятся автоматически",
)
steps.append(success_template.format(step=step_counter))
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"🏦 <b>Оплата через PayPalych</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку ‘Оплатить через PayPalych’\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"📱 <b>Инструкция:</b>\n{steps}\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
keyboard_rows = pay_buttons + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = message_template.format(
amount=settings.format_price(amount_kopeks),
bill_id=bill_id,
steps="\n".join(steps),
support=settings.get_support_contact_display_html(),
)
@@ -1226,26 +1327,48 @@ async def check_pal24_payment_status(
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
metadata = payment.metadata_json or {}
links_meta = metadata.get("links") if isinstance(metadata, dict) else None
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
message_lines = [
"💳 Статус платежа PayPalych:\n\n",
f"🆔 ID счета: {payment.bill_id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n",
"🏦 Статус платежа PayPalych:",
"",
f"🆔 ID счета: {payment.bill_id}",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}",
f"📊 Статус: {emoji} {status_text}",
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}",
]
if payment.is_paid:
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
message_lines.append("")
message_lines.append("✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if payment.link_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
message_lines.append("")
message_lines.append("⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if sbp_link:
message_lines.append("")
message_lines.append(f"🏦 СБП: {sbp_link}")
if card_link and card_link != sbp_link:
message_lines.append(f"💳 Банковская карта: {card_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append("")
message_lines.append(
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
f"❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
)
await callback.answer("".join(message_lines), show_alert=True)
await callback.answer()
await callback.message.answer(
"\n".join(message_lines),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка проверки статуса PayPalych: {e}")
@@ -1528,6 +1651,12 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
+877 -25
View File
@@ -1,15 +1,27 @@
import html
import logging
from decimal import Decimal
from typing import Dict, List
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.database.crud.promo_group import (
get_auto_assign_promo_groups,
has_auto_assign_promo_groups,
)
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.keyboards.inline import (
get_main_menu_keyboard,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.models import PromoGroup, User
from app.database.crud.user_message import get_random_active_message
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
@@ -17,18 +29,126 @@ from app.services.subscription_checkout_service import (
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
def _format_rubles(amount_kopeks: int) -> str:
rubles = Decimal(amount_kopeks) / Decimal(100)
if rubles == rubles.to_integral_value():
formatted = f"{rubles:,.0f}"
else:
formatted = f"{rubles:,.2f}"
return f"{formatted.replace(',', ' ')}"
def _collect_period_discounts(group: PromoGroup) -> Dict[int, int]:
discounts: Dict[int, int] = {}
raw_discounts = getattr(group, "period_discounts", None)
if isinstance(raw_discounts, dict):
for key, value in raw_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized_percent = max(0, min(100, percent))
if normalized_percent > 0:
discounts[period] = normalized_percent
if group.is_default and settings.is_base_promo_group_period_discount_enabled():
try:
base_discounts = settings.get_base_promo_group_period_discounts() or {}
except Exception:
base_discounts = {}
for key, value in base_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
if period in discounts:
continue
normalized_percent = max(0, min(100, percent))
if normalized_percent > 0:
discounts[period] = normalized_percent
return dict(sorted(discounts.items()))
def _build_group_discount_lines(group: PromoGroup, texts, language: str) -> list[str]:
lines: list[str] = []
if getattr(group, "server_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_SERVERS", "🌍 Серверы: {percent}%").format(
percent=group.server_discount_percent
)
)
if getattr(group, "traffic_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_TRAFFIC", "📊 Трафик: {percent}%").format(
percent=group.traffic_discount_percent
)
)
if getattr(group, "device_discount_percent", 0) > 0:
lines.append(
texts.t("PROMO_GROUP_DISCOUNT_DEVICES", "📱 Доп. устройства: {percent}%").format(
percent=group.device_discount_percent
)
)
period_discounts = _collect_period_discounts(group)
if period_discounts:
lines.append(
texts.t(
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER",
"⏳ Скидки за длительный период:",
)
)
for period_days, percent in period_discounts.items():
lines.append(
texts.t(
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM",
"{period}{percent}%",
).format(
period=format_period_description(period_days, language),
percent=percent,
)
)
return lines
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
db: AsyncSession,
*,
skip_callback_answer: bool = False,
):
texts = get_texts(db_user.language)
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
@@ -43,39 +163,42 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)
await callback.answer()
async def mark_user_as_had_paid_subscription(
db: AsyncSession,
user: User
) -> None:
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
if not skip_callback_answer:
await callback.answer()
async def show_service_rules(
callback: types.CallbackQuery,
db_user: User,
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
@@ -95,6 +218,642 @@ async def show_service_rules(
await callback.answer()
async def show_info_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
prompt = texts.t("MENU_INFO_PROMPT", "Выберите раздел:")
caption = f"{header}\n\n{prompt}" if prompt else header
privacy_enabled = await PrivacyPolicyService.is_policy_enabled(db, db_user.language)
public_offer_enabled = await PublicOfferService.is_offer_enabled(db, db_user.language)
faq_enabled = await FaqService.is_enabled(db, db_user.language)
promo_groups_available = await has_auto_assign_promo_groups(db)
await edit_or_answer_photo(
callback=callback,
caption=caption,
keyboard=get_info_menu_keyboard(
language=db_user.language,
show_privacy_policy=privacy_enabled,
show_public_offer=public_offer_enabled,
show_faq=faq_enabled,
show_promo_groups=promo_groups_available,
),
parse_mode="HTML",
)
await callback.answer()
async def show_promo_groups_info(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
promo_groups = await get_auto_assign_promo_groups(db)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]]
)
if not promo_groups:
empty_text = texts.t(
"PROMO_GROUPS_INFO_EMPTY",
"Промогруппы с автовыдачей ещё не настроены.",
)
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
message = f"{header}\n\n{empty_text}" if empty_text else header
await callback.message.edit_text(
message,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
return
total_spent_kopeks = await get_user_total_spent_kopeks(db, db_user.id)
total_spent_text = _format_rubles(total_spent_kopeks)
sorted_groups = sorted(
promo_groups,
key=lambda group: (group.auto_assign_total_spent_kopeks or 0, group.id),
)
achieved_groups: List[PromoGroup] = [
group
for group in sorted_groups
if (group.auto_assign_total_spent_kopeks or 0) > 0
and total_spent_kopeks >= (group.auto_assign_total_spent_kopeks or 0)
]
current_group = next(
(group for group in sorted_groups if group.id == db_user.promo_group_id),
None,
)
if not current_group and achieved_groups:
current_group = achieved_groups[-1]
next_group = next(
(
group
for group in sorted_groups
if (group.auto_assign_total_spent_kopeks or 0) > total_spent_kopeks
),
None,
)
header = texts.t("PROMO_GROUPS_INFO_HEADER", "🎯 <b>Промогруппы</b>")
lines: List[str] = [header, ""]
spent_line = texts.t(
"PROMO_GROUPS_INFO_TOTAL_SPENT",
"💰 Потрачено в боте: {amount}",
).format(amount=total_spent_text)
lines.append(spent_line)
if current_group:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_CURRENT_LEVEL",
"🏆 Текущий уровень: {name}",
).format(name=html.escape(current_group.name)),
)
else:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_NO_LEVEL",
"🏆 Текущий уровень: пока не получен",
)
)
if next_group:
remaining_kopeks = (next_group.auto_assign_total_spent_kopeks or 0) - total_spent_kopeks
lines.append(
texts.t(
"PROMO_GROUPS_INFO_NEXT_LEVEL",
"📈 До уровня «{name}»: осталось {amount}",
).format(
name=html.escape(next_group.name),
amount=_format_rubles(max(remaining_kopeks, 0)),
)
)
else:
lines.append(
texts.t(
"PROMO_GROUPS_INFO_MAX_LEVEL",
"🏆 Вы уже получили максимальный уровень скидок!",
)
)
lines.extend(["", texts.t("PROMO_GROUPS_INFO_LEVELS_HEADER", "📋 Уровни с автовыдачей:")])
for group in sorted_groups:
threshold = group.auto_assign_total_spent_kopeks or 0
status_icon = "" if total_spent_kopeks >= threshold else "🔒"
lines.append(
texts.t(
"PROMO_GROUPS_INFO_LEVEL_LINE",
"{status} <b>{name}</b> — от {amount}",
).format(
status=status_icon,
name=html.escape(group.name),
amount=_format_rubles(threshold),
)
)
discount_lines = _build_group_discount_lines(group, texts, db_user.language)
for discount_line in discount_lines:
if discount_line:
lines.append(f" {discount_line}")
lines.append("")
while lines and not lines[-1]:
lines.pop()
message_text = "\n".join(lines)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer()
async def show_faq_pages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
if not pages:
await callback.answer(
texts.t("FAQ_NOT_AVAILABLE", "FAQ временно недоступен."),
show_alert=True,
)
return
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
prompt = texts.t("FAQ_PAGES_PROMPT", "Выберите вопрос:" )
caption = f"{header}\n\n{prompt}" if prompt else header
buttons: list[list[types.InlineKeyboardButton]] = []
for index, page in enumerate(pages, start=1):
raw_title = (page.title or "").strip()
if not raw_title:
raw_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
if len(raw_title) > 60:
raw_title = f"{raw_title[:57]}..."
buttons.append([
types.InlineKeyboardButton(
text=f"{index}. {raw_title}",
callback_data=f"menu_faq_page:{page.id}:1",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
async def show_faq_page(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_data = callback.data or ""
parts = raw_data.split(":")
page_id = None
requested_page = 1
if len(parts) >= 2:
try:
page_id = int(parts[1])
except ValueError:
page_id = None
if len(parts) >= 3:
try:
requested_page = int(parts[2])
except ValueError:
requested_page = 1
if not page_id:
await callback.answer()
return
page = await FaqService.get_page(db, page_id, db_user.language)
if not page or not page.is_active:
await callback.answer(
texts.t("FAQ_PAGE_NOT_AVAILABLE", "Эта страница FAQ недоступна."),
show_alert=True,
)
return
content_pages = FaqService.split_content_into_pages(page.content)
if not content_pages:
await callback.answer(
texts.t("FAQ_PAGE_EMPTY", "Текст для этой страницы ещё не добавлен."),
show_alert=True,
)
return
total_pages = len(content_pages)
current_page = max(1, min(requested_page, total_pages))
header = texts.t("FAQ_HEADER", "❓ <b>FAQ</b>")
title_template = texts.t("FAQ_PAGE_TITLE", "<b>{title}</b>")
page_title = (page.title or "").strip()
if not page_title:
page_title = texts.t("FAQ_PAGE_UNTITLED", "Без названия")
title_block = title_template.format(title=html.escape(page_title))
body = content_pages[current_page - 1]
footer_template = texts.t(
"FAQ_PAGE_FOOTER",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
parts_to_join = [header, title_block]
if body:
parts_to_join.append(body)
if footer:
parts_to_join.append(f"<code>{footer}</code>")
message_text = "\n\n".join(segment for segment in parts_to_join if segment)
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_faq_page:{page.id}:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_faq_page:{page.id}:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text=texts.t("FAQ_BACK_TO_LIST", "⬅️ К списку FAQ"),
callback_data="menu_faq",
)
])
keyboard_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")
])
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_privacy_policy(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
policy = await PrivacyPolicyService.get_active_policy(db, db_user.language)
if not policy:
await callback.answer(
texts.t(
"PRIVACY_POLICY_NOT_AVAILABLE",
"Политика конфиденциальности временно недоступна.",
),
show_alert=True,
)
return
pages = PrivacyPolicyService.split_content_into_pages(policy.content)
if not pages:
await callback.answer(
texts.t(
"PRIVACY_POLICY_EMPTY_ALERT",
"Политика конфиденциальности ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PRIVACY_POLICY_HEADER",
"🛡️ <b>Политика конфиденциальности</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PRIVACY_POLICY_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_privacy_policy:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_privacy_policy:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_public_offer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
raw_page = 1
if callback.data and ":" in callback.data:
try:
raw_page = int(callback.data.split(":", 1)[1])
except ValueError:
raw_page = 1
if raw_page < 1:
raw_page = 1
offer = await PublicOfferService.get_active_offer(db, db_user.language)
if not offer:
await callback.answer(
texts.t(
"PUBLIC_OFFER_NOT_AVAILABLE",
"Публичная оферта временно недоступна.",
),
show_alert=True,
)
return
pages = PublicOfferService.split_content_into_pages(offer.content)
if not pages:
await callback.answer(
texts.t(
"PUBLIC_OFFER_EMPTY_ALERT",
"Публичная оферта ещё не заполнена.",
),
show_alert=True,
)
return
total_pages = len(pages)
current_page = raw_page if raw_page <= total_pages else total_pages
header = texts.t(
"PUBLIC_OFFER_HEADER",
"📄 <b>Публичная оферта</b>",
)
body = pages[current_page - 1]
footer_template = texts.t(
"PUBLIC_OFFER_PAGE_INFO",
"Страница {current} из {total}",
)
footer = ""
if total_pages > 1 and footer_template:
try:
footer = footer_template.format(current=current_page, total=total_pages)
except Exception:
footer = f"{current_page}/{total_pages}"
message_text = header
if body:
message_text += f"\n\n{body}"
if footer:
message_text += f"\n\n<code>{footer}</code>"
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
if total_pages > 1:
nav_row: list[types.InlineKeyboardButton] = []
if current_page > 1:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"menu_public_offer:{current_page - 1}",
)
)
nav_row.append(
types.InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="noop",
)
)
if current_page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"menu_public_offer:{current_page + 1}",
)
)
keyboard_rows.append(nav_row)
keyboard_rows.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_info")]
)
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
await edit_or_answer_photo(
callback=callback,
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
keyboard=get_language_selection_keyboard(
current_language=db_user.language,
include_back=True,
language=db_user.language,
),
parse_mode="HTML",
)
await callback.answer()
async def process_language_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
if db_user.language.lower() == normalized_selected:
await show_main_menu(
callback,
db_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
return
updated_user = await update_user(db, db_user, language=resolved_language)
texts = get_texts(updated_user.language)
await show_main_menu(
callback,
updated_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
async def handle_back_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
@@ -116,19 +875,32 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)
@@ -215,9 +987,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
@@ -240,3 +1041,54 @@ def register_handlers(dp: Dispatcher):
show_service_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
show_info_menu,
F.data == "menu_info",
)
dp.callback_query.register(
show_promo_groups_info,
F.data == "menu_info_promo_groups",
)
dp.callback_query.register(
show_faq_pages,
F.data == "menu_faq",
)
dp.callback_query.register(
show_faq_page,
F.data.startswith("menu_faq_page:"),
)
dp.callback_query.register(
show_privacy_policy,
F.data == "menu_privacy_policy",
)
dp.callback_query.register(
show_privacy_policy,
F.data.startswith("menu_privacy_policy:"),
)
dp.callback_query.register(
show_public_offer,
F.data == "menu_public_offer",
)
dp.callback_query.register(
show_public_offer,
F.data.startswith("menu_public_offer:"),
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
)
dp.callback_query.register(
process_language_change,
F.data.startswith("language_select:"),
StateFilter(None)
)
+16
View File
@@ -9,6 +9,7 @@ from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@@ -61,6 +62,21 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
+364 -63
View File
@@ -19,15 +19,26 @@ from app.database.crud.campaign import (
)
from app.database.models import UserStatus, SubscriptionStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
get_rules_keyboard,
get_main_menu_keyboard,
get_post_registration_keyboard,
get_language_selection_keyboard,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
logger = logging.getLogger(__name__)
@@ -72,8 +83,8 @@ async def _apply_campaign_bonus_if_needed(
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
current_state = await state.get_state()
@@ -133,7 +144,80 @@ async def handle_potential_referral_code(
else:
await complete_registration(message, state, db)
return True
return True
def _get_language_prompt_text() -> str:
return "🌐 Выберите язык / Choose your language:"
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
await state.set_state(RegistrationStates.waiting_for_language)
await message.answer(
_get_language_prompt_text(),
reply_markup=get_language_selection_keyboard(),
)
async def _continue_registration_after_language(
*,
message: types.Message | None,
callback: types.CallbackQuery | None,
state: FSMContext,
db: AsyncSession,
) -> None:
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
target_message = callback.message if callback else message
if not target_message:
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
return
async def _complete_registration_wrapper():
if callback:
await complete_registration_from_callback(callback, state, db)
else:
await complete_registration(message, state, db)
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
await _complete_registration_wrapper()
else:
try:
await target_message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
except Exception as error:
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
await _complete_registration_wrapper()
return
rules_text = await get_rules(language)
await target_message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
@@ -165,6 +249,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if campaign:
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_campaign_link_visit_notification(
db,
message.from_user,
campaign,
user,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
campaign.id,
notify_error,
)
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
@@ -229,17 +329,31 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
user.telegram_id
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -258,6 +372,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
from sqlalchemy import delete
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
@@ -309,55 +424,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен язык по умолчанию: {language}")
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
if not data.get('language'):
if settings.is_language_selection_enabled():
await _prompt_language_selection(message, state)
return
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
await complete_registration(message, state, db)
else:
try:
await message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split("-")[0].lower()
data['language'] = normalized_default
await state.set_data(data)
logger.info(
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
normalized_default,
)
await _continue_registration_after_language(
message=message,
callback=None,
state=state,
db=db,
)
async def process_language_selection(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
):
logger.info(
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
)
if not settings.is_language_selection_enabled():
data = await state.get_data() or {}
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split("-")[0].lower()
data['language'] = normalized_default
await state.set_data(data)
texts = get_texts(normalized_default)
try:
await callback.message.edit_text(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info("🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration(message, state, db)
)
except Exception:
await callback.message.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
)
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
return
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
logger.warning(
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
)
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
data = await state.get_data() or {}
data['language'] = resolved_language
await state.set_data(data)
texts = get_texts(resolved_language)
try:
await callback.message.edit_text(
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
)
except Exception as error:
logger.warning(
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
await callback.message.answer(
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
async def process_rules_accept(
callback: types.CallbackQuery,
state: FSMContext,
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
@@ -562,18 +750,33 @@ async def complete_registration_from_callback(
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -727,18 +930,33 @@ async def complete_registration_from_callback(
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -786,18 +1004,33 @@ async def complete_registration(
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(existing_user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -951,18 +1184,33 @@ async def complete_registration(
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1070,13 +1318,43 @@ def get_referral_code_keyboard(language: str):
async def get_main_menu_text(user, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
user_name=html.escape(user.full_name or ""),
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
getattr(user, "id", None),
hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
getattr(user, "id", None),
test_error,
)
if info_sections:
extra_block = "\n\n".join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
@@ -1089,8 +1367,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=user_name,
user_name=html.escape(user_name or ""),
subscription_status=_get_subscription_status_simple(texts)
)
@@ -1189,14 +1468,29 @@ async def required_sub_channel_check(
from app.utils.message_patch import LOGO_PATH
from aiogram.types import FSInputFile
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (
(not is_admin)
and SupportSettingsService.is_moderator(user.telegram_id)
)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
@@ -1291,7 +1585,14 @@ def register_handlers(dp: Dispatcher):
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_language_selection,
F.data.startswith("language_select:"),
StateFilter(RegistrationStates.waiting_for_language)
)
logger.info("✅ Зарегистрирован process_language_selection")
dp.callback_query.register(
process_referral_code_skip,
F.data == "referral_skip",
+2108 -1258
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.user import get_user_by_id
from app.keyboards.inline import (
get_ticket_cancel_keyboard,
get_my_tickets_keyboard,
@@ -411,7 +412,6 @@ async def show_my_tickets(
# Добавим кнопку перехода к закрытым
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")])
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
@@ -455,7 +455,6 @@ async def show_my_tickets_closed(
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_")
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")])
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
@@ -937,10 +936,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
if len(title) > 60:
title = title[:57] + "..."
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else "Unknown"
telegram_id_display = user.telegram_id if user else ""
username_display = (user.username or "отсутствует") if user else "отсутствует"
notification_text = (
f"🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n"
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"📅 <b>Создан:</b> {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
)
+1030 -317
View File
File diff suppressed because it is too large Load Diff
+194 -51
View File
@@ -17,6 +17,11 @@ import logging
logger = logging.getLogger(__name__)
_LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский",
"en": "🇬🇧 English",
}
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
@@ -62,6 +67,49 @@ def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKe
])
def get_language_selection_keyboard(
current_language: Optional[str] = None,
*,
include_back: bool = False,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
available_languages = settings.get_available_languages()
buttons: List[List[InlineKeyboardButton]] = []
row: List[InlineKeyboardButton] = []
normalized_current = (current_language or "").lower()
for index, lang_code in enumerate(available_languages, start=1):
normalized_code = lang_code.lower()
display_name = _LANGUAGE_DISPLAY_NAMES.get(
normalized_code,
normalized_code.upper(),
)
prefix = "" if normalized_code == normalized_current and normalized_current else ""
row.append(
InlineKeyboardButton(
text=f"{prefix}{display_name}",
callback_data=f"language_select:{normalized_code}",
)
)
if len(row) == 2:
buttons.append(row)
row = []
if row:
buttons.append(row)
if include_back:
texts = get_texts(language)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_main_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
is_admin: bool = False,
@@ -73,6 +121,7 @@ def get_main_menu_keyboard(
show_resume_checkout: bool = False,
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -182,6 +231,11 @@ def get_main_menu_keyboard(
)
])
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
keyboard.extend([
[
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
@@ -189,41 +243,28 @@ def get_main_menu_keyboard(
]
])
# Server status button
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(text=server_status_text, url=status_url)
])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
keyboard.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
keyboard.append([
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
])
# Support button is configurable (runtime via service)
try:
from app.services.support_settings_service import SupportSettingsService
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
support_row = []
if support_enabled:
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
keyboard.append(support_row)
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
if settings.is_language_selection_enabled():
keyboard.append([
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -245,6 +286,82 @@ def get_main_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_info_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
show_privacy_policy: bool = False,
show_public_offer: bool = False,
show_faq: bool = False,
show_promo_groups: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
buttons: List[List[InlineKeyboardButton]] = []
if show_faq:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_FAQ", "❓ FAQ"),
callback_data="menu_faq",
)
])
if show_promo_groups:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PROMO_GROUPS_INFO", "🎯 Промогруппы"),
callback_data="menu_info_promo_groups",
)
])
if show_privacy_policy:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PRIVACY_POLICY", "🛡️ Политика конф."),
callback_data="menu_privacy_policy",
)
])
if show_public_offer:
buttons.append([
InlineKeyboardButton(
text=texts.t("MENU_PUBLIC_OFFER", "📄 Оферта"),
callback_data="menu_public_offer",
)
])
buttons.append([
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
])
server_status_mode = settings.get_server_status_mode()
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
if server_status_mode == "external_link":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([InlineKeyboardButton(text=server_status_text, url=status_url)])
elif server_status_mode == "external_link_miniapp":
status_url = settings.get_server_status_external_url()
if status_url:
buttons.append([
InlineKeyboardButton(
text=server_status_text,
web_app=types.WebAppInfo(url=status_url),
)
])
elif server_status_mode == "xray":
buttons.append([
InlineKeyboardButton(
text=server_status_text,
callback_data="menu_server_status",
)
])
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
if not settings.is_happ_download_button_enabled():
return None
@@ -392,28 +509,31 @@ def get_insufficient_balance_keyboard(
texts = get_texts(language)
keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language)
if resume_callback:
keyboard.inline_keyboard.insert(
0,
[
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
],
)
back_row_index: int | None = None
if keyboard.inline_keyboard:
last_row = keyboard.inline_keyboard[-1]
if (
len(last_row) == 1
and isinstance(last_row[0], InlineKeyboardButton)
and last_row[0].callback_data == "menu_balance"
and last_row[0].callback_data in {"menu_balance", "back_to_menu"}
):
keyboard.inline_keyboard[-1][0] = InlineKeyboardButton(
text=last_row[0].text,
text=texts.t("PAYMENT_RETURN_HOME_BUTTON", "🏠 На главную"),
callback_data="back_to_menu",
)
back_row_index = len(keyboard.inline_keyboard) - 1
if resume_callback:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
return keyboard
@@ -829,7 +949,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
text=texts.t("PAYMENT_CARD_PAL24", "🏦 СБП (PayPalych)"),
callback_data=_build_callback("pal24")
)
])
@@ -1590,14 +1710,25 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
available_periods = settings.get_available_renewal_periods()
for days in available_periods:
if days in prices:
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
callback_data=f"extend_period_{days}"
)
])
if days not in prices:
continue
price_info = prices[days]
if isinstance(price_info, dict):
final_price = price_info.get("final")
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(final_price)}",
callback_data=f"extend_period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
@@ -1922,10 +2053,22 @@ def get_admin_tickets_keyboard(
status_emoji = ticket.get('status_emoji', '')
if ticket.get('is_closed', False):
status_emoji = ''
user_name = ticket.get('user_name', 'Unknown')[:15]
user_name = ticket.get('user_name', 'Unknown')
username = ticket.get('username')
telegram_id = ticket.get('telegram_id')
# Сформируем компактное отображение: Имя (@username | ID)
name_parts = [user_name[:15]]
contact_parts = []
if username:
contact_parts.append(f"@{username}")
if telegram_id:
contact_parts.append(str(telegram_id))
if contact_parts:
name_parts.append(f"({' | '.join(contact_parts)})")
name_display = ' '.join(name_parts)
title = ticket.get('title', 'Без названия')[:20]
locked_emoji = ticket.get('locked_emoji', '')
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ")
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {name_display}: {title}".replace(" ", " ")
row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")]
if ticket.get('is_closed', False):
closed_rows.append(row)
+356 -2
View File
@@ -1,19 +1,41 @@
{
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "️ Server management is unavailable — only one server is accessible",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n — will be added (paid)\n — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
"COUNTRY_CHANGES_ADDED_HEADER": " <b>Added countries:</b>\n",
"COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
"COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Removed countries:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "️ Reconnecting later will be charged",
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Active countries:</b> {count}",
"ADMIN_MAIN_MENU": "🏠 Main menu",
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
"AUTOPAY_BUTTON": "💳 Auto payment",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
"AUTOPAY_STATUS_ENABLED": "enabled",
"AUTOPAY_STATUS_DISABLED": "disabled",
"AUTOPAY_MENU_TEXT": "💳 <b>Auto payment</b>\n\n📊 <b>Status:</b> {status}\n⏰ <b>Charge:</b> {days} days before expiry\n\nChoose an action:",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
"AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
"BACK": "⬅️ Back",
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
"CANCEL": "❌ Cancel",
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
"CONFIRM": "✅ Confirm",
@@ -48,12 +70,35 @@
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
"DEVICE_NONE_CONNECTED": "️ You have no connected devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Device management</b>\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Connected devices:</b>\n",
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Actions:</b>\n• Select a device to reset\n• Or reset all devices at once",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
"DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
"DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
"DEVICE_RESET_ALL_DONE": "️ All devices have been reset",
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
"DEVICE_RESET_NOT_FOUND": "❌ Device not found",
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
"DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Devices reset partially</b>\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
"MENU_BALANCE": "💰 Balance",
"MENU_SUBSCRIPTION": "📱 Subscription",
"MENU_TRIAL": "🎁 Trial subscription",
@@ -96,6 +141,9 @@
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
"SKIP_BUTTON": "Skip ➡️",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
@@ -161,6 +209,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>User management</b>\n\n<b>Main information:</b>\n• Name: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\n<b>Finances:</b>\n• Balance: {balance}\n• Transactions: {transactions}\n\n<b>Activity:</b>\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
"ADMIN_USER_USERNAME_NOT_SET": "not set",
"ADMIN_USER_STATUS_ACTIVE": "✅ Active",
"ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
"ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
"ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Subscription:</b>\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Subscription:</b> None",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
@@ -213,17 +277,44 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CONTACT_SUPPORT": "💬 Contact support",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram blocked the contact request button because of your privacy settings.</b>\n\nAllow sharing your contact details in Telegram settings or send the required information manually in the chat.",
"CREATE_INVITE": "📝 Create invite",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
"DEVICES_NO_CHANGE": "️ Device limit was not changed",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"DEVICE_CHANGE_FREE": "Free",
"DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}",
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
"DEVICE_CHANGE_NO_REFUND_INFO": "️ Payments are not refunded",
"INVALID_AMOUNT": "❌ Invalid amount",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_INFO": "️ Info",
"MENU_INFO_HEADER": "️ <b>Info</b>",
"MENU_INFO_PROMPT": "Choose a section:",
"MENU_PROMO_GROUPS_INFO": "🎯 Promo groups",
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Promo groups</b>",
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Spent in the bot: {amount}",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Current level: not unlocked yet",
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 Until level “{name}”: spend {amount} more",
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 You already have the highest discount level!",
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Auto-assigned levels:",
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — from {amount}",
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
@@ -297,7 +388,12 @@
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_SERVER_RANDOM_POOL": "🎲 Random choice among {count} servers",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Trial server",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"USER_NOT_FOUND": "❌ User not found",
"MENU_LANGUAGE": "🌐 Language",
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
@@ -330,7 +426,9 @@
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "️ The subscription link is available via the buttons below or in the “My subscription” section.",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\n️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
@@ -412,6 +510,7 @@
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
@@ -422,6 +521,261 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
"ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Server: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
"ADMIN_PROMO_OFFER_RESULT": "📬 Broadcast finished\nSent: {sent}\nFailed: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Skipped: {skipped} (already have access)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Buy subscription",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Extend subscription",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ Back to offer",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ Back to promo offers",
"ADMIN_REPORTS": "📊 Reports",
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:",
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
"ATTACHMENTS_SENT": "✅ Attachments sent.",
"BACK_TO_MENU": "🏠 Back to menu",
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
"BACK_TO_SUPPORT": "⬅️ Back to support",
"BACK_TO_TICKETS": "⬅️ Back to tickets",
"BALANCE_TOPUP": "💳 Top up balance",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
"CAMPAIGN_EXISTING_USERL": "️ This promotional link is available to new users only.",
"CANCEL_REPLY": "❌ Cancel reply",
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
"CLOSED_TICKETS": "🟢 Closed",
"CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
"CLOSED_TICKETS_TITLE": "🟢 Closed tickets:",
"CLOSE_NOTIFICATION": "❌ Close notification",
"CLOSE_TICKET": "🔒 Close ticket",
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
"DELETE_MESSAGE": "🗑 Delete",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "️ This discount has already been activated.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"PROMO_OFFER_CLOSE": "❌ Close",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
"TEST_ACCESS_ALREADY_CONNECTED": "️ This server is already connected to your subscription.",
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
"MARK_AS_ANSWERED": "✅ Mark as answered",
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with Mulen Pay\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
"MY_TICKETS_BUTTON": "📋 My tickets",
"MY_TICKETS_TITLE": "📋 Your tickets:",
"NOTIFICATION_CLOSED": "Notification closed.",
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
"NO_ATTACHMENTS": "No attachments.",
"NO_CLOSED_TICKETS": "There are no closed tickets yet.",
"NO_TICKETS": "You don't have any tickets yet.",
"NO_TICKETS_ADMIN": "No tickets to display.",
"OPEN_TICKETS": "🔴 Open",
"OPEN_TICKETS_HEADER": "🔴 Open tickets",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with PayPalych (SBP)\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"REPLY_TO_TICKET": "💬 Reply",
"REPORT_CLOSE": "❌ Close",
"REPORT_CLOSED": "✅ Report closed.",
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
"SUPPORT_BUTTON": "🆘 Support",
"TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.",
"TICKET_ATTACHMENTS": "📎 Attachments",
"TICKET_CLOSED": "✅ Ticket closed.",
"TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.",
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
"TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
"TICKET_NOT_FOUND": "Ticket not found.",
"TICKET_PRIORITY_HIGH": "🟠 High",
"TICKET_PRIORITY_LOW": "🟢 Low",
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
"TICKET_REPLY_INPUT": "Enter your reply:",
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
"TICKET_STATUS_ANSWERED": "Answered",
"TICKET_STATUS_CLOSED": "Closed",
"TICKET_STATUS_OPEN": "Open",
"TICKET_STATUS_PENDING": "Pending",
"TICKET_TITLE_INPUT": "Enter ticket title:",
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
"UNBLOCK": "✅ Unblock",
"USER_BLOCKED_FOREVER": "You are blocked from contacting support.",
"USER_BLOCKED_UNTIL": "You are blocked until {time}",
"VIEW_CLOSED_TICKETS": "🟢 Closed tickets",
"VIEW_TICKET": "👁️ View ticket",
"ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ <b>Support moderation</b>",
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Moderator audit</b>",
"ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Moderators: {count}",
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": " Assign moderator",
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": " Remove moderator",
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Support settings</b>",
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>SLA configuration</b>\n\nEnter the response wait time in minutes (integer > 0):",
"ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
"ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ <b>Assign moderator</b>\n\nSend the user's Telegram ID (number)",
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ <b>Remove moderator</b>\n\nSend the user's Telegram ID (number)",
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
"ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ <b>Moderators</b>",
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Editing support description</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Contact for \"Contact\" mode</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted",
"MENU_PUBLIC_OFFER": "📄 Offer",
"PUBLIC_OFFER_NOT_AVAILABLE": "Public offer is temporarily unavailable.",
"PUBLIC_OFFER_EMPTY_ALERT": "Public offer content is not provided yet.",
"PUBLIC_OFFER_HEADER": "📄 <b>Public Offer</b>",
"PUBLIC_OFFER_PAGE_INFO": "Page {current} of {total}",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Public offer</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "The public offer is shown in the “Info” section.",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Language: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Offer display is disabled or empty.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Offer is active and visible to users.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Offer is enabled but text is empty — users will not see it.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Last updated: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Text preview:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Text is not set yet.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Choose an action:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Edit text",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 View current text",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Enable display",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Disable display",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML help",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Current text (preview):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Send a new public offer text. HTML markup is allowed.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Use /html_help for the list of allowed tags.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Public offer editing",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Cancel",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Offer text is too long. Maximum 4000 characters.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ HTML error: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Public offer updated.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ Back to offer settings",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Offer text is not set yet.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Current offer text</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Text shortened for display. Users will see the full version in the menu.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Offer enabled",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Offer disabled",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Back to editing",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Offer editing cancelled.",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Package enabled.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Package disabled.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "At least one package must remain.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Traffic package visibility",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Active packages: {items}",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "No active packages.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Tap a package to toggle its visibility."
}
+388 -2
View File
@@ -1,6 +1,20 @@
{
"ACCESS_DENIED": "❌ Доступ запрещен",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
"COUNTRY_CHANGES_ADDED_HEADER": " <b>Добавлены страны:</b>\n",
"COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)",
"COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Отключены страны:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Активных стран:</b> {count}",
"ADMIN_MAIN_MENU": "🏠 Главное меню",
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
"ADMIN_MESSAGES": "📨 Рассылки",
@@ -38,6 +52,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>Управление пользователем</b>\n\n<b>Основная информация:</b>\n• Имя: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\n<b>Финансы:</b>\n• Баланс: {balance}\n• Транзакций: {transactions}\n\n<b>Активность:</b>\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
"ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
"ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
"ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
"ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Подписка:</b>\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
@@ -72,6 +102,12 @@
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_STATUS_ENABLED": "включен",
"AUTOPAY_STATUS_DISABLED": "выключен",
"AUTOPAY_MENU_TEXT": "💳 <b>Автоплатеж</b>\n\n📊 <b>Статус:</b> {status}\n⏰ <b>Списание за:</b> {days} дн. до окончания\n\nВыберите действие:",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
"AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
"BACK": "⬅️ Назад",
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
"BALANCE_BUTTON": "💰 Баланс: {balance}",
@@ -93,6 +129,7 @@
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CANCEL": "❌ Отмена",
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
@@ -103,6 +140,7 @@
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
"CONFIRM": "✅ Подтвердить",
@@ -120,6 +158,7 @@
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"PRIVACY_RESTRICTED_BUTTON_HINT": "⚠️ <b>Telegram не позволяет боту запросить контакт из-за ваших настроек приватности.</b>\n\nРазрешите отправку контакта в настройках Telegram или поделитесь данными вручную в ответ на сообщение.",
"CONTINUE_BUTTON": "✅ Продолжить",
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
"CREATE_INVITE": "📝 Создать приглашение",
@@ -128,6 +167,19 @@
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"DEVICE_CHANGE_FREE": "Бесплатно",
"DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}",
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
@@ -144,18 +196,64 @@
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
"INVALID_AMOUNT": "❌ Неверная сумма",
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
"LOADING": "⏳ Загрузка...",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {servers}",
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
"DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Управление устройствами</b>\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n",
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Подключенные устройства:</b>\n",
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Действия:</b>\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы",
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
"DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса",
"DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!",
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
"DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено",
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
"DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Частичный сброс устройств</b>\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
"MENU_ADMIN": "⚙️ Админ-панель",
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_INFO": "️ Инфо",
"MENU_INFO_HEADER": "️ <b>Инфо</b>",
"MENU_INFO_PROMPT": "Выберите раздел:",
"MENU_PROMO_GROUPS_INFO": "🎯 Скидки за траты",
"PROMO_GROUPS_INFO_HEADER": "🎯 <b>Скидки за траты</b>",
"PROMO_GROUPS_INFO_TOTAL_SPENT": "💰 Потрачено в боте: {amount}",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
"PROMO_GROUPS_INFO_NO_LEVEL": "🏆 Текущий уровень: пока не получен",
"PROMO_GROUPS_INFO_NEXT_LEVEL": "📈 До уровня «{name}»: осталось {amount}",
"PROMO_GROUPS_INFO_MAX_LEVEL": "🏆 Вы уже получили максимальный уровень скидок!",
"PROMO_GROUPS_INFO_LEVELS_HEADER": "📋 Уровни с автовыдачей:",
"PROMO_GROUPS_INFO_LEVEL_LINE": "{status} <b>{name}</b> — от {amount}",
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
"MENU_PRIVACY_POLICY": "🛡️ Политика конф.",
"MENU_PUBLIC_OFFER": "📄 Оферта",
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
"PRIVACY_POLICY_EMPTY_ALERT": "Политика конфиденциальности ещё не заполнена.",
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
"PUBLIC_OFFER_NOT_AVAILABLE": "Публичная оферта временно недоступна.",
"PUBLIC_OFFER_EMPTY_ALERT": "Публичная оферта ещё не заполнена.",
"PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"PUBLIC_OFFER_PAGE_INFO": "Страница {current} из {total}",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -233,6 +331,9 @@
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
@@ -296,6 +397,9 @@
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
"USER_NOT_FOUND": "❌ Пользователь не найден",
@@ -332,7 +436,9 @@
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
@@ -414,6 +520,7 @@
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
@@ -424,6 +531,285 @@
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
"ADMIN_PROMO_OFFER_TEST_SQUAD_NAME": "Сервер: {name}",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
"ADMIN_PROMO_OFFER_RESULT": "📬 Рассылка завершена\nОтправлено: {sent}\nОшибок: {failed}",
"ADMIN_PROMO_OFFER_SKIPPED": "Пропущено: {skipped} (уже есть доступ)",
"ADMIN_PROMO_OFFER_CTA_BUY": "Купить подписку",
"ADMIN_PROMO_OFFER_CTA_EXTEND": "Продлить подписку",
"ADMIN_PROMO_OFFER_BACK_TO_TEMPLATE": "↩️ К предложению",
"ADMIN_PROMO_OFFER_BACK_TO_LIST": "⬅️ К промопредложениям",
"ADMIN_REPORTS": "📊 Отчеты",
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
"BACK_TO_MENU": "🏠 В главное меню",
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
"BACK_TO_SUPPORT": "⬅️ К поддержке",
"BACK_TO_TICKETS": "⬅️ К тикетам",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
"CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
"CANCEL_REPLY": "❌ Отменить ответ",
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
"CLOSED_TICKETS": "🟢 Закрытые",
"CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
"CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:",
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
"CLOSE_TICKET": "🔒 Закрыть тикет",
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
"DELETE_MESSAGE": "🗑 Удалить",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"PROMO_OFFER_CLOSE": "❌ Закрыть",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 <b>Скидка {percent}% активирована!</b> \n\nОна суммируется с другими скидками и автоматически применится при следующей оплате.",
"TEST_ACCESS_ALREADY_CONNECTED": "ℹ️ Этот сервер уже подключен к вашей подписке.",
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
"NO_ATTACHMENTS": "Вложений нет.",
"NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.",
"NO_TICKETS": "У вас пока нет тикетов.",
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
"OPEN_TICKETS": "🔴 Открытые",
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"REPLY_TO_TICKET": "💬 Ответить",
"REPORT_CLOSE": "❌ Закрыть",
"REPORT_CLOSED": "✅ Отчет закрыт.",
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
"SUPPORT_BUTTON": "🆘 Поддержка",
"TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.",
"TICKET_ATTACHMENTS": "📎 Вложения",
"TICKET_CLOSED": "✅ Тикет закрыт.",
"TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.",
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
"TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
"TICKET_NOT_FOUND": "Тикет не найден.",
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
"TICKET_PRIORITY_LOW": "🟢 Низкий",
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_STATUS_ANSWERED": "Отвечен",
"TICKET_STATUS_CLOSED": "Закрыт",
"TICKET_STATUS_OPEN": "Открыт",
"TICKET_STATUS_PENDING": "В ожидании",
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"UNBLOCK": "✅ Разблокировать",
"USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.",
"USER_BLOCKED_UNTIL": "Вы заблокированы до {time}",
"VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты",
"VIEW_TICKET": "👁️ Посмотреть тикет",
"ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ <b>Модерация поддержки</b>",
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Аудит модераторов</b>",
"ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
"ADMIN_PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"ADMIN_PRIVACY_POLICY_DESCRIPTION": "Политика конфиденциальности отображается в разделе «Инфо».",
"ADMIN_PRIVACY_POLICY_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PRIVACY_POLICY_STATUS_DISABLED": "⚠️ Показ политики выключен или текст отсутствует.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED": "✅ Политика активна и показывается пользователям.",
"ADMIN_PRIVACY_POLICY_STATUS_ENABLED_EMPTY": "⚠️ Политика включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PRIVACY_POLICY_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PRIVACY_POLICY_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PRIVACY_POLICY_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PRIVACY_POLICY_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PRIVACY_POLICY_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PRIVACY_POLICY_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PRIVACY_POLICY_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PRIVACY_POLICY_HTML_HELP": "️ HTML помощь",
"ADMIN_PRIVACY_POLICY_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PRIVACY_POLICY_EDIT_PROMPT": "Отправьте новый текст политики конфиденциальности. Допускается HTML-разметка.",
"ADMIN_PRIVACY_POLICY_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PRIVACY_POLICY_EDIT_TITLE": "Редактирование политики",
"ADMIN_PRIVACY_POLICY_CANCEL": "❌ Отмена",
"ADMIN_PRIVACY_POLICY_TOO_LONG": "❌ Текст политики слишком длинный. Максимум 4000 символов.",
"ADMIN_PRIVACY_POLICY_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PRIVACY_POLICY_SAVED": "✅ Политика конфиденциальности обновлена.",
"ADMIN_PRIVACY_POLICY_BACK_BUTTON": "⬅️ К настройкам политики",
"ADMIN_PRIVACY_POLICY_PREVIEW_EMPTY_ALERT": "Текст политики пока не задан.",
"ADMIN_PRIVACY_POLICY_VIEW_TITLE": "👀 <b>Текущий текст политики</b>",
"ADMIN_PRIVACY_POLICY_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PRIVACY_POLICY_ENABLED": "✅ Политика включена",
"ADMIN_PRIVACY_POLICY_DISABLED": "🚫 Политика отключена",
"ADMIN_PRIVACY_POLICY_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
"ADMIN_PUBLIC_OFFER_HEADER": "📄 <b>Публичная оферта</b>",
"ADMIN_PUBLIC_OFFER_DESCRIPTION": "Публичная оферта отображается в разделе «Инфо».",
"ADMIN_PUBLIC_OFFER_LANGUAGE": "Язык: <code>{lang}</code>",
"ADMIN_PUBLIC_OFFER_STATUS_DISABLED": "⚠️ Показ оферты выключен или текст отсутствует.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED": "✅ Оферта активна и показывается пользователям.",
"ADMIN_PUBLIC_OFFER_STATUS_ENABLED_EMPTY": "⚠️ Оферта включена, но текст пуст — пользователи её не увидят.",
"ADMIN_PUBLIC_OFFER_UPDATED_AT": "Последнее обновление: {timestamp}",
"ADMIN_PUBLIC_OFFER_PREVIEW_TITLE": "<b>Превью текста:</b>",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY": "Текст ещё не задан.",
"ADMIN_PUBLIC_OFFER_ACTION_PROMPT": "Выберите действие:",
"ADMIN_PUBLIC_OFFER_EDIT_BUTTON": "✏️ Изменить текст",
"ADMIN_PUBLIC_OFFER_VIEW_BUTTON": "👀 Просмотреть текущий текст",
"ADMIN_PUBLIC_OFFER_ENABLE_BUTTON": "✅ Включить показ",
"ADMIN_PUBLIC_OFFER_DISABLE_BUTTON": "🚫 Отключить показ",
"ADMIN_PUBLIC_OFFER_HTML_HELP": "️ HTML помощь",
"ADMIN_PUBLIC_OFFER_CURRENT_PREVIEW": "Текущий текст (превью):",
"ADMIN_PUBLIC_OFFER_EDIT_PROMPT": "Отправьте новый текст публичной оферты. Допускается HTML-разметка.",
"ADMIN_PUBLIC_OFFER_EDIT_HINT": "Используйте /html_help для справки по тегам.",
"ADMIN_PUBLIC_OFFER_EDIT_TITLE": "Редактирование оферты",
"ADMIN_PUBLIC_OFFER_CANCEL": "❌ Отмена",
"ADMIN_PUBLIC_OFFER_TOO_LONG": "❌ Текст оферты слишком длинный. Максимум 4000 символов.",
"ADMIN_PUBLIC_OFFER_HTML_ERROR": "❌ Ошибка в HTML: {error}",
"ADMIN_PUBLIC_OFFER_SAVED": "✅ Публичная оферта обновлена.",
"ADMIN_PUBLIC_OFFER_BACK_BUTTON": "⬅️ К настройкам оферты",
"ADMIN_PUBLIC_OFFER_PREVIEW_EMPTY_ALERT": "Текст оферты пока не задан.",
"ADMIN_PUBLIC_OFFER_VIEW_TITLE": "👀 <b>Текущий текст оферты</b>",
"ADMIN_PUBLIC_OFFER_VIEW_TRUNCATED": "\n\n⚠️ Текст сокращён для отображения. Полную версию увидят пользователи в меню.",
"ADMIN_PUBLIC_OFFER_ENABLED": "✅ Оферта включена",
"ADMIN_PUBLIC_OFFER_DISABLED": "🚫 Оферта отключена",
"ADMIN_PUBLIC_OFFER_RETURN_TO_EDIT": "⬅️ Назад к редактированию",
"ADMIN_PUBLIC_OFFER_EDIT_CANCELLED": "Редактирование оферты отменено.",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Модераторы: {count}",
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Настройки поддержки</b>",
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
"ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
"ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
"ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ <b>Модераторы</b>",
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Редактирование описания поддержки</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Контакт для режима «Контакт»</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE": "🚦 Отображение пакетов трафика",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE": "Активные пакеты: {items}",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE": "Активных пакетов нет.",
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT": "Нажмите на пакет, чтобы включить или выключить его отображение."
}
+12 -5
View File
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
from app.services.remnawave_service import RemnaWaveService
from app.states import RegistrationStates
from app.utils.check_reg_process import is_registration_process
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
and (
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
or event.data.startswith('language_select:')
)
)
)
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
profile_updated = True
if db_user.first_name != user.first_name:
safe_first = sanitize_telegram_name(user.first_name)
safe_last = sanitize_telegram_name(user.last_name)
if db_user.first_name != safe_first:
old_first_name = db_user.first_name
db_user.first_name = user.first_name
db_user.first_name = safe_first
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
profile_updated = True
if db_user.last_name != user.last_name:
if db_user.last_name != safe_last:
old_last_name = db_user.last_name
db_user.last_name = user.last_name
db_user.last_name = safe_last
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
profile_updated = True
+408 -160
View File
@@ -7,8 +7,17 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, Subscription, Transaction, TransactionType
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import get_user_by_id
from app.database.models import (
AdvertisingCampaign,
PromoCodeType,
PromoGroup,
Subscription,
Transaction,
TransactionType,
User,
)
logger = logging.getLogger(__name__)
@@ -25,20 +34,141 @@ class AdminNotificationService:
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
if not referred_by_id:
return "Нет"
try:
referrer = await get_user_by_id(db, referred_by_id)
if not referrer:
return f"ID {referred_by_id} (не найден)"
if referrer.username:
return f"@{referrer.username} (ID: {referred_by_id})"
else:
return f"ID {referrer.telegram_id}"
except Exception as e:
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
return f"ID {referred_by_id}"
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
if getattr(user, "promo_group", None):
return user.promo_group
if not user.promo_group_id:
return None
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
# relationship might not be available — fallback to direct fetch
pass
if getattr(user, "promo_group", None):
return user.promo_group
try:
return await get_promo_group_by_id(db, user.promo_group_id)
except Exception as e:
logger.error(
"Ошибка загрузки промогруппы %s пользователя %s: %s",
user.promo_group_id,
user.telegram_id,
e,
)
return None
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
discount_map = {
"servers": ("Серверы", promo_group.server_discount_percent),
"traffic": ("Трафик", promo_group.traffic_discount_percent),
"devices": ("Устройства", promo_group.device_discount_percent),
}
for _, (title, percent) in discount_map.items():
if percent and percent > 0:
discount_lines.append(f"{title}: -{percent}%")
period_discounts_raw = promo_group.period_discounts or {}
period_items: List[tuple[int, int]] = []
if isinstance(period_discounts_raw, dict):
for raw_days, raw_percent in period_discounts_raw.items():
try:
days = int(raw_days)
percent = int(raw_percent)
except (TypeError, ValueError):
continue
if percent > 0:
period_items.append((days, percent))
period_items.sort(key=lambda item: item[0])
if period_items:
formatted_periods = ", ".join(
f"{days} д. — -{percent}%" for days, percent in period_items
)
discount_lines.append(f"• Периоды: {formatted_periods}")
if promo_group.apply_discounts_to_addons:
discount_lines.append("• Доп. услуги: ✅ скидка действует")
else:
discount_lines.append("• Доп. услуги: ❌ без скидки")
return discount_lines
def _format_promo_group_block(
self,
promo_group: Optional[PromoGroup],
*,
title: str = "Промогруппа",
icon: str = "🏷️",
) -> str:
if not promo_group:
return f"{icon} <b>{title}:</b> —"
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
discount_lines = self._format_promo_group_discounts(promo_group)
if discount_lines:
lines.append("💸 <b>Скидки:</b>")
lines.extend(discount_lines)
else:
lines.append("💸 <b>Скидки:</b> отсутствуют")
return "\n".join(lines)
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
mapping = {
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
}
if not promo_type:
return "️ Не указан"
return mapping.get(promo_type, f"{promo_type}")
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
if campaign.is_balance_bonus:
return [
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
]
if campaign.is_subscription_bonus:
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
details = [
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
]
if campaign.subscription_squads:
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
return details
return ["ℹ️ Бонусы не предусмотрены"]
async def send_trial_activation_notification(
self,
@@ -52,7 +182,9 @@ class AdminNotificationService:
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -60,6 +192,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
@@ -102,7 +236,9 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -110,6 +246,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -214,7 +352,7 @@ class AdminNotificationService:
) -> bool:
if not self._is_enabled():
return False
try:
deposit_count_result = await db.execute(
select(func.count())
@@ -230,7 +368,14 @@ class AdminNotificationService:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
subscription_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = subscription_result.scalar_one_or_none()
subscription_status = self._get_subscription_status(subscription)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -238,6 +383,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
{promo_block}
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -249,7 +396,7 @@ class AdminNotificationService:
Изменение: +{settings.format_price(balance_change)}
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
📱 <b>Подписка:</b> {subscription_status}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -266,21 +413,31 @@ class AdminNotificationService:
subscription: Subscription,
transaction: Transaction,
extended_days: int,
old_end_date: datetime
old_end_date: datetime,
*,
new_end_date: datetime | None = None,
balance_after: int | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -289,23 +446,206 @@ class AdminNotificationService:
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
return False
async def send_promocode_activation_notification(
self,
db: AsyncSession,
user: User,
promocode_data: Dict[str, Any],
effect_description: str,
) -> bool:
if not self._is_enabled():
return False
try:
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
message_lines = [
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🎟️ <b>Промокод:</b>",
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
f"🧾 Тип: {type_display}",
f"📊 Использования: {usage_info}",
]
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
if balance_bonus:
message_lines.append(
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
)
subscription_days = promocode_data.get("subscription_days", 0)
if subscription_days:
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
message_lines.extend(
[
"",
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
return False
async def send_campaign_link_visit_notification(
self,
db: AsyncSession,
telegram_user: types.User,
campaign: AdvertisingCampaign,
user: Optional[User] = None,
) -> bool:
if not self._is_enabled():
return False
try:
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
promo_block = (
self._format_promo_group_block(await self._get_user_promo_group(db, user))
if user
else self._format_promo_group_block(None)
)
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
message_lines = [
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
"",
f"🧾 <b>Кампания:</b> {campaign.name}",
f"🆔 ID кампании: {campaign.id}",
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
"",
f"👤 <b>Пользователь:</b> {full_name}",
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
f"📱 <b>Username:</b> {username}",
user_status,
"",
promo_block,
"",
"🎯 <b>Бонус кампании:</b>",
]
bonus_lines = self._format_campaign_bonus(campaign)
message_lines.extend(bonus_lines)
message_lines.extend(
[
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
return False
async def send_user_promo_group_change_notification(
self,
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
*,
reason: Optional[str] = None,
initiator: Optional[User] = None,
automatic: bool = False,
) -> bool:
if not self._is_enabled():
return False
try:
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
initiator_line = None
if initiator:
initiator_line = (
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
]
if old_group and old_group.id != new_group.id:
message_lines.extend(
[
"",
self._format_promo_group_block(
old_group, title="Предыдущая промогруппа", icon="♻️"
),
]
)
if initiator_line:
message_lines.extend(["", initiator_line])
if reason:
message_lines.extend(["", f"📝 Причина: {reason}"])
message_lines.extend(
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
return False
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
@@ -353,7 +693,7 @@ class AdminNotificationService:
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': '💳 Mulen Pay (карта)',
'pal24': '💳 PayPalych (карта)',
'pal24': '🏦 PayPalych (СБП)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
}
@@ -361,22 +701,21 @@ class AdminNotificationService:
if not payment_method:
return '💰 С баланса'
return method_names.get(payment_method, f'💰 С баланса')
return method_names.get(payment_method, '💰 С баланса')
def _format_traffic(self, traffic_gb: int) -> str:
if traffic_gb == 0:
return "∞ Безлимит"
return f"{traffic_gb} ГБ"
def _get_subscription_status(self, user: User) -> str:
if not user.subscription:
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
if not subscription:
return "❌ Нет подписки"
sub = user.subscription
if sub.is_trial:
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
elif sub.is_active:
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
if subscription.is_trial:
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
elif subscription.is_active:
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
else:
return "❌ Неактивна"
@@ -409,40 +748,32 @@ class AdminNotificationService:
if details.get("auto_enabled", False):
icon = "⚠️"
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "warning"
else:
icon = "🔧"
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "info"
elif event_type == "disable":
icon = ""
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "success"
elif event_type == "api_status":
if status == "online":
icon = "🟢"
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
alert_type = "success"
else:
icon = "🔴"
title = "API REMNAWAVE НЕДОСТУПНО"
alert_type = "error"
elif event_type == "monitoring":
if status == "started":
icon = "🔍"
title = "МОНИТОРИНГ ЗАПУЩЕН"
alert_type = "info"
else:
icon = "⏹️"
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
alert_type = "info"
else:
icon = ""
title = "СИСТЕМА ТЕХРАБОТ"
alert_type = "info"
message_parts = [f"{icon} <b>{title}</b>", ""]
@@ -632,103 +963,6 @@ class AdminNotificationService:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
if details.get("manual_message"):
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_subscription_update_notification(
self,
db: AsyncSession,
@@ -744,50 +978,64 @@ class AdminNotificationService:
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
}
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
message = f"""{title}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
🔧 <b>Изменение:</b>
📋 Параметр: {param_name}"""
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🔧 <b>Изменение:</b>",
f"📋 Параметр: {param_name}",
]
if update_type == "servers":
old_servers_info = await self._format_servers_detailed(old_value)
new_servers_info = await self._format_servers_detailed(new_value)
message += f"""
📉 Было: {old_servers_info}
📈 Стало: {new_servers_info}"""
message_lines.extend(
[
f"📉 Было: {old_servers_info}",
f"📈 Стало: {new_servers_info}",
]
)
else:
message += f"""
📉 Было: {self._format_update_value(old_value, update_type)}
📈 Стало: {self._format_update_value(new_value, update_type)}"""
message_lines.extend(
[
f"📉 Было: {self._format_update_value(old_value, update_type)}",
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
]
)
if price_paid > 0:
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
else:
message += f"\n💸 Бесплатно"
message_lines.append("💸 Бесплатно")
message += f"""
message_lines.extend(
[
"",
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
f"🔗 <b>Рефер:</b> {referrer_info}",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Рефер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
+187 -9
View File
@@ -12,6 +12,7 @@ import aiofiles
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.config import settings
@@ -23,7 +24,8 @@ from app.database.models import (
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
MulenPayPayment, Pal24Payment
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
server_squad_promo_groups
)
logger = logging.getLogger(__name__)
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
@dataclass
class BackupMetadata:
timestamp: str
version: str = "1.1"
version: str = "1.2"
database_type: str = "postgresql"
backup_type: str = "full"
tables_count: int = 0
@@ -83,17 +85,23 @@ class BackupService:
PromoCodeUse,
ReferralEarning,
SentNotification,
DiscountOffer,
BroadcastHistory,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Ticket,
TicketMessage,
SupportAuditLog,
WebApiToken,
]
if self._settings.include_logs:
self.backup_models_ordered.append(MonitoringLog)
self.association_tables = {
"server_squad_promo_groups": server_squad_promo_groups,
}
def _load_settings(self) -> BackupSettings:
return BackupSettings(
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
@@ -171,6 +179,7 @@ class BackupService:
models_to_backup.append(MonitoringLog)
backup_data = {}
association_data = {}
total_records = 0
async for db in get_db():
@@ -214,7 +223,11 @@ class BackupService:
total_records += len(table_data)
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
association_data = await self._export_association_tables(db)
for records in association_data.values():
total_records += len(records)
break
except Exception as e:
logger.error(f"Ошибка при экспорте данных: {e}")
@@ -226,7 +239,7 @@ class BackupService:
timestamp=datetime.utcnow().isoformat(),
database_type="postgresql" if settings.is_postgresql() else "sqlite",
backup_type="full",
tables_count=len(models_to_backup),
tables_count=len(models_to_backup) + len(association_data),
total_records=total_records,
compressed=compress,
created_by=created_by,
@@ -239,10 +252,17 @@ class BackupService:
filename += ".gz"
backup_path = self.backup_dir / filename
file_snapshots = await self._collect_file_snapshots()
backup_structure = {
"metadata": asdict(metadata),
"data": backup_data
"data": backup_data,
"associations": association_data,
"files": file_snapshots,
"config": {
"backup_settings": asdict(self._settings)
}
}
if compress:
@@ -271,7 +291,7 @@ class BackupService:
size_mb = file_size / 1024 / 1024
message = (f"✅ Бекап успешно создан!\n"
f"📁 Файл: {filename}\n"
f"📊 Таблиц: {len(models_to_backup)}\n"
f"📊 Таблиц: {metadata.tables_count}\n"
f"📈 Записей: {total_records:,}\n"
f"💾 Размер: {size_mb:.2f} MB")
@@ -319,6 +339,8 @@ class BackupService:
metadata = backup_structure.get("metadata", {})
backup_data = backup_structure.get("data", {})
association_data = backup_structure.get("associations", {})
file_snapshots = backup_structure.get("files", {})
if not backup_data:
return False, "❌ Файл бекапа не содержит данных"
@@ -377,6 +399,14 @@ class BackupService:
await self._update_user_referrals(db, backup_data)
assoc_tables, assoc_records = await self._restore_association_tables(
db,
association_data,
clear_existing
)
restored_tables += assoc_tables
restored_records += assoc_records
await db.commit()
break
@@ -397,6 +427,11 @@ class BackupService:
if self.bot:
await self._send_backup_notification("restore_success", message)
if file_snapshots:
restored_files = await self._restore_file_snapshots(file_snapshots)
if restored_files:
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
return True, message
@@ -543,6 +578,97 @@ class BackupService:
return col.name
return None
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
association_data: Dict[str, List[Dict[str, Any]]] = {}
for table_name, table_obj in self.association_tables.items():
try:
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
result = await db.execute(select(table_obj))
rows = result.mappings().all()
association_data[table_name] = [dict(row) for row in rows]
logger.info(
f"✅ Экспортировано {len(rows)} связей из {table_name}"
)
except Exception as e:
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
return association_data
async def _restore_association_tables(
self,
db: AsyncSession,
association_data: Dict[str, List[Dict[str, Any]]],
clear_existing: bool
) -> Tuple[int, int]:
if not association_data:
return 0, 0
restored_tables = 0
restored_records = 0
if "server_squad_promo_groups" in association_data:
restored = await self._restore_server_squad_promo_groups(
db,
association_data["server_squad_promo_groups"],
clear_existing
)
restored_tables += 1
restored_records += restored
return restored_tables, restored_records
async def _restore_server_squad_promo_groups(
self,
db: AsyncSession,
records: List[Dict[str, Any]],
clear_existing: bool
) -> int:
if not records:
return 0
if clear_existing:
await db.execute(server_squad_promo_groups.delete())
restored = 0
for record in records:
server_id = record.get("server_squad_id")
promo_id = record.get("promo_group_id")
if server_id is None or promo_id is None:
logger.warning(
"Пропущена некорректная запись server_squad_promo_groups: %s",
record
)
continue
try:
await db.execute(
server_squad_promo_groups.insert().values(
server_squad_id=server_id,
promo_group_id=promo_id
)
)
restored += 1
except IntegrityError:
logger.debug(
"Запись server_squad_promo_groups (%s, %s) уже существует",
server_id,
promo_id
)
except Exception as e:
logger.error(
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
server_id,
promo_id,
e
)
await db.rollback()
raise e
return restored
async def _restore_table_records(
self,
db: AsyncSession,
@@ -590,17 +716,18 @@ class BackupService:
async def _clear_database_tables(self, db: AsyncSession):
tables_order = [
"server_squad_promo_groups",
"ticket_messages", "tickets", "support_audit_logs",
"advertising_campaign_registrations", "advertising_campaigns",
"subscription_servers", "sent_notifications",
"user_messages", "broadcast_history", "subscription_conversions",
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
"referral_earnings", "promocode_uses",
"yookassa_payments", "cryptobot_payments",
"mulenpay_payments", "pal24_payments",
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"server_squads", "squads", "service_rules",
"system_settings", "monitoring_logs"
"system_settings", "web_api_tokens", "monitoring_logs"
]
for table_name in tables_order:
@@ -610,6 +737,57 @@ class BackupService:
except Exception as e:
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {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, 'r', 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
).isoformat()
}
logger.info(
"📁 Добавлен в бекап файл конфигурации: %s",
path_obj
)
except Exception as e:
logger.error(
"Ошибка чтения файла конфигурации %s: %s",
path_obj,
e
)
return snapshots
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 восстановлен по пути %s", target_path)
except Exception as e:
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
return restored_files
async def get_backup_list(self) -> List[Dict[str, Any]]:
backups = []
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
from app.handlers.admin.messages import (
create_broadcast_keyboard,
get_custom_users,
get_target_users,
)
logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
@dataclass(slots=True)
class BroadcastMediaConfig:
type: str
file_id: str
caption: Optional[str] = None
@dataclass(slots=True)
class BroadcastConfig:
target: str
message_text: str
selected_buttons: list[str]
media: Optional[BroadcastMediaConfig] = None
initiator_name: Optional[str] = None
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
cancel_event: asyncio.Event
class BroadcastService:
"""Handles broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._bot: Optional[Bot] = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_bot(self, bot: Bot) -> None:
self._bot = bot
def is_running(self, broadcast_id: int) -> bool:
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
if self._bot is None:
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning("Рассылка %s уже запущена", broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f"broadcast-{broadcast_id}",
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: BroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
return
broadcast.status = "in_progress"
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
for index, user in enumerate(recipients, start=1):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
failed_count += 1
continue
try:
await self._deliver_message(telegram_id, config, keyboard)
sent_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
if index % 20 == 0:
await asyncio.sleep(1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc: # noqa: BLE001
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async with AsyncSessionLocal() as session:
if target.startswith("custom_"):
criteria = target[len("custom_"):]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
return create_broadcast_keyboard(selected_buttons)
async def _deliver_message(
self,
telegram_id: int,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
) -> None:
if not self._bot:
raise RuntimeError("Телеграм-бот не инициализирован")
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == "photo":
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "video":
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "document":
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
reply_markup=keyboard,
)
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = "cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
)
broadcast.completed_at = datetime.utcnow()
await session.commit()
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=True,
)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count or broadcast.failed_count
broadcast.status = "failed"
broadcast.completed_at = datetime.utcnow()
await session.commit()
broadcast_service = BroadcastService()
+19 -2
View File
@@ -125,8 +125,24 @@ class AdvertisingCampaignService:
)
squads = list(campaign.subscription_squads or [])
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
if not squads:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
getattr(settings, "TRIAL_SQUAD_UUID", None),
)
if trial_uuid:
squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад для кампании %s: %s",
campaign.id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
@@ -135,6 +151,7 @@ class AdvertisingCampaignService:
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
)
try:
+160
View File
@@ -0,0 +1,160 @@
"""Утилиты для синхронизации токена внешней админки."""
from __future__ import annotations
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
logger = logging.getLogger(__name__)
async def ensure_external_admin_token(
bot_username: Optional[str],
bot_id: Optional[int],
) -> Optional[str]:
"""Генерирует и сохраняет токен внешней админки, если требуется."""
username_raw = (bot_username or "").strip()
if not username_raw:
logger.warning(
"⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует",
)
return None
normalized_username = username_raw.lstrip("@").lower()
if not normalized_username:
logger.warning(
"⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации",
)
return None
try:
token = settings.build_external_admin_token(normalized_username)
except Exception as error: # pragma: no cover - защитный блок
logger.error("❌ Ошибка генерации токена внешней админки: %s", error)
return None
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_(
["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"]
)
)
)
rows = dict(result.all())
existing_token = rows.get("EXTERNAL_ADMIN_TOKEN")
existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID")
existing_bot_id: Optional[int] = None
if existing_bot_id_raw is not None:
try:
existing_bot_id = int(existing_bot_id_raw)
except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений
logger.warning(
"⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s",
existing_bot_id_raw,
)
if existing_token == token and existing_bot_id == bot_id:
if settings.get_external_admin_token() != token:
settings.EXTERNAL_ADMIN_TOKEN = token
if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id:
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
return token
if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id:
logger.error(
"❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s",
existing_bot_id,
bot_id,
)
try:
await bot_configuration_service.reset_value(
session,
"EXTERNAL_ADMIN_TOKEN",
force=True,
)
await bot_configuration_service.reset_value(
session,
"EXTERNAL_ADMIN_TOKEN_BOT_ID",
force=True,
)
await session.commit()
logger.warning(
"⚠️ Токен внешней админки очищен из-за несовпадения идентификаторов бота",
)
except Exception as cleanup_error: # pragma: no cover - защитный блок
await session.rollback()
logger.error(
"❌ Не удалось очистить токен внешней админки после обнаружения подмены: %s",
cleanup_error,
)
finally:
settings.EXTERNAL_ADMIN_TOKEN = None
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = None
return None
updates: list[tuple[str, object]] = []
if existing_token != token:
updates.append(("EXTERNAL_ADMIN_TOKEN", token))
if bot_id is not None and existing_bot_id != bot_id:
updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id))
if not updates:
# Токен совпал, но могли отсутствовать значения в настройках приложения
if settings.get_external_admin_token() != (existing_token or token):
settings.EXTERNAL_ADMIN_TOKEN = existing_token or token
if existing_bot_id is not None and (
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id
):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id
elif (
bot_id is not None
and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id
and existing_bot_id is None
):
settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id
return existing_token or token
try:
for key, value in updates:
await bot_configuration_service.set_value(
session,
key,
value,
force=True,
)
await session.commit()
logger.info(
"✅ Токен внешней админки синхронизирован для @%s",
normalized_username,
)
except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение
await session.rollback()
logger.warning(
"⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа",
)
return None
return token
except SQLAlchemyError as error:
logger.error("❌ Ошибка сохранения токена внешней админки: %s", error)
return None
+273
View File
@@ -0,0 +1,273 @@
import logging
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.faq import (
bulk_update_order,
create_faq_page,
delete_faq_page,
get_faq_page_by_id,
get_faq_pages,
get_faq_setting,
set_faq_enabled,
update_faq_page,
)
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
class FaqService:
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return FaqService._normalize_language(language)
@classmethod
async def get_setting(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = True,
) -> Optional[FaqSetting]:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
if setting or not fallback:
return setting
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_faq_setting(db, default_lang)
return setting
@classmethod
async def is_enabled(cls, db: AsyncSession, language: str) -> bool:
pages = await cls.get_pages(db, language)
return bool(pages)
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> FaqSetting:
lang = cls._normalize_language(language)
return await set_faq_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> FaqSetting:
lang = cls._normalize_language(language)
setting = await get_faq_setting(db, lang)
new_status = True
if setting:
new_status = not setting.is_enabled
return await set_faq_enabled(db, lang, new_status)
@classmethod
async def get_pages(
cls,
db: AsyncSession,
language: str,
*,
include_inactive: bool = False,
fallback: bool = True,
) -> List[FaqPage]:
lang = cls._normalize_language(language)
pages = await get_faq_pages(db, lang, include_inactive=include_inactive)
if pages:
setting = await get_faq_setting(db, lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return pages
if not fallback:
return []
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang == default_lang:
return []
fallback_pages = await get_faq_pages(
db,
default_lang,
include_inactive=include_inactive,
)
if not fallback_pages:
return []
setting = await get_faq_setting(db, default_lang)
if setting and not setting.is_enabled and not include_inactive:
return []
return fallback_pages
@classmethod
async def get_page(
cls,
db: AsyncSession,
page_id: int,
language: str,
*,
fallback: bool = True,
include_inactive: bool = False,
) -> Optional[FaqPage]:
page = await get_faq_page_by_id(db, page_id)
if not page:
return None
lang = cls._normalize_language(language)
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if not include_inactive and not page.is_active:
return None
if page.language == lang:
return page
if fallback and page.language == default_lang:
return page
return None
@classmethod
async def create_page(
cls,
db: AsyncSession,
*,
language: str,
title: str,
content: str,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
lang = cls._normalize_language(language)
is_active_value = True if is_active is None else bool(is_active)
page = await create_faq_page(
db,
language=lang,
title=title,
content=content,
display_order=display_order,
is_active=is_active_value,
)
setting = await get_faq_setting(db, lang)
if not setting:
await set_faq_enabled(db, lang, True)
return page
@classmethod
async def update_page(
cls,
db: AsyncSession,
page: FaqPage,
*,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
return await update_faq_page(
db,
page,
title=title,
content=content,
display_order=display_order,
is_active=is_active,
)
@classmethod
async def delete_page(cls, db: AsyncSession, page_id: int) -> None:
await delete_faq_page(db, page_id)
@classmethod
async def reorder_pages(
cls,
db: AsyncSession,
language: str,
pages: List[FaqPage],
) -> None:
lang = cls._normalize_language(language)
ordered = [page for page in pages if page.language == lang]
payload = [(page.id, index + 1) for index, page in enumerate(ordered)]
await bulk_update_order(db, payload)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: Optional[int] = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
limit = max_length or FaqService.MAX_PAGE_LENGTH
if len(normalized) <= limit:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= limit:
current = candidate
continue
flush_current()
if len(paragraph) <= limit:
current = paragraph
continue
start = 0
while start < len(paragraph):
chunk = paragraph[start : start + limit]
pages.append(chunk.strip())
start += limit
current = ""
flush_current()
if not pages:
return [normalized[:limit]]
return pages
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import List
from aiogram import types
from aiogram.types import InlineKeyboardButton
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
MainMenuButton,
MainMenuButtonActionType,
MainMenuButtonVisibility,
)
@dataclass(frozen=True)
class _MainMenuButtonData:
text: str
action_type: MainMenuButtonActionType
action_value: str
visibility: MainMenuButtonVisibility
is_active: bool
display_order: int
class MainMenuButtonService:
_cache: List[_MainMenuButtonData] | None = None
_lock: asyncio.Lock = asyncio.Lock()
@classmethod
def invalidate_cache(cls) -> None:
cls._cache = None
@classmethod
async def _load_cache(cls, db: AsyncSession) -> List[_MainMenuButtonData]:
if cls._cache is not None:
return cls._cache
async with cls._lock:
if cls._cache is not None:
return cls._cache
result = await db.execute(
select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
MainMenuButton.id.asc(),
)
)
items: List[_MainMenuButtonData] = []
for record in result.scalars().all():
text = (record.text or "").strip()
action_value = (record.action_value or "").strip()
if not text or not action_value:
continue
try:
action_type = MainMenuButtonActionType(record.action_type)
except ValueError:
continue
try:
visibility = MainMenuButtonVisibility(record.visibility)
except ValueError:
visibility = MainMenuButtonVisibility.ALL
items.append(
_MainMenuButtonData(
text=text,
action_type=action_type,
action_value=action_value,
visibility=visibility,
is_active=bool(record.is_active),
display_order=int(record.display_order or 0),
)
)
cls._cache = items
return items
@classmethod
async def get_buttons_for_user(
cls,
db: AsyncSession,
*,
is_admin: bool,
has_active_subscription: bool,
subscription_is_active: bool,
) -> list[InlineKeyboardButton]:
data = await cls._load_cache(db)
has_subscription = bool(has_active_subscription and subscription_is_active)
buttons: list[InlineKeyboardButton] = []
for item in data:
if not item.is_active:
continue
if item.visibility == MainMenuButtonVisibility.ADMINS and not is_admin:
continue
if item.visibility == MainMenuButtonVisibility.SUBSCRIBERS and not has_subscription:
continue
button = cls._build_button(item)
if button:
buttons.append(button)
return buttons
@staticmethod
def _build_button(item: _MainMenuButtonData) -> InlineKeyboardButton | None:
if item.action_type == MainMenuButtonActionType.URL:
return InlineKeyboardButton(text=item.text, url=item.action_value)
if item.action_type == MainMenuButtonActionType.MINI_APP:
return InlineKeyboardButton(
text=item.text,
web_app=types.WebAppInfo(url=item.action_value),
)
return None
+235 -25
View File
@@ -15,9 +15,12 @@ from app.config import settings
from app.database.database import get_db
from app.database.crud.discount_offer import (
deactivate_expired_offers,
get_latest_claimed_offer_for_user,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.crud.notification import (
clear_notification_by_type,
notification_sent,
record_notification,
)
@@ -33,12 +36,15 @@ from app.database.crud.user import (
get_inactive_users,
get_user_by_id,
subtract_user_balance,
cleanup_expired_promo_offer_discounts,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.services.payment_service import PaymentService
from app.services.subscription_service import SubscriptionService
from app.services.promo_offer_service import promo_offer_service
from app.utils.pricing_utils import apply_percentage_discount
from app.external.remnawave_api import (
RemnaWaveAPIError,
@@ -178,6 +184,17 @@ class MonitoringService:
if expired_offers:
logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений")
expired_active_discounts = await cleanup_expired_promo_offer_discounts(db)
if expired_active_discounts:
logger.info(
"🧹 Сброшено %s активных скидок промо-предложений с истекшим сроком",
expired_active_discounts,
)
cleaned_test_access = await promo_offer_service.cleanup_expired_test_access(db)
if cleaned_test_access:
logger.info(f"🧹 Отозвано {cleaned_test_access} истекших тестовых доступов к сквадам")
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -472,6 +489,10 @@ class MonitoringService:
try:
now = datetime.utcnow()
notifications_allowed = (
NotificationSettingsService.are_notifications_globally_enabled()
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
@@ -550,6 +571,22 @@ class MonitoringService:
user.remnawave_uuid,
api_error,
)
if notifications_allowed:
if not await notification_sent(
db,
user.id,
subscription.id,
"trial_channel_unsubscribed",
):
sent = await self._send_trial_channel_unsubscribed_notification(user)
if sent:
await record_notification(
db,
user.id,
subscription.id,
"trial_channel_unsubscribed",
)
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
@@ -575,6 +612,12 @@ class MonitoringService:
api_error,
)
await clear_notification_by_type(
db,
subscription.id,
"trial_channel_unsubscribed",
)
if disabled_count or restored_count:
await self._log_monitoring_event(
db,
@@ -649,15 +692,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave2",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -666,7 +709,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"second",
bonus_amount,
)
if success:
await record_notification(db, user.id, subscription.id, "expired_discount_wave2")
@@ -679,15 +721,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave3",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -696,7 +738,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"third",
bonus_amount,
trigger_days=trigger_days,
)
if success:
@@ -750,6 +791,85 @@ class MonitoringService:
return subscriptions
@staticmethod
def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if expires_at and expires_at <= datetime.utcnow():
return 0
return max(0, min(100, percent))
@staticmethod
async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None:
percent = MonitoringService._get_user_promo_offer_discount_percent(user)
if percent <= 0:
return
source = getattr(user, "promo_offer_discount_source", None)
log_payload = {
"offer_id": None,
"percent": percent,
"source": source,
"effect_type": None,
}
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception as lookup_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to resolve latest claimed promo offer for user %s: %s",
user.id,
lookup_error,
)
offer = None
if offer:
log_payload["offer_id"] = offer.id
log_payload["effect_type"] = offer.effect_type
if not log_payload["percent"] and offer.discount_percent:
log_payload["percent"] = offer.discount_percent
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
try:
await log_promo_offer_action(
db,
user_id=user.id,
offer_id=log_payload.get("offer_id"),
action="consumed",
source=log_payload.get("source"),
percent=log_payload.get("percent"),
effect_type=log_payload.get("effect_type"),
details={"reason": "autopay_consumed"},
)
except Exception as log_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer autopay log for user %s: %s",
user.id,
log_error,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer autopay log failure: %s",
rollback_error,
)
async def _process_autopayments(self, db: AsyncSession):
try:
current_time = datetime.utcnow()
@@ -782,36 +902,58 @@ class MonitoringService:
continue
renewal_cost = settings.PRICE_30_DAYS
promo_discount_percent = self._get_user_promo_offer_discount_percent(user)
charge_amount = renewal_cost
promo_discount_value = 0
if renewal_cost > 0 and promo_discount_percent > 0:
charge_amount, promo_discount_value = apply_percentage_discount(
renewal_cost,
promo_discount_percent,
)
autopay_key = f"autopay_{user.telegram_id}_{subscription.id}"
if autopay_key in self._notified_users:
continue
if user.balance_kopeks >= renewal_cost:
if user.balance_kopeks >= charge_amount:
success = await subtract_user_balance(
db, user, renewal_cost,
db, user, charge_amount,
"Автопродление подписки"
)
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
await self.subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="автопродление подписки",
)
if promo_discount_value > 0:
await self._consume_user_promo_offer_discount(db, user)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
await self._send_autopay_success_notification(user, charge_amount, 30)
processed_count += 1
self._notified_users.add(autopay_key)
logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно")
logger.info(
"💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)",
user.telegram_id,
charge_amount,
promo_discount_percent,
)
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}")
else:
failed_count += 1
if self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost)
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}")
if processed_count > 0 or failed_count > 0:
@@ -1035,6 +1177,69 @@ class MonitoringService:
)
return False
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
try:
texts = get_texts(user.language)
template = texts.get(
"TRIAL_CHANNEL_UNSUBSCRIBED",
(
"🚫 <b>Доступ приостановлен</b>\n\n"
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
),
)
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
message = template.format(check_button=check_button)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
buttons = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data="sub_channel_check",
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await self._send_message_with_logo(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, "уведомление об отписке от канала"):
return True
logger.error(
"Ошибка Telegram API при отправке уведомления об отписке от канала пользователю %s: %s",
user.telegram_id,
exc,
)
return False
except Exception as error:
logger.error(
"Ошибка отправки уведомления об отписке от канала пользователю %s: %s",
user.telegram_id,
error,
)
return False
async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
@@ -1091,7 +1296,6 @@ class MonitoringService:
expires_at: datetime,
offer_id: int,
wave: str,
bonus_amount: int,
trigger_days: int = None,
) -> bool:
try:
@@ -1102,8 +1306,8 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
else:
@@ -1111,14 +1315,13 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=expires_at.strftime("%d.%m.%Y %H:%M"),
trigger_days=trigger_days or "",
)
@@ -1325,10 +1528,17 @@ class MonitoringService:
if len(title) > 60:
title = title[:57] + '...'
# Детали пользователя: имя, Telegram ID и username
full_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
text = (
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
)
@@ -1,5 +1,4 @@
import json
import json
import logging
from copy import deepcopy
from pathlib import Path
@@ -21,6 +20,7 @@ class NotificationSettingsService:
_DEFAULTS: Dict[str, Dict[str, Any]] = {
"trial_inactive_1h": {"enabled": True},
"trial_inactive_24h": {"enabled": True},
"trial_channel_unsubscribed": {"enabled": True},
"expired_1d": {"enabled": True},
"expired_second_wave": {
"enabled": True,
@@ -139,6 +139,14 @@ class NotificationSettingsService:
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_inactive_24h", enabled)
@classmethod
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
return cls.is_enabled("trial_channel_unsubscribed")
@classmethod
def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_channel_unsubscribed", enabled)
# Expired subscription notifications
@classmethod
def is_expired_1d_enabled(cls) -> bool:
+3
View File
@@ -37,6 +37,7 @@ class Pal24Service:
ttl_seconds: Optional[int] = None,
custom_payload: Optional[Dict[str, Any]] = None,
payer_email: Optional[str] = None,
payment_method: Optional[str] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise Pal24APIError("Pal24 service is not configured")
@@ -49,6 +50,8 @@ class Pal24Service:
if payer_email:
extra_payload["payer_email"] = payer_email
if payment_method:
extra_payload["payment_method"] = payment_method
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
+58 -6
View File
@@ -857,6 +857,7 @@ class PaymentService:
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
payment_method="SBP",
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
@@ -871,8 +872,55 @@ class PaymentService:
logger.error("Pal24 не вернул bill_id: %s", response)
return None
link_url = response.get("link_url")
link_page_url = response.get("link_page_url")
def _pick_url(*keys: str) -> Optional[str]:
for key in keys:
value = response.get(key)
if value:
return str(value)
return None
transfer_url = _pick_url(
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_url = _pick_url(
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
link_page_url = _pick_url(
"link_page_url",
"linkPageUrl",
"page_url",
"pageUrl",
)
primary_link = transfer_url or link_page_url or card_url
secondary_link = link_page_url or card_url or transfer_url
metadata_links = {
key: value
for key, value in {
"sbp": transfer_url,
"card": card_url,
"page": link_page_url,
}.items()
if value
}
payment = await create_pal24_payment(
db,
@@ -884,22 +932,26 @@ class PaymentService:
status=response.get("status", "NEW"),
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=link_url,
link_page_url=link_page_url,
link_url=primary_link,
link_page_url=secondary_link,
ttl=ttl_seconds,
metadata={
"raw_response": response,
"language": language,
**({"links": metadata_links} if metadata_links else {}),
},
)
payment_info = {
"bill_id": bill_id,
"order_id": order_id,
"link_url": link_url or link_page_url,
"link_page_url": link_page_url,
"link_url": primary_link,
"link_page_url": secondary_link,
"local_payment_id": payment.id,
"amount_kopeks": amount_kopeks,
"sbp_url": transfer_url or primary_link,
"card_url": card_url,
"transfer_url": transfer_url,
}
logger.info(
+178
View File
@@ -0,0 +1,178 @@
import logging
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.privacy_policy import (
get_privacy_policy,
set_privacy_policy_enabled,
upsert_privacy_policy,
)
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
class PrivacyPolicyService:
"""Utility helpers around privacy policy storage and presentation."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PrivacyPolicyService._normalize_language(language)
@classmethod
async def get_policy(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy or not fallback:
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_privacy_policy(db, default_lang)
return policy
@classmethod
async def get_active_policy(
cls,
db: AsyncSession,
language: str,
) -> Optional[PrivacyPolicy]:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy and policy.is_enabled and policy.content.strip():
return policy
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_policy = await get_privacy_policy(db, default_lang)
if fallback_policy and fallback_policy.is_enabled and fallback_policy.content.strip():
return fallback_policy
return None
@classmethod
async def is_policy_enabled(cls, db: AsyncSession, language: str) -> bool:
policy = await cls.get_active_policy(db, language)
return policy is not None
@classmethod
async def save_policy(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
enable_if_new = True
policy = await upsert_privacy_policy(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Политика конфиденциальности обновлена для языка %s", lang)
return policy
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
return await set_privacy_policy_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PrivacyPolicy:
lang = cls._normalize_language(language)
policy = await get_privacy_policy(db, lang)
if policy:
new_status = not policy.is_enabled
else:
new_status = True
return await set_privacy_policy_enabled(db, lang, new_status)
@staticmethod
def split_content_into_pages(
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or PrivacyPolicyService.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
candidate = f"{current}\n\n{paragraph}".strip() if current else paragraph
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
if len(paragraph) <= max_len:
current = paragraph
continue
start_index = 0
while start_index < len(paragraph):
chunk = paragraph[start_index:start_index + max_len]
pages.append(chunk.strip())
start_index += max_len
current = ""
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+66
View File
@@ -2,15 +2,64 @@ import logging
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.models import PromoGroup, User
from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
async def _notify_admins_about_auto_assignment(
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
total_spent_kopeks: int,
):
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
bot_token = getattr(settings, "BOT_TOKEN", None)
if not bot_token:
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
return
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
try:
notification_service = AdminNotificationService(bot)
reason = (
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
if hasattr(settings, "format_price")
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}"
)
await notification_service.send_user_promo_group_change_notification(
db,
user,
old_group,
new_group,
reason=reason,
initiator=None,
automatic=True,
)
except Exception as exc:
logger.error(
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
user.telegram_id,
exc,
)
finally:
try:
await bot.session.close()
except Exception:
pass
async def _get_best_group_for_spending(
db: AsyncSession,
total_spent_kopeks: int,
@@ -48,6 +97,14 @@ async def maybe_assign_promo_group_by_total_spent(
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
return None
old_group = None
if user.promo_group_id:
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
pass
old_group = getattr(user, "promo_group", None)
total_spent = await get_user_total_spent_kopeks(db, user_id)
if total_spent <= 0:
return None
@@ -112,6 +169,15 @@ async def maybe_assign_promo_group_by_total_spent(
await db.commit()
await db.refresh(user)
if target_group.id != previous_group_id:
await _notify_admins_about_auto_assignment(
db,
user,
old_group,
target_group,
total_spent,
)
return target_group
except Exception as exc:
logger.error(
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
DiscountOffer,
Subscription,
SubscriptionTemporaryAccess,
User,
)
from app.services.subscription_service import SubscriptionService
from app.database.crud.promo_offer_log import log_promo_offer_action
logger = logging.getLogger(__name__)
class PromoOfferService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def grant_test_access(
self,
db: AsyncSession,
user: User,
offer: DiscountOffer,
) -> Tuple[bool, Optional[List[str]], Optional[datetime], str]:
subscription = getattr(user, "subscription", None)
if not subscription:
return False, None, None, "subscription_missing"
payload = offer.extra_data or {}
raw_squads = payload.get("test_squad_uuids") or payload.get("squads") or []
if isinstance(raw_squads, str):
candidates = [raw_squads]
else:
try:
candidates = list(raw_squads)
except TypeError:
candidates = []
squad_uuids: Sequence[str] = [str(item) for item in candidates if item]
if not squad_uuids:
return False, None, None, "squads_missing"
squad_uuids = list(dict.fromkeys(squad_uuids))
connected = {str(item) for item in subscription.connected_squads or []}
if squad_uuids and set(squad_uuids).issubset(connected):
return False, None, None, "already_connected"
try:
duration_hours = int(payload.get("test_duration_hours") or payload.get("duration_hours") or 24)
except (TypeError, ValueError):
duration_hours = 24
if duration_hours <= 0:
duration_hours = 24
now = datetime.utcnow()
expires_at = now + timedelta(hours=duration_hours)
original_connected = set(connected)
newly_added: List[str] = []
changes_made = False
for squad_uuid in squad_uuids:
normalized_uuid = str(squad_uuid)
existing_result = await db.execute(
select(SubscriptionTemporaryAccess)
.where(
SubscriptionTemporaryAccess.offer_id == offer.id,
SubscriptionTemporaryAccess.squad_uuid == normalized_uuid,
)
.order_by(SubscriptionTemporaryAccess.id.desc())
)
existing_access = existing_result.scalars().first()
if existing_access and existing_access.is_active:
if existing_access.expires_at < expires_at:
existing_access.expires_at = expires_at
changes_made = True
continue
was_already_connected = normalized_uuid in connected
if not was_already_connected:
connected.add(normalized_uuid)
newly_added.append(normalized_uuid)
changes_made = True
access_entry = SubscriptionTemporaryAccess(
subscription_id=subscription.id,
offer_id=offer.id,
squad_uuid=normalized_uuid,
expires_at=expires_at,
is_active=True,
was_already_connected=was_already_connected,
)
db.add(access_entry)
changes_made = True
connected_changed = connected != original_connected
if newly_added:
subscription.connected_squads = list(connected)
subscription.updated_at = now
changes_made = True
if connected_changed:
remnawave_user = await self.subscription_service.update_remnawave_user(
db,
subscription,
)
if remnawave_user is None:
await db.rollback()
await db.refresh(subscription)
logger.error(
"Не удалось синхронизировать временный доступ подписки %s с RemnaWave",
subscription.id,
)
return False, None, None, "remnawave_sync_failed"
await db.refresh(subscription)
elif changes_made:
await db.commit()
await db.refresh(subscription)
return True, newly_added, expires_at, "ok"
async def cleanup_expired_test_access(self, db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(
selectinload(SubscriptionTemporaryAccess.subscription),
selectinload(SubscriptionTemporaryAccess.offer),
)
.where(
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
SubscriptionTemporaryAccess.expires_at <= now,
)
)
entries = result.scalars().all()
if not entries:
return 0
subscriptions_updates: dict[int, Tuple[Subscription, set[str]]] = {}
log_payloads: List[Dict[str, object]] = []
for entry in entries:
entry.is_active = False
entry.deactivated_at = now
subscription = entry.subscription
if not subscription:
continue
bucket = subscriptions_updates.setdefault(subscription.id, (subscription, set()))
if not entry.was_already_connected:
bucket[1].add(entry.squad_uuid)
user_id = subscription.user_id
if user_id:
offer = entry.offer
log_payloads.append(
{
"user_id": user_id,
"offer_id": entry.offer_id,
"source": getattr(offer, "notification_type", None),
"percent": getattr(offer, "discount_percent", None),
"effect_type": getattr(offer, "effect_type", "test_access"),
"details": {
"reason": "test_access_expired",
"squad_uuid": entry.squad_uuid,
},
}
)
for subscription, squads_to_remove in subscriptions_updates.values():
if not squads_to_remove:
continue
current = set(subscription.connected_squads or [])
updated = current.difference(squads_to_remove)
if updated != current:
subscription.connected_squads = list(updated)
subscription.updated_at = now
try:
await self.subscription_service.update_remnawave_user(db, subscription)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Ошибка обновления Remnawave при отзыве тестового доступа подписки %s: %s",
subscription.id,
exc,
)
await db.commit()
for payload in log_payloads:
try:
await log_promo_offer_action(
db,
user_id=payload["user_id"],
offer_id=payload.get("offer_id"),
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details=payload.get("details"),
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
"Failed to record promo offer test access disable log for user %s: %s",
payload.get("user_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to rollback session after promo offer test access log failure: %s",
rollback_error,
)
return len(entries)
promo_offer_service = PromoOfferService()
+41 -14
View File
@@ -49,23 +49,34 @@ class PromoCodeService:
return {"success": False, "error": "already_used_by_user"}
result_description = await self._apply_promocode_effects(db, user, promocode)
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
await mark_user_as_had_paid_subscription(db, user)
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
await create_promocode_use(db, promocode.id, user_id)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
promocode_data = {
"code": promocode.code,
"type": promocode.type,
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
"subscription_days": promocode.subscription_days,
"max_uses": promocode.max_uses,
"current_uses": promocode.current_uses,
"valid_until": promocode.valid_until,
}
return {
"success": True,
"description": result_description
"description": result_description,
"promocode": promocode_data,
}
except Exception as e:
@@ -102,16 +113,32 @@ class PromoCodeService:
from app.database.crud.subscription import create_paid_subscription
trial_squads = []
if hasattr(settings, 'TRIAL_SQUAD_UUID') and settings.TRIAL_SQUAD_UUID:
trial_squads = [settings.TRIAL_SQUAD_UUID]
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
if trial_uuid:
trial_squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад для подписки по промокоду %s: %s",
promocode.code,
error,
)
if getattr(settings, 'TRIAL_SQUAD_UUID', None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=promocode.subscription_days,
traffic_limit_gb=0,
traffic_limit_gb=0,
device_limit=1,
connected_squads=trial_squads
connected_squads=trial_squads,
update_server_counters=True,
)
await self.subscription_service.create_remnawave_user(db, new_subscription)
@@ -129,9 +156,9 @@ class PromoCodeService:
trial_days = promocode.subscription_days if promocode.subscription_days > 0 else settings.TRIAL_DURATION_DAYS
trial_subscription = await create_trial_subscription(
db,
user.id,
duration_days=trial_days
db,
user.id,
duration_days=trial_days
)
await self.subscription_service.create_remnawave_user(db, trial_subscription)
+359
View File
@@ -0,0 +1,359 @@
import logging
from html.parser import HTMLParser
from typing import List, Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.public_offer import (
get_public_offer,
set_public_offer_enabled,
upsert_public_offer,
)
from app.database.models import PublicOffer
logger = logging.getLogger(__name__)
class PublicOfferService:
"""Helpers for managing the public offer text and visibility."""
MAX_PAGE_LENGTH = 3500
@staticmethod
def _normalize_language(language: str) -> str:
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
return base_language.split("-")[0].lower()
@staticmethod
def normalize_language(language: str) -> str:
return PublicOfferService._normalize_language(language)
@classmethod
async def get_offer(
cls,
db: AsyncSession,
language: str,
*,
fallback: bool = False,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer or not fallback:
return offer
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
return await get_public_offer(db, default_lang)
return offer
@classmethod
async def get_active_offer(
cls,
db: AsyncSession,
language: str,
) -> Optional[PublicOffer]:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
if offer.is_enabled and offer.content.strip():
return offer
if not offer.is_enabled:
return None
default_lang = cls._normalize_language(settings.DEFAULT_LANGUAGE)
if lang != default_lang:
fallback_offer = await get_public_offer(db, default_lang)
if fallback_offer and fallback_offer.is_enabled and fallback_offer.content.strip():
return fallback_offer
return None
@classmethod
async def is_offer_enabled(cls, db: AsyncSession, language: str) -> bool:
offer = await cls.get_active_offer(db, language)
return offer is not None
@classmethod
async def save_offer(
cls,
db: AsyncSession,
language: str,
content: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
enable_if_new = True
offer = await upsert_public_offer(
db,
lang,
content,
enable_if_new=enable_if_new,
)
logger.info("✅ Публичная оферта обновлена для языка %s", lang)
return offer
@classmethod
async def set_enabled(
cls,
db: AsyncSession,
language: str,
enabled: bool,
) -> PublicOffer:
lang = cls._normalize_language(language)
return await set_public_offer_enabled(db, lang, enabled)
@classmethod
async def toggle_enabled(
cls,
db: AsyncSession,
language: str,
) -> PublicOffer:
lang = cls._normalize_language(language)
offer = await get_public_offer(db, lang)
if offer:
new_status = not offer.is_enabled
else:
new_status = True
return await set_public_offer_enabled(db, lang, new_status)
class _RichTextPaginator(HTMLParser):
"""Split HTML-like text into Telegram-safe chunks."""
SELF_CLOSING_TAGS = {"br", "hr", "img", "input", "meta", "link"}
def __init__(self, max_len: int) -> None:
super().__init__(convert_charrefs=False)
self.max_len = max_len
self.pages: List[str] = []
self.current_parts: List[str] = []
self.current_length = 0
self.open_stack: List[Tuple[str, str, int]] = []
self.closing_length = 0
self.needs_prefix = False
self.prefix_length = 0
def _closing_sequence(self) -> str:
return "".join(f"</{name}>" for name, _, _ in reversed(self.open_stack))
def _ensure_prefix(self) -> None:
if not self.needs_prefix:
return
prefix = "".join(token for _, token, _ in self.open_stack)
if prefix:
self.current_parts.append(prefix)
self.current_length += len(prefix)
self.needs_prefix = False
def _flush(self) -> None:
if not self.current_parts and not self.closing_length:
return
content = "".join(self.current_parts)
closing_tags = self._closing_sequence()
page = (content + closing_tags).strip()
if page:
self.pages.append(page)
self.current_parts = []
self.current_length = 0
if self.prefix_length + self.closing_length > self.max_len:
self.open_stack = []
self.closing_length = 0
self.prefix_length = 0
self.needs_prefix = False
else:
self.needs_prefix = bool(self.open_stack)
@staticmethod
def _format_attrs(attrs: List[Tuple[str, Optional[str]]]) -> str:
parts = []
for name, value in attrs:
if value is None:
parts.append(f" {name}")
else:
parts.append(f" {name}=\"{value}\"")
return "".join(parts)
def _append_token(self, token: str) -> None:
while True:
self._ensure_prefix()
if self.current_length + len(token) + self.closing_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
break
self._flush()
if len(token) > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
break
def handle_data(self, data: str) -> None:
if not data:
return
remaining = data
while remaining:
self._ensure_prefix()
available = self.max_len - (self.current_length + self.closing_length)
if available <= 0:
self._flush()
continue
piece = remaining[:available]
self.current_parts.append(piece)
self.current_length += len(piece)
remaining = remaining[available:]
if remaining:
self._flush()
def handle_entityref(self, name: str) -> None:
self.handle_data(f"&{name};")
def handle_charref(self, name: str) -> None:
self.handle_data(f"&#{name};")
def _handle_self_closing(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
token = f"<{tag}{self._format_attrs(attrs)}/>"
self._append_token(token)
def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
if tag in self.SELF_CLOSING_TAGS:
self._handle_self_closing(tag, attrs)
return
token = f"<{tag}{self._format_attrs(attrs)}>"
closing_token = f"</{tag}>"
closing_len = len(closing_token)
while True:
self._ensure_prefix()
projected_length = self.current_length + len(token) + self.closing_length + closing_len
if projected_length <= self.max_len:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
self._flush()
if len(token) + closing_len > self.max_len and not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
self.open_stack.append((tag, token, closing_len))
self.closing_length += closing_len
self.prefix_length += len(token)
break
def handle_endtag(self, tag: str) -> None:
token = f"</{tag}>"
closing_len_reduction = 0
index_to_remove = None
for index in range(len(self.open_stack) - 1, -1, -1):
if self.open_stack[index][0] == tag:
closing_len_reduction = self.open_stack[index][2]
index_to_remove = index
break
while True:
self._ensure_prefix()
projected_closing_length = self.closing_length - closing_len_reduction
if projected_closing_length < 0:
projected_closing_length = 0
projected_total = self.current_length + len(token) + projected_closing_length
if projected_total <= self.max_len or not self.current_parts:
self.current_parts.append(token)
self.current_length += len(token)
if index_to_remove is not None:
removed_tag = self.open_stack.pop(index_to_remove)
self.closing_length -= closing_len_reduction
self.prefix_length -= len(removed_tag[1])
break
self._flush()
def handle_startendtag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None:
self._handle_self_closing(tag, attrs)
def finalize(self) -> List[str]:
self._flush()
return self.pages
@classmethod
def _split_rich_paragraph(cls, paragraph: str, max_len: int) -> List[str]:
if len(paragraph) <= max_len:
return [paragraph]
paginator = cls._RichTextPaginator(max_len)
paginator.feed(paragraph)
paginator.close()
pages = paginator.finalize()
return pages or [paragraph]
@classmethod
def split_content_into_pages(
cls,
content: str,
*,
max_length: int = None,
) -> List[str]:
if not content:
return []
normalized = content.replace("\r\n", "\n").strip()
if not normalized:
return []
max_len = max_length or cls.MAX_PAGE_LENGTH
if len(normalized) <= max_len:
return [normalized]
paragraphs = [
paragraph.strip()
for paragraph in normalized.split("\n\n")
if paragraph.strip()
]
pages: List[str] = []
current = ""
def flush_current() -> None:
nonlocal current
if current:
pages.append(current.strip())
current = ""
for paragraph in paragraphs:
segments = cls._split_rich_paragraph(paragraph, max_len)
for segment in segments:
segment = segment.strip()
if not segment:
continue
candidate = f"{current}\n\n{segment}".strip() if current else segment
if len(candidate) <= max_len:
current = candidate
continue
flush_current()
current = segment
flush_current()
if not pages:
return [normalized[:max_len]]
return pages
+59 -48
View File
@@ -1,18 +1,25 @@
import logging
from contextlib import asynccontextmanager
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from datetime import datetime, timedelta
import os
import re
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from app.config import settings
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveAPI, RemnaWaveUser, RemnaWaveInternalSquad,
RemnaWaveNode, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError
)
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user import get_users_list, get_user_by_telegram_id, update_user
from app.database.crud.subscription import get_subscription_by_user_id, update_subscription_usage
from app.database.crud.subscription import (
get_subscription_by_user_id,
update_subscription_usage,
decrement_subscription_server_counts,
)
from app.database.models import (
User, SubscriptionServer, Transaction, ReferralEarning,
PromoCodeUse, SubscriptionStatus
@@ -34,6 +41,16 @@ class RemnaWaveService:
self._config_error: Optional[str] = None
tz_name = os.getenv("TZ", "UTC")
try:
self._panel_timezone = ZoneInfo(tz_name)
except Exception:
logger.warning(
"⚠️ Не удалось загрузить временную зону '%s'. Используется UTC.",
tz_name,
)
self._panel_timezone = ZoneInfo("UTC")
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
elif not api_key:
@@ -72,33 +89,41 @@ class RemnaWaveService:
async with self.api as api:
yield api
def _now_in_panel_timezone(self) -> datetime:
"""Возвращает текущее время без часового пояса в зоне панели."""
return datetime.now(self._panel_timezone).replace(tzinfo=None)
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
return datetime.utcnow() + timedelta(days=30)
return self._now_in_panel_timezone() + timedelta(days=30)
try:
cleaned_date = date_str.strip()
if cleaned_date.endswith('Z'):
cleaned_date = cleaned_date[:-1] + '+00:00'
if '+00:00+00:00' in cleaned_date:
cleaned_date = cleaned_date.replace('+00:00+00:00', '+00:00')
cleaned_date = re.sub(r'(\+\d{2}:\d{2})\+\d{2}:\d{2}$', r'\1', cleaned_date)
parsed_date = datetime.fromisoformat(cleaned_date)
if parsed_date.tzinfo is not None:
parsed_date = parsed_date.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {parsed_date}")
return parsed_date
localized = parsed_date.astimezone(self._panel_timezone)
else:
localized = parsed_date.replace(tzinfo=self._panel_timezone)
localized_naive = localized.replace(tzinfo=None)
logger.debug(f"Успешно распарсена дата: {date_str} -> {localized_naive}")
return localized_naive
except Exception as e:
logger.warning(f"⚠️ Не удалось распарсить дату '{date_str}': {e}. Используем дефолтную дату.")
return datetime.utcnow() + timedelta(days=30)
return self._now_in_panel_timezone() + timedelta(days=30)
async def get_system_statistics(self) -> Dict[str, Any]:
try:
@@ -600,7 +625,9 @@ class RemnaWaveService:
try:
from sqlalchemy import delete
from app.database.models import SubscriptionServer
await decrement_subscription_server_counts(db, subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == subscription.id
@@ -652,7 +679,7 @@ class RemnaWaveService:
expire_at = self._parse_remnawave_date(expire_at_str)
panel_status = panel_user.get('status', 'ACTIVE')
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if panel_status == 'ACTIVE' and expire_at > current_time:
status = SubscriptionStatus.ACTIVE
@@ -707,7 +734,7 @@ class RemnaWaveService:
user_id=user.id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=False,
end_date=datetime.utcnow() + timedelta(days=30),
end_date=self._now_in_panel_timezone() + timedelta(days=30),
traffic_limit_gb=0,
traffic_used_gb=0.0,
device_limit=1,
@@ -744,7 +771,7 @@ class RemnaWaveService:
subscription.end_date = expire_at
logger.debug(f"Обновлена дата окончания подписки до {expire_at}")
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if panel_status == 'ACTIVE' and subscription.end_date > current_time:
new_status = SubscriptionStatus.ACTIVE.value
elif subscription.end_date <= current_time:
@@ -987,15 +1014,6 @@ class RemnaWaveService:
logger.error(f"Error removing users from squad: {e}")
return False
async def delete_squad(self, squad_uuid: str) -> bool:
try:
async with self.get_api_client() as api:
response = await api.delete_internal_squad(squad_uuid)
return response
except Exception as e:
logger.error(f"Error deleting squad: {e}")
return False
async def get_all_inbounds(self) -> List[Dict]:
try:
async with self.get_api_client() as api:
@@ -1030,15 +1048,6 @@ class RemnaWaveService:
logger.error(f"Error renaming squad: {e}")
return False
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
try:
async with self.get_api_client() as api:
squad = await api.create_internal_squad(name, inbound_uuids)
return squad is not None
except Exception as e:
logger.error(f"Error creating squad: {e}")
return False
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
try:
async with self.get_api_client() as api:
@@ -1134,6 +1143,8 @@ class RemnaWaveService:
)
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
@@ -1168,22 +1179,22 @@ class RemnaWaveService:
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.used_promocodes = 0
user.updated_at = datetime.utcnow()
user.updated_at = self._now_in_panel_timezone()
if user.subscription:
user.subscription.status = SubscriptionStatus.DISABLED.value
user.subscription.is_trial = True
user.subscription.end_date = datetime.utcnow()
user.subscription.end_date = self._now_in_panel_timezone()
user.subscription.traffic_limit_gb = 0
user.subscription.traffic_used_gb = 0.0
user.subscription.device_limit = 1
user.subscription.connected_squads = []
user.subscription.autopay_enabled = False
user.subscription.autopay_days_before = 3
user.subscription.autopay_days_before = settings.DEFAULT_AUTOPAY_DAYS_BEFORE
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.subscription_crypto_link = ""
user.subscription.updated_at = datetime.utcnow()
user.subscription.updated_at = self._now_in_panel_timezone()
await db.commit()
@@ -1352,7 +1363,7 @@ class RemnaWaveService:
user = subscription.user
issues_fixed = 0
current_time = datetime.utcnow()
current_time = self._now_in_panel_timezone()
if subscription.end_date <= current_time and subscription.status == SubscriptionStatus.ACTIVE.value:
logger.info(f"🔧 Исправляем статус просроченной подписки {user.telegram_id}")
subscription.status = SubscriptionStatus.EXPIRED.value
+27
View File
@@ -17,6 +17,8 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
Ticket,
TicketStatus,
Transaction,
TransactionType,
)
@@ -203,6 +205,10 @@ class ReportingService:
f"• Активных сейчас: {totals['active_paid']}",
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
"",
"🎟️ <b>Тикеты поддержки</b>",
f"• Активных сейчас: {totals['open_tickets']}",
f"• Новых за период: {period_stats['new_tickets']}",
"",
"💰 <b>Платежи</b>",
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
@@ -243,9 +249,22 @@ class ReportingService:
async def _collect_current_totals(self, session) -> dict:
stats = await get_subscriptions_statistics(session)
open_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.status.in_(
[
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value,
TicketStatus.PENDING.value,
]
)
)
)
open_tickets = int(open_tickets_result.scalar() or 0)
return {
"active_trials": stats.get("trial_subscriptions", 0) or 0,
"active_paid": stats.get("paid_subscriptions", 0) or 0,
"open_tickets": open_tickets,
}
async def _collect_period_stats(
@@ -315,6 +334,13 @@ class ReportingService:
total_payments_count = subscription_payments_count + deposits_count
total_payments_amount = subscription_payments_amount + deposits_amount
new_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
new_tickets = int(new_tickets_result.scalar() or 0)
return {
"new_trials": new_trials,
@@ -325,6 +351,7 @@ class ReportingService:
"deposits_amount": deposits_amount,
"total_payments_count": total_payments_count,
"total_payments_amount": total_payments_amount,
"new_tickets": new_tickets,
}
def _format_period_label(self, start: datetime, end: datetime) -> str:
+140 -24
View File
@@ -1,4 +1,5 @@
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
@@ -6,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
)
from app.database.crud.user import get_user_by_id
@@ -75,21 +76,88 @@ def get_traffic_reset_strategy():
class SubscriptionService:
def __init__(self):
self._config_error: Optional[str] = None
self.api: Optional[RemnaWaveAPI] = None
self._last_config_signature: Optional[Tuple[str, ...]] = None
self._refresh_configuration()
def _refresh_configuration(self) -> None:
auth_params = settings.get_remnawave_auth_params()
self.api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
auth_type = (auth_params.get("auth_type") or "").strip() or None
config_signature = (
base_url,
api_key,
secret_key or "",
username or "",
password or "",
auth_type or "",
)
if config_signature == self._last_config_signature:
return
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
self.api = None
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
self.api = None
else:
self._config_error = None
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
)
if self._config_error:
logger.warning(
"RemnaWave API недоступен: %s. Подписочный сервис будет работать в оффлайн-режиме.",
self._config_error
)
self._last_config_signature = config_signature
@property
def is_configured(self) -> bool:
return self._config_error is None
@property
def configuration_error(self) -> Optional[str]:
return self._config_error
def _ensure_configured(self) -> None:
self._refresh_configuration()
if not self.api or not self.is_configured:
raise RemnaWaveAPIError(
self._config_error or "RemnaWave API не настроен"
)
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
yield api
async def create_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
self,
db: AsyncSession,
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -103,7 +171,7 @@ class SubscriptionService:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
async with self.api as api:
async with self.get_api_client() as api:
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
if existing_users:
logger.info(f"🔄 Найден существующий пользователь в панели для {user.telegram_id}")
@@ -130,6 +198,14 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
@@ -148,7 +224,15 @@ class SubscriptionService:
),
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
@@ -172,7 +256,10 @@ class SubscriptionService:
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -194,7 +281,7 @@ class SubscriptionService:
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
async with self.api as api:
async with self.get_api_client() as api:
updated_user = await api.update_user(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
@@ -210,6 +297,14 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
user.remnawave_uuid,
user.telegram_id,
reset_reason,
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
@@ -219,18 +314,39 @@ class SubscriptionService:
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def disable_remnawave_user(self, user_uuid: str) -> bool:
async def _reset_user_traffic(
self,
api: RemnaWaveAPI,
user_uuid: str,
telegram_id: int,
reset_reason: Optional[str] = None,
) -> None:
if not user_uuid:
return
try:
async with self.api as api:
await api.reset_user_traffic(user_uuid)
reason_text = f" ({reset_reason})" if reset_reason else ""
logger.info(
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
)
except Exception as exc:
logger.warning(
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
)
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.get_api_client() as api:
await api.disable_user(user_uuid)
logger.info(f"✅ Отключен RemnaWave пользователь {user_uuid}")
return True
@@ -250,7 +366,7 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
return None
async with self.api as api:
async with self.get_api_client() as api:
updated_user = await api.revoke_user_subscription(user.remnawave_uuid)
subscription.remnawave_short_uuid = updated_user.short_uuid
@@ -268,7 +384,7 @@ class SubscriptionService:
async def get_subscription_info(self, short_uuid: str) -> Optional[dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
info = await api.get_subscription_info(short_uuid)
return info
@@ -287,7 +403,7 @@ class SubscriptionService:
if not user or not user.remnawave_uuid:
return False
async with self.api as api:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
return False
@@ -534,7 +650,7 @@ class SubscriptionService:
if user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
remnawave_user = await api.get_user_by_uuid(user.remnawave_uuid)
if not remnawave_user:
+380 -76
View File
@@ -4,10 +4,12 @@ import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings, settings
from app.config import Settings, settings, refresh_period_prices, refresh_traffic_prices
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
@@ -54,94 +56,172 @@ class ChoiceOption:
description: Optional[str] = None
class ReadOnlySettingError(RuntimeError):
"""Исключение, выбрасываемое при попытке изменить настройку только для чтения."""
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"}
CATEGORY_TITLES: Dict[str, str] = {
"SUPPORT": "Поддержка",
"ADMIN_NOTIFICATIONS": "Уведомления администраторов",
"ADMIN_REPORTS": "Автоотчеты",
"CHANNEL": "Обязательная подписка на канал",
"DATABASE": "База данных",
"POSTGRES": "PostgreSQL",
"SQLITE": "SQLite",
"REDIS": "Redis",
"REMNAWAVE": "Remnawave API",
"TRIAL": "Триал подписка",
"PAID_SUBSCRIPTION": "Платная подписка",
"SUBSCRIPTIONS_GLOBAL": "Глобальные параметры подписок",
"TRAFFIC": "Настройки трафика",
"PERIODS": "Периоды подписки",
"SUBSCRIPTION_PRICES": "Цены подписки",
"TRAFFIC_PACKAGES": "Пакеты трафика",
"DISCOUNTS": "Скидки промогрупп",
"REFERRAL": "Реферальная система",
"AUTOPAY": "Автопродление",
"TELEGRAM": "Telegram Stars",
"TRIBUTE": "Tribute",
"YOOKASSA": "YooKassa",
"CRYPTOBOT": "CryptoBot",
"MULENPAY": "MulenPay",
"PAL24": "PayPalych / Pal24",
"PAYMENT": "Описания платежей",
"INTERFACE_BRANDING": "Брендинг и логотип",
"INTERFACE_SUBSCRIPTION": "Блок подписки",
"CONNECT_BUTTON": "Кнопка «Подключиться»",
"HAPP": "Happ CryptoLink",
"SKIP": "Пропуски onboarding",
"MONITORING": "Мониторинг",
"NOTIFICATIONS": "Уведомления",
"SERVER": "Статус серверов",
"MAINTENANCE": "Технические работы",
"LOCALIZATION": "Локализация",
"ADDITIONAL": "Дополнительные настройки",
"BACKUP": "Бекапы",
"VERSION": "Проверка обновлений",
"LOG": "Логирование",
"WEBHOOK": "Вебхуки",
"DEBUG": "Режим разработки",
"CORE": "🤖 Основные настройки",
"SUPPORT": "💬 Поддержка и тикеты",
"LOCALIZATION": "🌍 Языки интерфейса",
"CHANNEL": "📣 Обязательная подписка",
"PAYMENT": "💳 Общие платежные настройки",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "🪙 CryptoBot",
"YOOKASSA": "🟣 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 PAL24 / PayPalych",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
"PERIODS": "📆 Периоды подписок",
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
"TRAFFIC": "📊 Трафик",
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
"TRIAL": "🎁 Пробный период",
"REFERRAL": "👥 Реферальная программа",
"AUTOPAY": "🔄 Автопродление",
"NOTIFICATIONS": "🔔 Уведомления пользователям",
"ADMIN_NOTIFICATIONS": "📣 Оповещения администраторам",
"ADMIN_REPORTS": "🗂 Автоматические отчеты",
"INTERFACE": "🎨 Интерфейс и брендинг",
"INTERFACE_BRANDING": "🖼️ Брендинг",
"INTERFACE_SUBSCRIPTION": "🔗 Ссылка на подписку",
"CONNECT_BUTTON": "🚀 Кнопка подключения",
"MINIAPP": "📱 Mini App",
"HAPP": "🅷 Happ",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Дополнительные приложения",
"DATABASE": "💾 База данных",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "🧱 SQLite",
"REDIS": "🧠 Redis",
"REMNAWAVE": "🌐 RemnaWave API",
"SERVER_STATUS": "📊 Статус серверов",
"MONITORING": "📈 Мониторинг",
"MAINTENANCE": "🔧 Обслуживание",
"BACKUP": "💾 Резервные копии",
"VERSION": "🔄 Проверка версий",
"WEB_API": "⚡ Web API",
"WEBHOOK": "🌐 Webhook",
"LOG": "📝 Логирование",
"DEBUG": "🧪 Режим разработки",
}
CATEGORY_DESCRIPTIONS: Dict[str, str] = {
"CORE": "Базовые параметры работы бота и обязательные ссылки.",
"SUPPORT": "Контакты поддержки, SLA и режимы обработки обращений.",
"LOCALIZATION": "Доступные языки, локализация интерфейса и выбор языка.",
"CHANNEL": "Настройки обязательной подписки на канал или группу.",
"PAYMENT": "Общие тексты платежей, описания чеков и шаблоны.",
"YOOKASSA": "Интеграция с YooKassa: идентификаторы магазина и вебхуки.",
"CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.",
"MULENPAY": "Платежи MulenPay и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
"PERIODS": "Доступные периоды подписок и продлений.",
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
"TRAFFIC": "Лимиты трафика и стратегии сброса.",
"TRAFFIC_PACKAGES": "Цены пакетов трафика и конфигурация предложений.",
"TRIAL": "Длительность и ограничения пробного периода.",
"REFERRAL": "Бонусы и пороги реферальной программы.",
"AUTOPAY": "Настройки автопродления и минимальный баланс.",
"NOTIFICATIONS": "Пользовательские уведомления и кэширование сообщений.",
"ADMIN_NOTIFICATIONS": "Оповещения админам о событиях и тикетах.",
"ADMIN_REPORTS": "Автоматические отчеты для команды.",
"INTERFACE": "Глобальные параметры интерфейса и брендирования.",
"INTERFACE_BRANDING": "Логотип и фирменный стиль.",
"INTERFACE_SUBSCRIPTION": "Отображение ссылок и кнопок подписок.",
"CONNECT_BUTTON": "Поведение кнопки «Подключиться» и miniapp.",
"MINIAPP": "Mini App и кастомные ссылки.",
"HAPP": "Интеграция Happ и связанные ссылки.",
"SKIP": "Настройки быстрого старта и гайд по подключению.",
"ADDITIONAL": "Конфигурация app-config.json, deep links и кеша.",
"DATABASE": "Режим работы базы данных и пути до файлов.",
"POSTGRES": "Параметры подключения к PostgreSQL.",
"SQLITE": "Файл SQLite и резервные параметры.",
"REDIS": "Подключение к Redis для кэша.",
"REMNAWAVE": "Параметры авторизации и интеграция с RemnaWave API.",
"SERVER_STATUS": "Отображение статуса серверов и external URL.",
"MONITORING": "Интервалы мониторинга и хранение логов.",
"MAINTENANCE": "Режим обслуживания, сообщения и интервалы.",
"BACKUP": "Резервное копирование и расписание.",
"VERSION": "Отслеживание обновлений репозитория.",
"WEB_API": "Web API, токены и права доступа.",
"WEBHOOK": "Пути и секреты вебхуков.",
"LOG": "Уровни логирования и ротация.",
"DEBUG": "Отладочные функции и безопасный режим.",
}
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
"LOCALES_PATH": "DATABASE",
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "SUBSCRIPTIONS_GLOBAL",
"LOCALES_PATH": "LOCALIZATION",
"CHANNEL_SUB_ID": "CHANNEL",
"CHANNEL_LINK": "CHANNEL",
"CHANNEL_IS_REQUIRED_SUB": "CHANNEL",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
"DEFAULT_DEVICE_LIMIT": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_LIMIT_GB": "SUBSCRIPTIONS_CORE",
"MAX_DEVICES_LIMIT": "SUBSCRIPTIONS_CORE",
"PRICE_PER_DEVICE": "SUBSCRIPTIONS_CORE",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTIONS_CORE",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_ENABLED": "AUTOPAY",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
"TRIAL_WARNING_HOURS": "TRIAL",
"SUPPORT_USERNAME": "SUPPORT",
"SUPPORT_MENU_ENABLED": "SUPPORT",
"SUPPORT_SYSTEM_MODE": "SUPPORT",
"SUPPORT_TICKET_SLA_ENABLED": "SUPPORT",
"SUPPORT_TICKET_SLA_MINUTES": "SUPPORT",
"SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS": "SUPPORT",
"SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES": "SUPPORT",
"ADMIN_NOTIFICATIONS_ENABLED": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_CHAT_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_TOPIC_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID": "ADMIN_NOTIFICATIONS",
"ADMIN_REPORTS_ENABLED": "ADMIN_REPORTS",
"ADMIN_REPORTS_CHAT_ID": "ADMIN_REPORTS",
"ADMIN_REPORTS_TOPIC_ID": "ADMIN_REPORTS",
"ADMIN_REPORTS_SEND_TIME": "ADMIN_REPORTS",
"PAYMENT_SERVICE_NAME": "PAYMENT",
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
"MONITORING_INTERVAL": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
@@ -150,14 +230,17 @@ class BotConfigurationService:
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"PAYMENT_SERVICE_NAME": "PAYMENT",
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
"INACTIVE_USER_DELETE_MONTHS": "MAINTENANCE",
"MAINTENANCE_MESSAGE": "MAINTENANCE",
"MAINTENANCE_CHECK_INTERVAL": "MAINTENANCE",
"MAINTENANCE_AUTO_ENABLE": "MAINTENANCE",
"WEBHOOK_URL": "WEBHOOK",
"WEBHOOK_SECRET": "WEBHOOK",
"VERSION_CHECK_ENABLED": "VERSION",
"VERSION_CHECK_REPO": "VERSION",
"VERSION_CHECK_INTERVAL_HOURS": "VERSION",
"TELEGRAM_STARS_RATE_RUB": "TELEGRAM",
"REMNAWAVE_USER_DESCRIPTION_TEMPLATE": "REMNAWAVE",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
@@ -182,17 +265,20 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
"MINIAPP_": "MINIAPP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
"SERVER_STATUS": "SERVER",
"SERVER_STATUS": "SERVER_STATUS",
"MAINTENANCE_": "MAINTENANCE",
"VERSION_CHECK": "VERSION",
"BACKUP_": "BACKUP",
"WEBHOOK_": "WEBHOOK",
"LOG_": "LOG",
"WEB_API_": "WEB_API",
"DEBUG": "DEBUG",
}
@@ -283,6 +369,173 @@ class BotConfigurationService:
],
}
SETTING_HINTS: Dict[str, Dict[str, str]] = {
"YOOKASSA_ENABLED": {
"description": "Включает оплату через YooKassa. Требует корректных идентификаторов магазина и секретного ключа.",
"format": "Булево значение: выберите \"Включить\" или \"Выключить\".",
"example": "Включено при полностью настроенной интеграции.",
"warning": "При включении без Shop ID и Secret Key пользователи увидят ошибки при оплате.",
"dependencies": "YOOKASSA_SHOP_ID, YOOKASSA_SECRET_KEY, YOOKASSA_RETURN_URL",
},
"CRYPTOBOT_ENABLED": {
"description": "Разрешает принимать криптоплатежи через CryptoBot.",
"format": "Булево значение.",
"example": "Включите после указания токена API и секрета вебхука.",
"warning": "Пустой токен или неверный вебхук приведут к отказам платежей.",
"dependencies": "CRYPTOBOT_API_TOKEN, CRYPTOBOT_WEBHOOK_SECRET",
},
"SUPPORT_TICKET_SLA_MINUTES": {
"description": "Лимит времени для ответа модераторов на тикет в минутах.",
"format": "Целое число от 1 до 1440.",
"example": "5",
"warning": "Слишком низкое значение может вызвать частые напоминания, слишком высокое — ухудшить SLA.",
"dependencies": "SUPPORT_TICKET_SLA_ENABLED, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES",
},
"MAINTENANCE_MODE": {
"description": "Переводит бота в режим технического обслуживания и скрывает действия для пользователей.",
"format": "Булево значение.",
"example": "Включено на время плановых работ.",
"warning": "Не забудьте отключить после завершения работ, иначе бот останется недоступен.",
"dependencies": "MAINTENANCE_MESSAGE, MAINTENANCE_CHECK_INTERVAL",
},
"REMNAWAVE_API_URL": {
"description": "Базовый адрес панели RemnaWave, с которой синхронизируется бот.",
"format": "Полный URL вида https://panel.example.com.",
"example": "https://panel.remnawave.net",
"warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.",
"dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD",
},
"EXTERNAL_ADMIN_TOKEN": {
"description": "Приватный токен, который использует внешняя админка для проверки запросов.",
"format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.",
"example": "Генерируется автоматически",
"warning": "Токен обновится при смене username или токена бота.",
"dependencies": "Username телеграм-бота, токен бота",
},
"EXTERNAL_ADMIN_TOKEN_BOT_ID": {
"description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.",
"format": "Проставляется автоматически после первого запуска и не редактируется вручную.",
"example": "123456789",
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
"dependencies": "Результат вызова getMe() в Telegram Bot API",
},
}
@classmethod
def get_category_description(cls, category_key: str) -> str:
return cls.CATEGORY_DESCRIPTIONS.get(category_key, "")
@classmethod
def is_toggle(cls, key: str) -> bool:
definition = cls.get_definition(key)
return definition.python_type is bool
@classmethod
def is_read_only(cls, key: str) -> bool:
return key in cls.READ_ONLY_KEYS
@classmethod
def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]:
if isinstance(value, bool):
return None
upper_key = key.upper()
if any(suffix in upper_key for suffix in ("PRICE", "_KOPEKS", "AMOUNT")):
try:
return settings.format_price(int(value))
except Exception:
return f"{value}"
if upper_key.endswith("_PERCENT") or "PERCENT" in upper_key:
return f"{value}%"
if upper_key.endswith("_HOURS"):
return f"{value} ч"
if upper_key.endswith("_MINUTES"):
return f"{value} мин"
if upper_key.endswith("_SECONDS"):
return f"{value} сек"
if upper_key.endswith("_DAYS"):
return f"{value} дн"
if upper_key.endswith("_GB"):
return f"{value} ГБ"
if upper_key.endswith("_MB"):
return f"{value} МБ"
return None
@classmethod
def _split_comma_values(cls, text: str) -> Optional[List[str]]:
raw = (text or "").strip()
if not raw or "," not in raw:
return None
parts = [segment.strip() for segment in raw.split(",") if segment.strip()]
return parts or None
@classmethod
def format_value_human(cls, key: str, value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "✅ ВКЛЮЧЕНО" if value else "❌ ВЫКЛЮЧЕНО"
if isinstance(value, (int, float)):
formatted = cls._format_numeric_with_unit(key, value)
return formatted or str(value)
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
return ""
if key in cls.PLAIN_TEXT_KEYS:
return cleaned
if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
return "••••••••"
items = cls._split_comma_values(cleaned)
if items:
return ", ".join(items)
return cleaned
if isinstance(value, (list, tuple, set)):
return ", ".join(str(item) for item in value)
if isinstance(value, dict):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value)
@classmethod
def get_setting_guidance(cls, key: str) -> Dict[str, str]:
definition = cls.get_definition(key)
original = cls.get_original_value(key)
type_label = definition.type_label
hints = dict(cls.SETTING_HINTS.get(key, {}))
base_description = (
hints.get("description")
or f"Параметр <b>{definition.display_name}</b> управляет категорией «{definition.category_label}»."
)
base_format = hints.get("format") or (
"Булево значение (да/нет)." if definition.python_type is bool
else "Введите значение соответствующего типа (число или строку)."
)
example = hints.get("example") or (
cls.format_value_human(key, original) if original is not None else ""
)
warning = hints.get("warning") or (
"Неверные значения могут привести к некорректной работе бота."
)
dependencies = hints.get("dependencies") or definition.category_label
return {
"description": base_description,
"format": base_format,
"example": example,
"warning": warning,
"dependencies": dependencies,
"type": type_label,
}
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
@@ -436,7 +689,7 @@ class BotConfigurationService:
@classmethod
def format_value_for_list(cls, key: str) -> str:
value = cls.get_current_value(key)
formatted = cls.format_value(value)
formatted = cls.format_value_human(key, value)
if formatted == "":
return formatted
return _truncate(formatted)
@@ -541,6 +794,8 @@ class BotConfigurationService:
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, parsed_value)
await cls._sync_default_web_api_token()
@classmethod
async def reload(cls) -> None:
cls._overrides_raw.clear()
@@ -636,26 +891,74 @@ class BotConfigurationService:
return parsed_value
@classmethod
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
async def set_value(
cls,
db: AsyncSession,
key: str,
value: Any,
*,
force: bool = False,
) -> None:
if cls.is_read_only(key) and not force:
raise ReadOnlySettingError(f"Setting {key} is read-only")
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, value)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
async def reset_value(cls, db: AsyncSession, key: str) -> None:
async def reset_value(
cls,
db: AsyncSession,
key: str,
*,
force: bool = False,
) -> None:
if cls.is_read_only(key) and not force:
raise ReadOnlySettingError(f"Setting {key} is read-only")
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
original = cls.get_original_value(key)
cls._apply_to_settings(key, original)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
def _apply_to_settings(cls, key: str, value: Any) -> None:
try:
setattr(settings, key, value)
if key in {
"PRICE_14_DAYS",
"PRICE_30_DAYS",
"PRICE_60_DAYS",
"PRICE_90_DAYS",
"PRICE_180_DAYS",
"PRICE_360_DAYS",
}:
refresh_period_prices()
elif key.startswith("PRICE_TRAFFIC_") or key == "TRAFFIC_PACKAGES_CONFIG":
refresh_traffic_prices()
except Exception as error:
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
@staticmethod
async def _sync_default_web_api_token() -> None:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
return
success = await ensure_default_web_api_token()
if not success:
logger.warning(
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
)
@classmethod
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
definition = cls.get_definition(key)
@@ -666,12 +969,13 @@ class BotConfigurationService:
return {
"key": key,
"name": definition.display_name,
"current": cls.format_value(current),
"original": cls.format_value(original),
"current": cls.format_value_human(key, current),
"original": cls.format_value_human(key, original),
"type": definition.type_label,
"category_key": definition.category_key,
"category_label": definition.category_label,
"has_override": has_override,
"is_read_only": cls.is_read_only(key),
}
+150 -15
View File
@@ -2,23 +2,28 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update
from sqlalchemy import delete, select, update, func
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user
add_user_balance, subtract_user_balance, update_user, delete_user,
get_users_spending_stats
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import (
get_subscription_by_user_id,
decrement_subscription_server_counts,
)
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
AdvertisingCampaign
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
)
from app.config import settings
@@ -141,20 +146,32 @@ class UserService:
"has_next": False,
"has_prev": False
}
async def get_users_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None,
order_by_balance: bool = False
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
db,
offset=offset,
limit=limit,
status=status,
order_by_balance=order_by_balance,
order_by_traffic=order_by_traffic,
order_by_last_activity=order_by_last_activity,
order_by_total_spent=order_by_total_spent,
order_by_purchase_count=order_by_purchase_count,
)
total_count = await get_users_count(db, status=status)
@@ -179,7 +196,110 @@ class UserService:
"has_next": False,
"has_prev": False
}
async def get_user_spending_stats_map(
self,
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
try:
return await get_users_spending_stats(db, user_ids)
except Exception as e:
logger.error(f"Ошибка получения статистики трат пользователей: {e}")
return {}
async def get_users_by_campaign_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
campaign_ranked = (
select(
AdvertisingCampaignRegistration.user_id.label("user_id"),
AdvertisingCampaignRegistration.campaign_id.label("campaign_id"),
AdvertisingCampaignRegistration.created_at.label("created_at"),
func.row_number()
.over(
partition_by=AdvertisingCampaignRegistration.user_id,
order_by=AdvertisingCampaignRegistration.created_at.desc(),
)
.label("rn"),
)
.cte("campaign_ranked")
)
latest_campaign = (
select(
campaign_ranked.c.user_id,
campaign_ranked.c.campaign_id,
campaign_ranked.c.created_at,
)
.where(campaign_ranked.c.rn == 1)
.subquery()
)
query = (
select(
User,
AdvertisingCampaign.name.label("campaign_name"),
latest_campaign.c.created_at,
)
.join(latest_campaign, latest_campaign.c.user_id == User.id)
.join(
AdvertisingCampaign,
AdvertisingCampaign.id == latest_campaign.c.campaign_id,
)
.order_by(
AdvertisingCampaign.name.asc(),
latest_campaign.c.created_at.desc(),
)
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
users = [row[0] for row in rows]
campaign_map = {
row[0].id: {
"campaign_name": row[1],
"registered_at": row[2],
}
for row in rows
}
total_stmt = select(func.count()).select_from(latest_campaign)
total_result = await db.execute(total_stmt)
total_count = total_result.scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 1
return {
"users": users,
"campaigns": campaign_map,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
"has_next": page < total_pages,
"has_prev": page > 1,
}
except Exception as e:
logger.error(f"Ошибка получения пользователей по кампаниям: {e}")
return {
"users": [],
"campaigns": {},
"current_page": 1,
"total_pages": 1,
"total_count": 0,
"has_next": False,
"has_prev": False,
}
async def update_user_balance(
self,
db: AsyncSession,
@@ -203,7 +323,14 @@ class UserService:
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
success = True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
success = await subtract_user_balance(
db,
user,
abs(amount_kopeks),
description,
create_transaction=True,
payment_method=PaymentMethod.MANUAL,
)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
@@ -231,15 +358,17 @@ class UserService:
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None, None
return False, None, None, None
old_group = user.promo_group
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
return False, None, None
return False, None, None, old_group
user.promo_group_id = promo_group.id
user.promo_group = promo_group
@@ -254,12 +383,12 @@ class UserService:
promo_group.name,
)
return True, user, promo_group
return True, user, promo_group, old_group
except Exception as e:
await db.rollback()
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
return False, None, None
return False, None, None, None
async def block_user(
self,
@@ -408,7 +537,13 @@ class UserService:
)
)
subscription_servers = subscription_servers_result.scalars().all()
await decrement_subscription_server_counts(
db,
user.subscription,
subscription_servers=subscription_servers,
)
if subscription_servers:
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
await db.execute(
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import secrets
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud import web_api_token as crud
from app.database.models import WebApiToken
from app.database.universal_migration import ensure_default_web_api_token
from app.utils.security import generate_api_token, hash_api_token
class WebApiTokenService:
"""Сервис для управления токенами административного веб-API."""
def __init__(self):
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
def hash_token(self, token: str) -> str:
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
async def authenticate(
self,
db: AsyncSession,
token_value: str,
*,
remote_ip: Optional[str] = None,
) -> Optional[WebApiToken]:
normalized_value = token_value.strip()
if not normalized_value:
return None
async def _load_token(value: str) -> Optional[WebApiToken]:
token_hash = self.hash_token(value)
return await crud.get_token_by_hash(db, token_hash)
token = await _load_token(normalized_value)
if not token:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if default_token and secrets.compare_digest(default_token, normalized_value):
await ensure_default_web_api_token()
token = await _load_token(default_token)
if not token or not token.is_active:
return None
if token.expires_at and token.expires_at < datetime.utcnow():
return None
token.last_used_at = datetime.utcnow()
if remote_ip:
token.last_used_ip = remote_ip
await db.flush()
return token
async def create_token(
self,
db: AsyncSession,
*,
name: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
token_value: Optional[str] = None,
) -> Tuple[str, WebApiToken]:
plain_token = token_value or generate_api_token()
token_hash = self.hash_token(plain_token)
token = await crud.create_token(
db,
name=name,
token_hash=token_hash,
token_prefix=plain_token[:12],
description=description,
expires_at=expires_at,
created_by=created_by,
)
return plain_token, token
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = False
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = True
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
web_api_token_service = WebApiTokenService()
+30 -2
View File
@@ -1,6 +1,7 @@
from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_language = State()
waiting_for_rules_accept = State()
waiting_for_referral_code = State()
@@ -86,6 +87,12 @@ class AdminStates(StatesGroup):
editing_user_traffic = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
creating_faq_title = State()
creating_faq_content = State()
editing_faq_title = State()
editing_faq_content = State()
editing_notification_value = State()
confirming_sync = State()
@@ -96,17 +103,32 @@ class AdminStates(StatesGroup):
editing_server_limit = State()
editing_server_description = State()
editing_server_promo_groups = State()
creating_server_uuid = State()
creating_server_name = State()
creating_server_price = State()
creating_server_country = State()
editing_welcome_text = State()
waiting_for_message_buttons = "waiting_for_message_buttons"
editing_promo_offer_message = State()
editing_promo_offer_button = State()
editing_promo_offer_valid_hours = State()
editing_promo_offer_active_duration = State()
editing_promo_offer_discount = State()
editing_promo_offer_test_duration = State()
editing_promo_offer_squads = State()
selecting_promo_offer_user = State()
searching_promo_offer_user = State()
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
viewing_user_from_traffic_list = State()
viewing_user_from_last_activity_list = State()
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
@@ -126,6 +148,12 @@ class SupportSettingsStates(StatesGroup):
class BotConfigStates(StatesGroup):
waiting_for_value = State()
waiting_for_search_query = State()
waiting_for_import_file = State()
class PricingStates(StatesGroup):
waiting_for_value = State()
class AutoPayStates(StatesGroup):
setting_autopay_days = State()
+13 -7
View File
@@ -8,22 +8,28 @@ from app.states import RegistrationStates
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
registration_callbacks = [
"rules_accept",
"rules_decline",
"rules_accept",
"rules_decline",
"referral_skip"
]
language_select_prefix = "language_select:"
if current_state in registration_states:
return True
if (isinstance(event, CallbackQuery)
and event.data
and event.data in registration_callbacks):
if (isinstance(event, CallbackQuery)
and event.data
and (
event.data in registration_callbacks
or event.data.startswith(language_select_prefix)
)):
return True
return False
return False
+44 -25
View File
@@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
return dt.strftime(format_str)
def format_time_ago(dt: Union[datetime, str]) -> str:
def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
@@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
now = datetime.utcnow()
diff = now - dt
language_code = (language or "ru").split("-")[0].lower()
if diff.days > 0:
if diff.days == 1:
return "вчера"
elif diff.days < 7:
return f"{diff.days} дн. назад"
elif diff.days < 30:
weeks = diff.days // 7
return f"{weeks} нед. назад"
elif diff.days < 365:
months = diff.days // 30
return f"{months} мес. назад"
else:
years = diff.days // 365
return f"{years} г. назад"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} ч. назад"
elif diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} мин. назад"
else:
return "только что"
return "yesterday" if language_code == "en" else "вчера"
if diff.days < 7:
value = diff.days
if language_code == "en":
suffix = "day" if value == 1 else "days"
return f"{value} {suffix} ago"
return f"{value} дн. назад"
if diff.days < 30:
value = diff.days // 7
if language_code == "en":
suffix = "week" if value == 1 else "weeks"
return f"{value} {suffix} ago"
return f"{value} нед. назад"
if diff.days < 365:
value = diff.days // 30
if language_code == "en":
suffix = "month" if value == 1 else "months"
return f"{value} {suffix} ago"
return f"{value} мес. назад"
value = diff.days // 365
if language_code == "en":
suffix = "year" if value == 1 else "years"
return f"{value} {suffix} ago"
return f"{value} г. назад"
if diff.seconds > 3600:
value = diff.seconds // 3600
if language_code == "en":
suffix = "hour" if value == 1 else "hours"
return f"{value} {suffix} ago"
return f"{value} ч. назад"
if diff.seconds > 60:
value = diff.seconds // 60
if language_code == "en":
suffix = "minute" if value == 1 else "minutes"
return f"{value} {suffix} ago"
return f"{value} мин. назад"
return "just now" if language_code == "en" else "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
+88 -6
View File
@@ -1,10 +1,14 @@
from pathlib import Path
from aiogram.types import Message, FSInputFile, InputMediaPhoto
from typing import Any, Dict
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile, InputMediaPhoto, Message
from app.config import settings
from app.localization.texts import get_texts
LOGO_PATH = Path(settings.LOGO_FILE)
_PRIVACY_RESTRICTED_CODE = "BUTTON_USER_PRIVACY_RESTRICTED"
def is_qr_message(message: Message) -> bool:
@@ -15,6 +19,65 @@ _original_answer = Message.answer
_original_edit_text = Message.edit_text
def _get_language(message: Message) -> str | None:
try:
user = message.from_user
if user and getattr(user, "language_code", None):
return user.language_code
except AttributeError:
pass
return None
def _default_privacy_hint(language: str | None) -> str:
if language and language.lower().startswith("en"):
return (
"⚠️ Telegram blocked the contact request button because of your privacy settings. "
"Please allow sharing your contact information or send the required details manually."
)
return (
"⚠️ Telegram запретил кнопку запроса контакта из-за настроек приватности. "
"Разрешите отправку контакта в настройках Telegram или отправьте данные вручную."
)
def append_privacy_hint(text: str | None, language: str | None) -> str:
base_text = text or ""
try:
hint = get_texts(language).t(
"PRIVACY_RESTRICTED_BUTTON_HINT",
default=_default_privacy_hint(language),
)
except Exception:
hint = _default_privacy_hint(language)
hint = hint.strip()
if not hint:
return base_text
if hint in base_text:
return base_text
if base_text:
return f"{base_text}\n\n{hint}"
return hint
def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]:
safe_kwargs: Dict[str, Any] = dict(kwargs or {})
safe_kwargs.pop("reply_markup", None)
return safe_kwargs
def is_privacy_restricted_error(error: Exception) -> bool:
if not isinstance(error, TelegramBadRequest):
return False
message = getattr(error, "message", "") or ""
description = str(error)
return _PRIVACY_RESTRICTED_CODE in message or _PRIVACY_RESTRICTED_CODE in description
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
@@ -25,11 +88,20 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
return await _original_answer(self, text, **kwargs)
except Exception:
pass
language = _get_language(self)
if LOGO_PATH.exists():
try:
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
return await _original_answer(self, fallback_text, **safe_kwargs)
# Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст
return await _original_answer(self, text, **kwargs)
except Exception:
# Фоллбек, если Telegram ругается на caption: отправим как текст
return await _original_answer(self, text, **kwargs)
return await _original_answer(self, text, **kwargs)
@@ -39,6 +111,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if not settings.ENABLE_LOGO_MODE:
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
# Если caption потенциально слишком длинный — отправим как текст вместо caption
try:
if text is not None and len(text) > 900:
@@ -58,14 +131,23 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
else:
media = self.photo[-1].file_id
media_kwargs = {"media": media, "caption": text}
if "parse_mode" in kwargs:
_pm = kwargs.pop("parse_mode")
edit_kwargs = dict(kwargs)
if "parse_mode" in edit_kwargs:
_pm = edit_kwargs.pop("parse_mode")
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
else:
media_kwargs["parse_mode"] = "HTML"
try:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
except TelegramBadRequest:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **edit_kwargs)
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
fallback_text = append_privacy_hint(text, language)
safe_kwargs = prepare_privacy_safe_kwargs(kwargs)
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, fallback_text, **safe_kwargs)
# Фоллбек: удалим и отправим обычный текст без фото
try:
await self.delete()
+2 -2
View File
@@ -48,8 +48,8 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
if settings.is_pal24_enabled():
methods.append({
"id": "pal24",
"name": "Банковская карта",
"icon": "💳",
"name": "СБП",
"icon": "🏦",
"description": "через PayPalych",
"callback": "topup_pal24"
})
+68 -27
View File
@@ -3,7 +3,13 @@ from aiogram.exceptions import TelegramBadRequest
from aiogram.types import FSInputFile, InputMediaPhoto
from app.config import settings
from .message_patch import LOGO_PATH, is_qr_message
from .message_patch import (
LOGO_PATH,
append_privacy_hint,
is_privacy_restricted_error,
is_qr_message,
prepare_privacy_safe_kwargs,
)
def _resolve_media(message: types.Message):
@@ -17,35 +23,69 @@ def _resolve_media(message: types.Message):
return FSInputFile(LOGO_PATH)
def _get_language(callback: types.CallbackQuery) -> str | None:
try:
user = callback.from_user
if user and getattr(user, "language_code", None):
return user.language_code
except AttributeError:
pass
return None
def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None):
kwargs: dict[str, object] = {}
if parse_mode is not None:
kwargs["parse_mode"] = parse_mode
if keyboard is not None:
kwargs["reply_markup"] = keyboard
return kwargs
async def _answer_text(
callback: types.CallbackQuery,
caption: str,
keyboard: types.InlineKeyboardMarkup | None,
parse_mode: str | None,
error: TelegramBadRequest | None = None,
) -> None:
language = _get_language(callback)
kwargs = _build_base_kwargs(keyboard, parse_mode)
if error and is_privacy_restricted_error(error):
caption = append_privacy_hint(caption, language)
kwargs = prepare_privacy_safe_kwargs(kwargs)
kwargs.setdefault("parse_mode", parse_mode or "HTML")
await callback.message.answer(
caption,
**kwargs,
)
async def edit_or_answer_photo(
callback: types.CallbackQuery,
caption: str,
keyboard: types.InlineKeyboardMarkup,
parse_mode: str | None = "HTML",
) -> None:
resolved_parse_mode = parse_mode or "HTML"
# Если режим логотипа выключен — работаем текстом
if not settings.ENABLE_LOGO_MODE:
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
else:
await callback.message.edit_text(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
parse_mode=resolved_parse_mode,
)
except TelegramBadRequest:
except TelegramBadRequest as error:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
# Если текст слишком длинный для caption — отправим как текст
@@ -53,13 +93,9 @@ async def edit_or_answer_photo(
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
except TelegramBadRequest:
pass
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
except TelegramBadRequest as error:
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
media = _resolve_media(callback.message)
@@ -68,7 +104,14 @@ async def edit_or_answer_photo(
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
reply_markup=keyboard,
)
except TelegramBadRequest:
except TelegramBadRequest as error:
if is_privacy_restricted_error(error):
try:
await callback.message.delete()
except Exception:
pass
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
return
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
try:
await callback.message.delete()
@@ -80,12 +123,10 @@ async def edit_or_answer_photo(
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
caption=caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
parse_mode=resolved_parse_mode,
)
except TelegramBadRequest as photo_error:
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
except Exception:
# Последний фоллбек — обычный текст
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
)
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
+7
View File
@@ -50,6 +50,13 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
discount_value = amount * clamped_percent // 100
discounted_amount = amount - discount_value
# Round the discounted price up to the nearest full ruble (100 kopeks)
# to avoid undercharging users because of fractional kopeks.
if discount_value >= 100 and discounted_amount % 100:
discounted_amount += 100 - (discounted_amount % 100)
discounted_amount = min(discounted_amount, amount)
discount_value = amount - discounted_amount
logger.debug(
"Применена скидка %s%%: %s%s (скидка %s)",
clamped_percent,
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import html
import math
from datetime import datetime
from typing import Optional, Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
from app.database.models import ServerSquad, SubscriptionTemporaryAccess, User
def _escape_format_braces(text: str) -> str:
"""Escape braces so str.format treats them as literals."""
return text.replace("{", "{{").replace("}", "}}")
def get_user_active_promo_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if expires_at and expires_at <= datetime.utcnow():
return 0
return max(0, min(100, percent))
def _format_time_left(seconds_left: int, language: str) -> str:
total_minutes = max(1, math.ceil(seconds_left / 60))
days, remainder_minutes = divmod(total_minutes, 60 * 24)
hours, minutes = divmod(remainder_minutes, 60)
language_code = (language or "ru").split("-")[0].lower()
if language_code == "en":
day_label, hour_label, minute_label = "d", "h", "m"
else:
day_label, hour_label, minute_label = "д", "ч", "м"
parts: list[str] = []
if days:
parts.append(f"{days}{day_label}")
if hours or days:
parts.append(f"{hours}{hour_label}")
parts.append(f"{minutes}{minute_label}")
return " ".join(parts)
def _build_progress_bar(seconds_left: int, total_seconds: int) -> str:
if total_seconds <= 0:
total_seconds = seconds_left or 1
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
bar_length = 10
filled_segments = int(round(ratio * bar_length))
filled_segments = max(0, min(bar_length, filled_segments))
if filled_segments == 0 and seconds_left > 0:
filled_segments = 1
return f"[{'' * filled_segments}{'' * (bar_length - filled_segments)}]"
async def build_promo_offer_timer_line(
db: AsyncSession,
user: User,
texts,
) -> Optional[str]:
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
if not expires_at:
return None
now = datetime.utcnow()
if expires_at <= now:
return None
seconds_left = int((expires_at - now).total_seconds())
if seconds_left <= 0:
return None
total_seconds: Optional[int] = None
source = getattr(user, "promo_offer_discount_source", None)
try:
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
except Exception:
offer = None
if offer and getattr(offer, "claimed_at", None):
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
if total_seconds <= 0:
total_seconds = None
if total_seconds is None and offer:
extra_data = getattr(offer, "extra_data", None)
if isinstance(extra_data, dict):
raw_duration = (
extra_data.get("active_discount_hours")
or extra_data.get("duration_hours")
)
else:
raw_duration = None
try:
if raw_duration:
total_seconds = int(float(raw_duration) * 3600)
except (TypeError, ValueError):
total_seconds = None
if total_seconds is None or total_seconds <= 0:
total_seconds = seconds_left
bar = _build_progress_bar(seconds_left, total_seconds)
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
template = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
)
return template.format(bar=bar, time_left=time_left_text)
async def build_promo_offer_hint(
db: AsyncSession,
user: User,
texts,
percent: Optional[int] = None,
) -> Optional[str]:
if percent is None:
percent = get_user_active_promo_discount_percent(user)
if percent <= 0:
return None
base_hint = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
).format(percent=percent)
timer_line = await build_promo_offer_timer_line(db, user, texts)
if timer_line:
return f"{base_hint}\n{timer_line}"
return base_hint
async def build_test_access_hint(
db: AsyncSession,
user: User,
texts,
) -> Optional[str]:
subscription = getattr(user, "subscription", None)
if not subscription:
return None
subscription_id = getattr(subscription, "id", None)
if not subscription_id:
return None
now = datetime.utcnow()
result = await db.execute(
select(SubscriptionTemporaryAccess)
.options(selectinload(SubscriptionTemporaryAccess.offer))
.where(
SubscriptionTemporaryAccess.subscription_id == subscription_id,
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
SubscriptionTemporaryAccess.expires_at > now,
)
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
)
entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all()
active_entries = [
entry for entry in entries if entry.expires_at and entry.expires_at > now
]
if not active_entries:
return None
latest_expiry = max(entry.expires_at for entry in active_entries)
seconds_left = int((latest_expiry - now).total_seconds())
if seconds_left <= 0:
return None
total_seconds: Optional[int] = None
for entry in active_entries:
offer = entry.offer
claimed_at = getattr(offer, "claimed_at", None) if offer else None
if claimed_at:
total = int((entry.expires_at - claimed_at).total_seconds())
if total > 0 and (total_seconds is None or total > total_seconds):
total_seconds = total
if total_seconds is None or total_seconds <= 0:
total_seconds = seconds_left
bar = _build_progress_bar(seconds_left, total_seconds)
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
unique_squad_uuids: list[str] = []
seen_squads: set[str] = set()
for entry in active_entries:
squad_uuid = getattr(entry, "squad_uuid", None)
if squad_uuid and squad_uuid not in seen_squads:
seen_squads.add(squad_uuid)
unique_squad_uuids.append(squad_uuid)
squad_display_names: list[str] = []
if unique_squad_uuids:
squads_result = await db.execute(
select(ServerSquad.squad_uuid, ServerSquad.display_name).where(
ServerSquad.squad_uuid.in_(unique_squad_uuids)
)
)
names_map = {
squad_uuid: html.escape(display_name)
for squad_uuid, display_name in squads_result.all()
if display_name
}
for squad_uuid in unique_squad_uuids:
if squad_uuid in names_map:
squad_display_names.append(names_map[squad_uuid])
else:
squad_display_names.append(html.escape(squad_uuid))
if squad_display_names:
servers_display = ", ".join(squad_display_names)
elif unique_squad_uuids:
servers_display = ", ".join(
html.escape(squad_uuid) for squad_uuid in unique_squad_uuids
)
else:
servers_display = str(len(active_entries))
header_template = texts.t(
"MAIN_MENU_TEST_ACCESS_HEADER",
"🧪 Test servers active: {servers}",
)
timer_template = texts.t(
"MAIN_MENU_TEST_ACCESS_TIMER",
"⏳ Access active for {time_left}\n<code>{bar}</code>",
)
header = header_template.format(
servers=_escape_format_braces(servers_display)
)
timer_line = timer_template.format(time_left=time_left_text, bar=bar)
return f"{header}\n{timer_line}"
+28
View File
@@ -0,0 +1,28 @@
"""Утилиты безопасности и генерации ключей."""
from __future__ import annotations
import hashlib
import secrets
from typing import Literal
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
"""Возвращает хеш токена в формате hex."""
normalized = (algorithm or "sha256").lower()
if normalized not in {"sha256", "sha384", "sha512"}:
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
digest = getattr(hashlib, normalized)
return digest(token.encode("utf-8")).hexdigest()
def generate_api_token(length: int = 48) -> str:
"""Генерирует криптографически стойкий токен."""
length = max(24, min(length, 128))
return secrets.token_urlsafe(length)
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
+184
View File
@@ -0,0 +1,184 @@
import platform
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, Iterable, List, Optional, Sequence, Tuple
@dataclass
class StepRecord:
title: str
icon: str
status_label: str
message: str
duration: float
class StageHandle:
def __init__(
self,
timeline: "StartupTimeline",
title: str,
icon: str,
success_message: Optional[str],
) -> None:
self.timeline = timeline
self.title = title
self.icon = icon
self.message = success_message or ""
self.status_icon = ""
self.status_label = "Готово"
self._explicit_status = False
def success(self, message: Optional[str] = None) -> None:
if message is not None:
self.message = message
self.status_icon = ""
self.status_label = "Готово"
self._explicit_status = True
def warning(self, message: str) -> None:
self.status_icon = "⚠️"
self.status_label = "Предупреждение"
self.message = message
self._explicit_status = True
def skip(self, message: str) -> None:
self.status_icon = "⏭️"
self.status_label = "Пропущено"
self.message = message
self._explicit_status = True
def failure(self, message: str) -> None:
self.status_icon = ""
self.status_label = "Ошибка"
self.message = message
self._explicit_status = True
def log(self, message: str, icon: str = "") -> None:
self.timeline.logger.info(f"{icon} {message}")
class StartupTimeline:
def __init__(self, logger: Any, app_name: str) -> None:
self.logger = logger
self.app_name = app_name
self.steps: List[StepRecord] = []
def _record_step(
self, title: str, icon: str, status_label: str, message: str, duration: float
) -> None:
self.steps.append(
StepRecord(
title=title,
icon=icon,
status_label=status_label,
message=message,
duration=duration,
)
)
def log_banner(self, metadata: Optional[Sequence[Tuple[str, Any]]] = None) -> None:
title_text = f"🚀 {self.app_name}"
subtitle_parts = [f"Python {platform.python_version()}"]
if metadata:
for key, value in metadata:
subtitle_parts.append(f"{key}: {value}")
subtitle_text = " | ".join(subtitle_parts)
width = max(len(title_text), len(subtitle_text))
border = "" + "" * (width + 2) + ""
self.logger.info(border)
self.logger.info("" + title_text.ljust(width) + "")
self.logger.info("" + subtitle_text.ljust(width) + "")
self.logger.info("" + "" * (width + 2) + "")
def log_section(self, title: str, lines: Iterable[str], icon: str = "📄") -> None:
items = [f"{icon} {title}"] + [f"{line}" for line in lines]
width = max(len(item) for item in items)
top = "" + "" * width + ""
middle = "" + "" * width + ""
bottom = "" + "" * width + ""
self.logger.info(top)
self.logger.info("" + items[0].ljust(width) + "")
self.logger.info(middle)
for item in items[1:]:
self.logger.info("" + item.ljust(width) + "")
self.logger.info(bottom)
def add_manual_step(
self,
title: str,
icon: str,
status_label: str,
message: str,
) -> None:
self.logger.info(f"{icon} {title}")
self.logger.info(f"{icon} {title}{status_label}: {message}")
self._record_step(title, icon, status_label, message, 0.0)
@asynccontextmanager
async def stage(
self,
title: str,
icon: str = "⚙️",
description: Optional[str] = None,
success_message: Optional[str] = "Готово",
):
if description:
self.logger.info(f"{icon} {title}{description}")
else:
self.logger.info(f"{icon} {title}")
handle = StageHandle(self, title, icon, success_message)
start_time = time.perf_counter()
try:
yield handle
except Exception as exc:
message = str(exc)
handle.failure(message)
self.logger.exception(f"┣ ❌ {title} — ошибка: {message}")
raise
finally:
duration = time.perf_counter() - start_time
if not handle._explicit_status:
handle.success(handle.message or "Готово")
self.logger.info(
f"{handle.status_icon} {title}{handle.message} [{duration:.2f}s]"
)
self._record_step(
title=title,
icon=handle.status_icon,
status_label=handle.status_label,
message=handle.message,
duration=duration,
)
def log_summary(self) -> None:
if not self.steps:
return
lines = []
for step in self.steps:
base = (
f"{step.icon} {step.title}{step.status_label}"
f" [{step.duration:.2f}s]"
)
if step.message:
base += f" :: {step.message}"
lines.append(base)
width = max(len(line) for line in lines)
border_top = "" + "" * (width + 2) + ""
border_mid = "" + "" * (width + 2) + ""
border_bottom = "" + "" * (width + 2) + ""
title = "РЕЗЮМЕ ЗАПУСКА"
self.logger.info(border_top)
self.logger.info("" + title.center(width) + "")
self.logger.info(border_mid)
for line in lines:
self.logger.info("" + line.ljust(width) + "")
self.logger.info(border_bottom)
+38 -5
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime
from typing import Optional
from urllib.parse import quote
from urllib.parse import quote, urlparse, urlunparse
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
@@ -48,18 +48,36 @@ async def update_or_create_subscription(
for key, value in subscription_data.items():
if hasattr(existing_subscription, key):
setattr(existing_subscription, key, value)
existing_subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing_subscription)
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
return existing_subscription
else:
subscription_defaults = dict(subscription_data)
autopay_enabled = subscription_defaults.pop(
"autopay_enabled", None
)
autopay_days_before = subscription_defaults.pop(
"autopay_days_before", None
)
new_subscription = Subscription(
user_id=user_id,
**subscription_data
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
**subscription_defaults
)
db.add(new_subscription)
@@ -141,3 +159,18 @@ def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optio
return f"{template}{encoded_link}"
return f"{template}{encoded_link}"
def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]:
if not subscription_link:
return None
parsed_link = urlparse(subscription_link)
if parsed_link.scheme.lower() == "happ":
return subscription_link
if not parsed_link.scheme:
return subscription_link
return urlunparse(parsed_link._replace(scheme="happ"))
+91
View File
@@ -0,0 +1,91 @@
"""Utilities for validating Telegram WebApp initialization data."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from typing import Any, Dict
from urllib.parse import parse_qsl
class TelegramWebAppAuthError(Exception):
"""Raised when Telegram WebApp init data fails validation."""
def parse_webapp_init_data(
init_data: str,
bot_token: str,
*,
max_age_seconds: int = 86400,
) -> Dict[str, Any]:
"""Validate and parse Telegram WebApp init data.
Args:
init_data: Raw init data string provided by Telegram WebApp.
bot_token: Bot token used to verify the signature.
max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours.
Returns:
Parsed init data as a dictionary.
Raises:
TelegramWebAppAuthError: If validation fails.
"""
if not init_data:
raise TelegramWebAppAuthError("Missing init data")
if not bot_token:
raise TelegramWebAppAuthError("Bot token is not configured")
parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True)
data: Dict[str, Any] = {key: value for key, value in parsed_pairs}
received_hash = data.pop("hash", None)
if not received_hash:
raise TelegramWebAppAuthError("Missing init data signature")
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(data.items())
)
secret_key = hmac.new(
key=b"WebAppData",
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
computed_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
raise TelegramWebAppAuthError("Invalid init data signature")
auth_date_raw = data.get("auth_date")
if auth_date_raw is not None:
try:
auth_date = int(auth_date_raw)
except (TypeError, ValueError):
raise TelegramWebAppAuthError("Invalid auth_date value") from None
if max_age_seconds and auth_date:
current_ts = int(time.time())
if current_ts - auth_date > max_age_seconds:
raise TelegramWebAppAuthError("Init data is too old")
data["auth_date"] = auth_date
user_payload = data.get("user")
if user_payload is not None:
try:
data["user"] = json.loads(user_payload)
except json.JSONDecodeError as error:
raise TelegramWebAppAuthError("Invalid user payload") from error
return data
+1 -1
View File
@@ -189,7 +189,7 @@ async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int
and_(
Transaction.user_id == referral.id,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True
Transaction.is_completed.is_(True)
)
)
)
+17
View File
@@ -143,6 +143,23 @@ def sanitize_html(text: str) -> str:
return text
def sanitize_telegram_name(name: Optional[str]) -> Optional[str]:
"""Санитизация Telegram-имени для безопасной вставки в HTML и хранения.
Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги.
"""
if not name:
return name
try:
return (
name.replace('<', '')
.replace('>', '')
.replace('&', '')
.strip()
)
except Exception:
return name
def validate_device_count(count: Union[str, int]) -> Optional[int]:
try:
count_int = int(count)
+5
View File
@@ -0,0 +1,5 @@
"""Пакет административного веб-API."""
from .app import create_web_api_app
from .server import WebAPIServer
__all__ = ["create_web_api_app", "WebAPIServer"]
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from .middleware import RequestLoggingMiddleware
from .routes import (
broadcasts,
backups,
campaigns,
config,
health,
main_menu_buttons,
promocodes,
miniapp,
promo_groups,
promo_offers,
pages,
remnawave,
stats,
subscriptions,
tickets,
tokens,
transactions,
users,
)
OPENAPI_TAGS = [
{
"name": "health",
"description": "Мониторинг состояния административного API и связанных сервисов.",
},
{
"name": "stats",
"description": "Сводные показатели по пользователям, подпискам и платежам.",
},
{
"name": "settings",
"description": "Получение и изменение конфигурации бота из административной панели.",
},
{
"name": "main-menu",
"description": "Управление кнопками главного меню Telegram-бота.",
},
{
"name": "users",
"description": "Управление пользователями, балансом и статусами подписок.",
},
{
"name": "subscriptions",
"description": "Создание, продление и настройка подписок бота.",
},
{
"name": "support",
"description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.",
},
{
"name": "transactions",
"description": "История финансовых операций и пополнений баланса.",
},
{
"name": "promo-groups",
"description": "Создание и управление промо-группами и их участниками.",
},
{
"name": "promo-offers",
"description": "Управление промо-предложениями, шаблонами и журналом событий.",
},
{
"name": "auth",
"description": "Управление токенами доступа к административному API.",
},
{
"name": "remnawave",
"description": (
"Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией "
"данных между ботом и панелью."
),
},
{
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
]
def create_web_api_app() -> FastAPI:
docs_config = settings.get_web_api_docs_config()
app = FastAPI(
title=settings.WEB_API_TITLE,
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
openapi_tags=OPENAPI_TAGS,
swagger_ui_parameters={"persistAuthorization": True},
)
allowed_origins = settings.get_web_api_allowed_origins()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if settings.WEB_API_REQUEST_LOGGING:
app.add_middleware(RequestLoggingMiddleware)
app.include_router(health.router)
app.include_router(stats.router, prefix="/stats", tags=["stats"])
app.include_router(config.router, prefix="/settings", tags=["settings"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promo_offers.router, prefix="/promo-offers", tags=["promo-offers"])
app.include_router(
main_menu_buttons.router,
prefix="/main-menu/buttons",
tags=["main-menu"],
)
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
app.include_router(backups.router, prefix="/backups", tags=["backups"])
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
return app
+1
View File
@@ -0,0 +1 @@
"""Background utilities for Web API."""
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from app.services.backup_service import backup_service
@dataclass(slots=True)
class BackupTaskState:
task_id: str
status: str = "queued"
message: Optional[str] = None
file_path: Optional[str] = None
created_by: Optional[int] = None
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
class BackupTaskManager:
def __init__(self) -> None:
self._tasks: dict[str, BackupTaskState] = {}
self._lock = asyncio.Lock()
async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState:
task_id = uuid.uuid4().hex
state = BackupTaskState(task_id=task_id, created_by=created_by)
async with self._lock:
self._tasks[task_id] = state
asyncio.create_task(self._run_task(state))
return state
async def _run_task(self, state: BackupTaskState) -> None:
state.status = "running"
state.updated_at = datetime.utcnow()
try:
success, message, file_path = await backup_service.create_backup(
created_by=state.created_by
)
state.message = message
state.file_path = file_path
state.status = "completed" if success else "failed"
except Exception as exc: # noqa: BLE001
state.status = "failed"
state.message = f"Unexpected error: {exc}"
finally:
state.updated_at = datetime.utcnow()
async def get(self, task_id: str) -> Optional[BackupTaskState]:
async with self._lock:
return self._tasks.get(task_id)
async def list(self, *, active_only: bool = False) -> list[BackupTaskState]:
async with self._lock:
states = list(self._tasks.values())
if active_only:
return [
state
for state in states
if state.status in {"queued", "running"}
]
return states
backup_task_manager = BackupTaskManager()
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
from app.database.models import WebApiToken
from app.services.web_api_token_service import web_api_token_service
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def require_api_token(
request: Request,
api_key_header: str | None = Security(api_key_header_scheme),
db: AsyncSession = Depends(get_db_session),
) -> WebApiToken:
api_key = api_key_header
if not api_key:
authorization = request.headers.get("Authorization")
if authorization:
scheme, _, credentials = authorization.partition(" ")
if scheme.lower() == "bearer" and credentials:
api_key = credentials
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key",
)
token = await web_api_token_service.authenticate(
db,
api_key,
remote_ip=request.client.host if request.client else None,
)
if not token:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired API key",
)
await db.commit()
return token
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import logging
from time import monotonic
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("web_api")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Логирование входящих запросов в административный API."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
start = monotonic()
response: Response | None = None
try:
response = await call_next(request)
return response
finally:
duration_ms = (monotonic() - start) * 1000
status = response.status_code if response else "error"
logger.info(
"%s %s -> %s (%.2f ms)",
request.method,
request.url.path,
status,
duration_ms,
)
+33
View File
@@ -0,0 +1,33 @@
from . import (
config,
health,
main_menu_buttons,
miniapp,
promo_offers,
pages,
promo_groups,
remnawave,
stats,
subscriptions,
tickets,
tokens,
transactions,
users,
)
__all__ = [
"config",
"health",
"main_menu_buttons",
"miniapp",
"promo_offers",
"pages",
"promo_groups",
"remnawave",
"stats",
"subscriptions",
"tickets",
"tokens",
"transactions",
"users",
]

Some files were not shown because too many files have changed in this diff Show More