Compare commits

...

230 Commits

Author SHA1 Message Date
Egor cdd1e3f107 Update Dockerfile 2025-10-09 08:18:59 +03:00
Egor 6c6ade4f7f Update docker-registry.yml 2025-10-09 08:18:48 +03:00
Egor 30ad692bc2 Update docker-hub.yml 2025-10-09 08:18:37 +03:00
Egor 24b3ea4881 Merge pull request #984 from Fr1ngg/dev4
Апгрейд miniapp с подпиской
2025-10-09 08:13:28 +03:00
Egor 560689d9ee Merge pull request #983 from Fr1ngg/nx6oya-bedolaga/add-device-removal-feature-to-miniapp
Add device removal controls to miniapp
2025-10-09 08:01:30 +03:00
Egor aa763403c6 Add device removal support to miniapp 2025-10-09 08:01:13 +03:00
Egor 96c0c3562d Update index.html 2025-10-09 07:43:37 +03:00
Egor e48eaf584c Merge pull request #982 from Fr1ngg/ybcgxa-bedolaga/add-affiliate-section-to-miniapp
Update referral terms copy
2025-10-09 07:41:13 +03:00
Egor 790cd6bab3 Update referral terms labels 2025-10-09 07:40:57 +03:00
Egor 55ae2bd91c Merge pull request #981 from Fr1ngg/revert-980-jb9c12-bedolaga/add-affiliate-section-to-miniapp
Revert "Normalize referral rewards and adjust mini app spacing"
2025-10-09 07:39:50 +03:00
Egor d431931501 Revert "Normalize referral rewards and adjust mini app spacing" 2025-10-09 07:39:42 +03:00
Egor 425d15c850 Merge pull request #980 from Fr1ngg/jb9c12-bedolaga/add-affiliate-section-to-miniapp
Normalize referral rewards and adjust mini app spacing
2025-10-09 07:28:45 +03:00
Egor 58affda938 Fix referral reward normalization and spacing 2025-10-09 07:28:29 +03:00
Egor c4eb2a60de Merge pull request #979 from Fr1ngg/revert-978-ec880l-bedolaga/add-affiliate-section-to-miniapp
Revert "Fix referral bonus display and layout spacing"
2025-10-09 07:28:05 +03:00
Egor 46dca8c095 Revert "Fix referral bonus display and layout spacing" 2025-10-09 07:27:57 +03:00
Egor b752278398 Merge pull request #978 from Fr1ngg/ec880l-bedolaga/add-affiliate-section-to-miniapp
Fix referral bonus display and layout spacing
2025-10-09 07:24:40 +03:00
Egor 6a659a2131 Fix referral terms display and spacing 2025-10-09 07:24:20 +03:00
Egor 019397b0ea Merge pull request #977 from Fr1ngg/revert-976-tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Revert "Enhance referral mini app card summary"
2025-10-09 07:23:37 +03:00
Egor 947a400909 Revert "Enhance referral mini app card summary" 2025-10-09 07:23:29 +03:00
Egor 7f52beb2a2 Merge pull request #976 from Fr1ngg/tvd4k7-bedolaga/add-affiliate-section-to-miniapp
Enhance referral mini app card summary
2025-10-09 07:06:47 +03:00
Egor 283d42af86 Enhance referral summary in mini app 2025-10-09 07:06:29 +03:00
Egor d36d1e3041 Merge pull request #975 from Fr1ngg/5o8uht-bedolaga/add-promo-code-activation-field
Improve promo code button layout on mini app
2025-10-09 06:32:51 +03:00
Egor a83bc25bd0 Improve promo code button layout on mini app 2025-10-09 06:32:34 +03:00
Egor 3555ada3b5 Merge pull request #974 from Fr1ngg/revert-973-ek2x9x-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:27:52 +03:00
Egor 1cb93d9688 Revert "Add promo code activation UI to mini app" 2025-10-09 06:27:44 +03:00
Egor 6f9fc5e071 Merge pull request #973 from Fr1ngg/ek2x9x-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:26:16 +03:00
Egor 96d466328c Add promo code activation flow to mini app 2025-10-09 06:25:47 +03:00
Egor 846e7844db Merge pull request #972 from Fr1ngg/revert-971-f0jtfw-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation support in mini app"
2025-10-09 06:25:19 +03:00
Egor 4e0b0837d9 Revert "Add promo code activation support in mini app" 2025-10-09 06:25:11 +03:00
Egor 8ba5cda095 Merge pull request #971 from Fr1ngg/f0jtfw-bedolaga/add-promo-code-activation-field
Add promo code activation support in mini app
2025-10-09 06:23:08 +03:00
Egor 7ac33bce28 Add promo code activation flow to mini app 2025-10-09 06:22:51 +03:00
Egor d8a0c166ac Merge pull request #970 from Fr1ngg/revert-969-x3v2no-bedolaga/add-promo-code-activation-field
Revert "Add promo code activation UI to mini app"
2025-10-09 06:22:14 +03:00
Egor c1858fe925 Revert "Add promo code activation UI to mini app" 2025-10-09 06:22:06 +03:00
Egor dae8e4c34c Merge pull request #969 from Fr1ngg/x3v2no-bedolaga/add-promo-code-activation-field
Add promo code activation UI to mini app
2025-10-09 06:19:52 +03:00
Egor 3d7c219fd1 feat: add promo code activation to miniapp 2025-10-09 06:19:37 +03:00
Egor 03781463e4 Merge pull request #968 from Fr1ngg/nnkucu-bedolaga/add-faq-section-to-/miniapp/index.html
Expose legal documents in mini app subscription view
2025-10-09 06:02:54 +03:00
Egor dcd9074136 Expose legal documents in mini app 2025-10-09 06:02:37 +03:00
Egor 7fbd26c8c3 Merge pull request #967 from Fr1ngg/revert-966-wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ section to mini app"
2025-10-09 06:02:05 +03:00
Egor 9677a55375 Revert "Add FAQ section to mini app" 2025-10-09 06:01:50 +03:00
Egor eb51cda617 Merge pull request #966 from Fr1ngg/wq02f5-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ section to mini app
2025-10-09 05:49:20 +03:00
Egor 09bdde6f91 Add FAQ section to mini app 2025-10-09 05:49:01 +03:00
Egor 784b2ed996 Merge pull request #965 from Fr1ngg/revert-964-cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "Add FAQ support to mini app subscription page"
2025-10-09 05:48:47 +03:00
Egor 9bee4a2365 Revert "Add FAQ support to mini app subscription page" 2025-10-09 05:48:39 +03:00
Egor f8946b99f3 Merge pull request #964 from Fr1ngg/cg1tpe-bedolaga/add-faq-section-to-/miniapp/index.html
Add FAQ support to mini app subscription page
2025-10-09 05:47:44 +03:00
Egor a17cbf4448 Add FAQ section to miniapp and expose data 2025-10-09 05:47:27 +03:00
Egor b0422e0c0f Merge pull request #963 from Fr1ngg/revert-962-io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
Revert "feat: show FAQ in mini app"
2025-10-09 05:47:09 +03:00
Egor 9d0bf8b3cf Revert "feat: show FAQ in mini app" 2025-10-09 05:47:00 +03:00
Egor 8c5e8ca019 Merge pull request #962 from Fr1ngg/io42y3-bedolaga/add-faq-section-to-/miniapp/index.html
feat: show FAQ in mini app
2025-10-09 05:45:55 +03:00
Egor 0327adb8da feat: show FAQ in mini app 2025-10-09 05:45:40 +03:00
Egor 5769fa63a5 Merge pull request #961 from Fr1ngg/rkljpj-bedolaga/add-promo-offer-display-on-subscription-page
Display active test access promo offers
2025-10-09 05:20:01 +03:00
Egor 1d09006b7c Display active test access promo offers 2025-10-09 05:19:44 +03:00
Egor e194ab9729 Merge pull request #960 from Fr1ngg/revert-959-36674g-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Fix promo offer timers by normalizing date parsing"
2025-10-09 05:18:46 +03:00
Egor 45a6173916 Revert "Fix promo offer timers by normalizing date parsing" 2025-10-09 05:18:38 +03:00
Egor b21baba006 Merge pull request #959 from Fr1ngg/36674g-bedolaga/add-promo-offer-display-on-subscription-page
Fix promo offer timers by normalizing date parsing
2025-10-09 05:09:51 +03:00
Egor 45c1b4695c Fix promo offer timer timezone handling 2025-10-09 05:09:31 +03:00
Egor 9b2b28423d Merge pull request #958 from Fr1ngg/revert-957-xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer display to subscription miniapp"
2025-10-09 05:07:44 +03:00
Egor 9d00c2ddc6 Revert "Add promo offer display to subscription miniapp" 2025-10-09 05:07:35 +03:00
Egor dc66676663 Merge pull request #957 from Fr1ngg/xzisus-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer display to subscription miniapp
2025-10-09 04:54:21 +03:00
Egor 1d600e2c63 Add promo offer display to subscription miniapp 2025-10-09 04:54:05 +03:00
Egor 5ce177a7b5 Merge pull request #956 from Fr1ngg/revert-955-4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Revert "Add promo offer banners to mini app"
2025-10-09 04:53:29 +03:00
Egor d181a333d7 Revert "Add promo offer banners to mini app" 2025-10-09 04:53:20 +03:00
Egor 3694cb4ed7 Merge pull request #955 from Fr1ngg/4ttjow-bedolaga/add-promo-offer-display-on-subscription-page
Add promo offer banners to mini app
2025-10-09 04:51:18 +03:00
Egor 051048e0ae Add promo offer banners to mini app 2025-10-09 04:50:46 +03:00
Egor 61ee6e42cf Merge pull request #954 from Fr1ngg/revert-953-x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
Revert "feat: surface promo offers in mini app"
2025-10-09 04:50:35 +03:00
Egor 5bd618a799 Revert "feat: surface promo offers in mini app" 2025-10-09 04:50:27 +03:00
Egor 7794a4cc7b Merge pull request #953 from Fr1ngg/x57z5w-bedolaga/add-promo-offer-display-on-subscription-page
feat: surface promo offers in mini app
2025-10-09 04:43:18 +03:00
Egor 6d01d32dc2 feat: show promo offers in miniapp 2025-10-09 04:42:58 +03:00
Egor be71def203 Update index.html 2025-10-09 04:22:01 +03:00
Egor 7a2de410bd Update index.html 2025-10-09 04:15:51 +03:00
Egor b019e8b32d Update index.html 2025-10-09 04:11:57 +03:00
Egor cb4f773eb1 Update index.html 2025-10-09 04:09:28 +03:00
Egor 29fe9f4943 Merge pull request #952 from Fr1ngg/hycxed-bedolaga/add-user-promotion-group-display
Refine mini app promo section layout and discount display
2025-10-09 04:03:51 +03:00
Egor 63edfa852d Improve promo section presentation and discounts 2025-10-09 04:03:37 +03:00
Egor 66fabb6836 Merge pull request #951 from Fr1ngg/revert-950-5ob0h8-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI and period discounts"
2025-10-09 04:03:19 +03:00
Egor f29ea49185 Revert "Improve promo group UI and period discounts" 2025-10-09 04:03:10 +03:00
Egor 7620a3df92 Merge pull request #950 from Fr1ngg/5ob0h8-bedolaga/add-user-promotion-group-display
Improve promo group UI and period discounts
2025-10-09 04:02:20 +03:00
Egor 577bcfdc88 Improve promo group UI and period discounts 2025-10-09 04:02:00 +03:00
Egor 33ec09edd0 Merge pull request #949 from Fr1ngg/revert-948-ess9tg-bedolaga/add-user-promotion-group-display
Revert "Improve promo group UI with collapsible discounts"
2025-10-09 04:01:47 +03:00
Egor 7b3c463fa2 Revert "Improve promo group UI with collapsible discounts" 2025-10-09 04:01:40 +03:00
Egor 0e541af5e1 Merge pull request #948 from Fr1ngg/ess9tg-bedolaga/add-user-promotion-group-display
Improve promo group UI with collapsible discounts
2025-10-09 04:00:32 +03:00
Egor e39822e732 Improve promo group UI with collapsible discounts 2025-10-09 04:00:18 +03:00
Egor c7973e7b58 Merge pull request #947 from Fr1ngg/revert-946-0zyc5p-bedolaga/add-user-promotion-group-display
Revert "Display promo group discounts in mini app"
2025-10-09 03:59:47 +03:00
Egor 4941052d57 Revert "Display promo group discounts in mini app" 2025-10-09 03:59:40 +03:00
Egor 0022abc6e0 Merge pull request #946 from Fr1ngg/0zyc5p-bedolaga/add-user-promotion-group-display
Display promo group discounts in mini app
2025-10-09 03:45:11 +03:00
Egor acef77b0e8 Display promo group discounts in mini app 2025-10-09 03:44:54 +03:00
Egor 4ddd580347 Merge pull request #945 from Fr1ngg/revert-944-nafa9w-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in the mini app"
2025-10-09 03:44:44 +03:00
Egor 42cc4db077 Revert "Display promo level discounts in the mini app" 2025-10-09 03:44:35 +03:00
Egor 8e3f976441 Merge pull request #944 from Fr1ngg/nafa9w-bedolaga/add-user-promotion-group-display
Display promo level discounts in the mini app
2025-10-09 03:40:28 +03:00
Egor 190d9ae13e Show promo level discounts when available 2025-10-09 03:39:59 +03:00
Egor d6deec1a29 Merge pull request #943 from Fr1ngg/revert-942-38969u-bedolaga/add-user-promotion-group-display
Revert "Display promo level discounts in mini app"
2025-10-09 03:39:44 +03:00
Egor e8dcbd09ff Revert "Display promo level discounts in mini app" 2025-10-09 03:39:33 +03:00
Egor 382f1eb6f7 Merge pull request #942 from Fr1ngg/38969u-bedolaga/add-user-promotion-group-display
Display promo level discounts in mini app
2025-10-09 03:38:30 +03:00
Egor 2853410ebc Show discount details for auto promo groups 2025-10-09 03:38:14 +03:00
Egor 2bde6a83ed Merge pull request #941 from Fr1ngg/revert-940-gbzjeq-bedolaga/add-user-promotion-group-display
Revert "Show promo group levels in mini app subscription view"
2025-10-09 03:36:29 +03:00
Egor 83b1fa0f0f Revert "Show promo group levels in mini app subscription view" 2025-10-09 03:36:21 +03:00
Egor 3a2a05d307 Merge pull request #940 from Fr1ngg/gbzjeq-bedolaga/add-user-promotion-group-display
Show promo group levels in mini app subscription view
2025-10-09 03:25:40 +03:00
Egor 2460766e30 Show promo group info and auto levels in miniapp 2025-10-09 03:25:25 +03:00
Egor 3eb01eaabc Merge pull request #939 from Fr1ngg/revert-938-pwo411-bedolaga/add-user-promotion-group-display
Revert "Show promo group and auto levels in mini app"
2025-10-09 03:24:36 +03:00
Egor 35bca4166d Revert "Show promo group and auto levels in mini app" 2025-10-09 03:24:28 +03:00
Egor f2db938fdc Merge pull request #938 from Fr1ngg/pwo411-bedolaga/add-user-promotion-group-display
Show promo group and auto levels in mini app
2025-10-09 03:22:18 +03:00
Egor ff0c68afc6 Show promo group and auto levels in mini app 2025-10-09 03:21:40 +03:00
Egor 67babf7566 Update README.md 2025-10-08 20:21:06 +03:00
Egor f71788d2d4 Update README.md 2025-10-08 20:11:24 +03:00
Egor 5eebdcc85c Update README.md 2025-10-08 20:02:57 +03:00
Egor 4b89de9634 Update README.md 2025-10-08 19:57:45 +03:00
Egor b81d5b199b Update README.md 2025-10-08 19:55:33 +03:00
Egor a21559cd0d Merge pull request #931 from Fr1ngg/jehc2b-bedolaga/-readme
docs: expand README functional overview
2025-10-08 19:46:23 +03:00
Egor 6cd3f87318 docs: expand feature overview in README 2025-10-08 19:46:04 +03:00
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
45 changed files with 7933 additions and 484 deletions
+1
View File
@@ -147,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
+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.5-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-$(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.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-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.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-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.5-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.4.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.4.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.7-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.4.5"
ARG VERSION="v2.4.7"
ARG BUILD_DATE
ARG VCS_REF
+200 -214
View File
@@ -35,7 +35,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + YooKassa + MulenPay + PayPalych (СБП)
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
@@ -696,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
@@ -951,105 +952,111 @@ WEBHOOK_PATH=/webhook
### 👤 **Для пользователей**
🧭 **Онбординг и доступ**
- 🌐 Выбор языка интерфейса, динамическая локализация и настройка кнопки меню.
- 📜 Принятие правил, оферты и политики конфиденциальности прямо в боте.
- 📡 Проверка подписки на обязательный канал, deeplink-инвайты и UTM-кампании.
- 👤 Сбор контактных данных с антиспам-защитой и быстрым восстановлением доступа.
🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14-360 дней)
- 📊 Выбор трафика или фиксированный лимит
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств и серверов прямо при оформлении
- 🧾 Понятный калькулятор стоимости с учетом всех опций
- 📅 Гибкие периоды (14360 дней) и скидки за длительные подписки.
- 📊 Выбор трафика: фиксированный лимит, квоты или безлимит.
- 🌍 Автоматический выбор сквада или мультивыбор из десятков серверов.
- 📱 Настройка количества устройств, стран и доп. услуг прямо в чек-ауте.
- 🧾 Динамический калькулятор стоимости, черновики и возвращение к незавершённым заказам.
🧪 **Тестовая подписка**
- Настраиваемый триал-период
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
- Гибко настраиваемый триал и welcome-цепочка.
- Уведомления об истечении и автоконверсия в платный тариф.
- Автовыдача бонусов за участие в кампаниях и инвайт-линках.
- Контроль за обязательной подпиской на канал даже в режиме триала.
💰 **Удобные платежи**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa (включая СБП и онлайн-чек)
- 💳 MulenPay
- 💳 PayPalych (Pal24)
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
- 🎁 Реферальные и промо-бонусы
- Детальная история транзакций и чеков
💰 **Платежи и баланс**
- ⭐ Telegram Stars, 💳 Tribute, 💳 YooKassa (СБП + онлайн-чек), 💳 MulenPay, 🏦 PayPalych (СБП), 💰 CryptoBot.
- 📥 Автогенерация счетов, контроль статусов через вебхуки и уведомления.
- 💼 История операций с фильтрами, квитанциями и выгрузкой чеков.
- 🔄 Автоплатёж с настройкой дня списания и уведомлениями об ошибках.
- 🎁 Реферальные, промо- и кэшбэк-бонусы автоматически начисляются на баланс.
📱 **Управление подписками**
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
- 🔄 Управление трафиком и лимитами без участия оператора
- 🌍 Переключение серверов Remnawave на лету
- 📱 Управление устройствами + сброс HWID
- 🧩 Смена языка и персональных настроек
- 📈 Реальный трафик, устройства и география серверов прямо в диалоге.
- 🌍 Переключение серверов, стран и лимитов без помощи оператора.
- 📱 Сброс HWID, повторная выдача конфигов и инструкции подключения.
- 🧩 Смена языка, промогруппы и персональных параметров.
- 🧾 Просмотр активных услуг, статуса оплаты и сроков действия.
🎁 **Бонусная система**
- 🎫 Промокоды на деньги/дни/длинный триал
- 👥 Защищенная реферальная программа с комиссиями и антифродом
- 📊 Детальная статистика рефералов и кампаний
- 📨 Персональные рассылки и уведомления о новых бонусах
🛟 **Поддержка и самообслуживание**
- 🎫 Тикеты с приоритетами, вложениями и уведомлениями об ответе.
- 📚 FAQ, правила, оферта и политика доступны из меню.
- 💬 Быстрые ссылки на живую поддержку или кастомные каналы связи.
🧩 **Бонусы и рефералы**
- 🎫 Промокоды на деньги, дни, устройства, страны и расширенные триалы.
- 👥 Реферальная программа с комиссиями, бонусами и антифрод-фильтрами.
- 🧾 Подробная аналитика доходов, конверсии и активности приглашённых.
- 🔗 Генерация реферальных ссылок и QR-кодов в один тап.
🪪 **Mini App и инструкции**
- 📱 Telegram WebApp с обзором подписки, конфигами и кнопкой «Подключить».
- 📥 Библиотека загрузочных ссылок для клиентов Happ и кастомных гайдов.
- 🛰️ Web API для получения статуса подписки в мини-приложениях и внешних витринах.
</td>
<td width="50%" valign="top">
### ⚙️ **Для администраторов**
📊 **Мощная аналитика**
- 👥 Детальная статистика пользователей и подписок
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
📊 **Аналитика и отчётность**
- 📈 Дашборды по пользователям, подпискам, трафику и удержанию.
- 💰 Детализация платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot).
- 🧮 Продажи по тарифам, устройствам, странам и промогруппам.
- 📣 Эффективность кампаний, промокодов, UTM-источников и реферальных потоков.
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами и ручные начисления
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
- 🔄 Сброс HWID устройств и обновление конфигураций
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 Изменение типа подписки триал/платная и ручное продление
- 🚫 Блокировка/разблокировка/удаление аккаунтов
- 🔍 Поиск, фильтры и детальные карточки с историей операций.
- 💰 Ручное изменение баланса, компенсации и корректировки.
- 📱 Изменение лимитов устройств, трафика, стран и подключенных сквадов.
- 🔄 Сброс HWID, перегенерация подписки и синхронизация с Remnawave.
- 🎯 Назначение промогрупп, тарифов и ручных продлений/обновлений.
- 🚫 Блокировки с таймером, полное удаление и аудит действий персонала.
🎯 **Маркетинг и продажи**
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
- 📊 Детальная статистика использования промокодов и кампаний
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
🎯 **Продажи, маркетинг и удержание**
- 🎫 Промокоды (разовые/многоразовые), бонусные предложения и бесплатные пакеты.
- 💳 Промогруппы со скидками на серверы, трафик, устройства и допуслуги.
- 📣 Кампании с deeplink-ссылками, UTM и автопродвижением триалов.
- 📨 Рассылки по сегментам, кастомные кнопки и медиа, отмена «в полёте».
- 🔘 Настройка кнопок главного меню, приветственных экранов и мини-приложений.
🖥️ **Умный мониторинг**
- 💚 Контроль доступности Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
- 📦 Проверка обновлений проекта и уведомления о новых релизах
🛟 **Поддержка и модерация**
- 🎫 Центр тикетов с приоритетами, статусами и перепиской.
- 🧑‍⚖️ Разграничение ролей (админы, модераторы), журнал аудита действий.
- ⏱️ SLA таймеры, автоматические напоминания и уведомления в канал.
- 🚫 Массовые блокировки нарушителей и автоматическое снятие блокировок.
- 🧾 История диалогов, прикреплённые файлы и быстрые ответы.
📨 **Коммуникации и поддержка**
- 📢 Рассылки по сегментам и мгновенные оповещения
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
- 💬 Система поддержки с HTML разметкой и история обращений
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
🔔 **Уведомления и коммуникации**
- 📢 Топики для событий (покупки, триалы, техработы, ошибки интеграций).
- 🔔 Настройка пользовательских и админских уведомлений, расписаний и каналов.
- 📨 Шаблоны правил, оферты, FAQ, приветствий и страницы поддержки.
- 💬 Автоматические сообщения о задолженностях, автоплатежах и продлениях.
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала, продления и конверсии в платную подписку
- 💎 Покупка подписки и пополнение баланса
- 🔄 Изменения параметров подписки пользователем
- 🚧 Потеря соединения с Remnawave и включение техработ
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
🧰 **Обслуживание и DevOps**
- 🛠️ `install_bot.sh`: управление контейнерами, логами, обновлениями, прокси и резервными копиями.
- 🚧 Ручной и авто-режим техработ с кастомными сообщениями.
- 🗒️ Просмотр системных логов, health-check, мониторинг Remnawave и серверов.
- ♻️ Проверка обновлений репозитория, уведомления о новых версиях и плавный деплой.
🗄️ **Бекапы и восстановление**
- Ручной запуск бекапа с выбором содержимого (включая логи)
- Автоматические бекапы по расписанию и управление retention-политикой
- Восстановление из бекапа без остановки сервиса
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
- 🗓️ Планировщик авто-бекапов, гибкая политика хранения и компрессия.
- 📦 Ручные бекапы с выбором содержимого (БД, конфиги, логи).
- 📤 Отправка архивов в выделенный чат/топик, журнал попыток и статусов.
- 🔁 Восстановление из бекапа без остановки бота, проверка целостности.
💳 **Автоплатежи и биллинг**
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
- Принудительная проверка готовности автосписаний
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
- Настройка минимального баланса и логирование всех списаний
💳 **Биллинг и автоплатежи**
- ⚙️ Глобальные настройки автосписаний, минимального баланса и напоминаний.
- 🧪 Тестовые платежи для каждого провайдера прямо из админки.
- 🪝 Управление вебхуками Tribute, YooKassa, CryptoBot, MulenPay и PayPalych.
- 🧾 Экспорт транзакций, сверка с платёжками и интеграция с бухгалтерией.
</td>
</tr>
@@ -1064,6 +1071,16 @@ WEBHOOK_PATH=/webhook
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
### 🌐 Веб-API, мини-приложение и интеграции
- ⚙️ **FastAPI Web API** (`app/webapi`) с эндпоинтами для управления токенами, конфигурациями, рассылками, кампаниями, промогруппами, статистикой, подписками, транзакциями, тикетами и бекапами.
- 🔑 **Управление API-ключами** — выпуск, отзыв, реактивация и аудит токенов прямо из админки.
- 🛡️ **Гибкая авторизация** — API-key, Bearer, Basic Auth и cookie eGames для совместимости с внешними панелями.
- 🛰️ **Mini App** — статический фронтенд в `miniapp/` и proxied эндпоинты `/miniapp/*` для отображения статуса подписки внутри Telegram.
- 🧭 **App Config** — централизованная раздача ссылок на клиенты и инструкции (`app-config.json`, `miniapp/app-config.json`).
- 🪝 **Платёжные вебхуки** — встроенные Flask-серверы для Tribute, YooKassa, CryptoBot, MulenPay и PayPalych (Pal24) с health-check и логированием.
- 📡 **Мониторинг серверов** — REST-эндпоинты для просмотра нод Remnawave, статистики нагрузки и управления сквадами из внешних систем.
### 🧭 Навигация по проекту
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
@@ -1349,121 +1366,6 @@ ADMIN_NOTIFICATIONS_TOPIC_ID=123 # ID топика (опционал
- **🔗 SQLAlchemy ORM** - безопасная работа с БД
- **🚀 aiogram 3** - современная Telegram Bot API
### 📁 Структура проекта
```
bedolaga_bot/
├── 🎯 main.py # Точка входа
├── 📦 requirements.txt # Зависимости
├── ⚙️ .env.example # Конфиг
├── ⚙️ app-config.json # Информация для гайда подключения
├── 📱 app/
│ ├── 🤖 bot.py # Инициализация бота
│ ├── ⚙️ config.py # Настройки
│ ├── 🎛️ states.py # FSM состояния
│ │
│ ├── 🎮 handlers/ # Обработчики событий
│ │ ├── 🏠 start.py # Регистрация и старт
│ │ ├── 🛒 subscription.py # Подписки
│ │ ├── 💰 balance.py # Баланс и платежи
│ │ ├── 🎁 promocode.py # Промокоды
│ │ ├── 👥 referral.py # Реферальная система
│ │ ├── 🌟 stars_payment.py # Start платежи
│ │ ├── common.py
│ │ ├── 💬 support.py # Техподдержка
│ │ └── 👑 admin/ # Админ панель
│ │ ├── 📊 statistics.py # Статистика
│ │ ├── 🗄️ backup.py # Бекапы
│ │ ├── 👥 users.py # Управление юзерами
│ │ ├── 🎫 promocodes.py # Управление промокодами
│ │ ├── 🚧 maintenance.py # Тех работы
│ │ ├── 📨 messages.py # Рассылки
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
│ │ ├── ⚙️ main.py # Админское меню
│ │ ├── 📖 rules.py # Правила
│ │ ├── 🙋 referrals.py # Правила
│ │ ├── 🌎 servers.py # Сервера
│ │ ├── 📱 subscriptions.py # Подписки
│ │ ├── 🔍 monitoring.py # Мониторинг
│ │ └── 🔗 remnawave.py # Система Remnawave
│ │
│ ├── 🗄️ database/ # База данных
│ │ ├── 📊 models.py # Модели SQLAlchemy
│ │ ├── 🔗 database.py # Подключение к БД
│ │ ├── 🔄 universal_migration.py # Миграции
│ │ └── 📝 crud/ # CRUD операции
│ │ ├── 👤 user.py # Операции с пользователями
│ │ ├── 📋 subscription.py # Операции с подписками
│ │ ├── 💰 transaction.py # Операции с транзакциями
│ │ ├── 📜 rules.py # Правила сервиса
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
│ │ ├── 💳 yookassa.py # YooKassa операции
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Партнерка
│ │
│ ├── 🔧 services/ # Бизнес-логика
│ │ ├── 👤 user_service.py # Сервис пользователей
│ │ ├── 📋 subscription_service.py # Сервис подписок
│ │ ├── 💰 payment_service.py # Платежи
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Партнерка
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
│ │
│ ├── 🛠️ utils/ # Утилиты
│ │ ├── 🎨 decorators.py # Декораторы
│ │ ├── 📝 formatters.py # Форматирование данных
│ │ ├── ✅ validators.py # Валидация
│ │ ├── ✅ subscription_utils.py # Проверка подписок
│ │ ├── 📄 pagination.py # Пагинация
│ │ ├── 📄 pricing_utils.py # Цены
│ │ ├── 📄 message_patch.py
│ │ ├── 📄 photo_message.py
│ │ ├── ‼️ global_error.py # Обработка ошибок
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
│ │ └── ⚡ cache.py # Кеширование
│ │
│ ├── 🛡️ middlewares/ # Middleware
│ │ ├── 🔐 auth.py # Авторизация
│ │ ├── 📋 logging.py # Логирование
│ │ ├── 🚧 maintenance.py # тех работы
│ │ ├── 🔐 subscription_checker.py # тех работы
│ │ ├── 🔐 channel_checker.py
│ │ └── ⏱️ throttling.py # Ограничение запросов
│ │
│ ├── 🌐 localization/ # Локализация
│ │ ├── 📝 texts.py # Тексты интерфейса
│ │ └── 🗣️ languages/
│ │
│ ├── ⌨️ keyboards/ # Клавиатуры
│ │ ├── 🔗 inline.py # Inline клавиатуры
│ │ ├── 💬 reply.py # Reply клавиатуры
│ │ └── 👑 admin.py # Админские клавиатуры
│ │
│ └── 🔌 external/ # Внешние API
│ ├── 🌐 remnawave_api.py # Remnawave API
│ ├── ⭐ telegram_stars.py # Telegram Stars
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
│ ├── 🌐 webhook_server.py # Webhook сервер
│ ├── 💳 cryptobot.py # CryptoBot Api
│ └── 🎖️ tribute.py # Tribute платежи
├── 🔄 migrations/ # Миграции БД
└── 📋 logs/ # Логи системы
```
---
## 🐛 Устранение неполадок
@@ -1707,62 +1609,146 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
<tr>
<td>🥇</td>
<td><strong>@SmartSystemCompany</strong></td>
<td>₽8,500</td>
<td>За щедрую поддержку и вклад в развитие</td>
</tr>
<tr>
<td>🥈</td>
<td><strong>@pilot_737800</strong></td>
<td>₽4,750</td>
<td>₽7,750</td>
<td>За веру в проект с самого начала</td>
</tr>
<tr>
<td>🥈</td>
<td><strong>@Legacyyy777</strong></td>
<td>₽2,600</td>
<td>За ценные предложения по улучшению</td>
<td><strong>@psych0O</strong></td>
<td>$60</td>
<td>За щедрую поддержку и вклад в развитие</td>
</tr>
<tr>
<td>🥉</td>
<td><strong>Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td>За активное тестирование и фидбек</td>
<td><strong>@Vldmrmtn</strong></td>
<td>₽5,000</td>
<td>За значительную поддержку проекта</td>
</tr>
<tr>
<td>4</td>
<td><strong>@k0tbtc</strong></td>
<td>₽3,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td><strong>@Legacyyy777</strong></td>
<td>₽2,900</td>
<td>За ценные предложения по улучшению</td>
</tr>
<tr>
<td>6</td>
<td><strong>@sklvg</strong></td>
<td>₽3,000</td>
<td>За международную поддержку</td>
</tr>
<tr>
<td>7</td>
<td><strong>@bolgov0zero</strong></td>
<td>₽2,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>8</td>
<td><strong>@i_burjuy</strong></td>
<td>₽1,300</td>
<td>За активное участие в проекте</td>
</tr>
<tr>
<td>9</td>
<td><strong>@kr33njee</strong></td>
<td>₽1,550</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>10</td>
<td><strong>@Nav1_0</strong></td>
<td>₽2,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>11</td>
<td><strong>@vitos_7771997</strong></td>
<td>₽1,500</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>12</td>
<td><strong>@haxonate</strong></td>
<td>₽1,500</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>13</td>
<td><strong>@fso404</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td>14</td>
<td><strong>@edward_forix</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
</tr>
<tr>
<td>5</td>
<td><strong>@Nav1_0</strong></td>
<td>₽1,000</td>
<td>За поддержку и доверие</td>
<td>15</td>
<td><strong>@Илья (@ispanec_nn)</strong></td>
<td>$30</td>
<td>За активное тестирование и фидбек</td>
</tr>
<tr>
<td>6</td>
<td><strong>@kr33njee</strong></td>
<td>₽800</td>
<td>За поддержку и доверие</td>
<td>16</td>
<td><strong>@DonMatteoRU</strong></td>
<td>₽500</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>7</td>
<td><strong>@SmartSystemCompany</strong></td>
<td>₽600</td>
<td>За поддержку и доверие</td>
<td>17</td>
<td><strong>@pedzeo</strong></td>
<td>₽500</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>8</td>
<td>18</td>
<td><strong>@suzumuj</strong></td>
<td>₽400</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>19</td>
<td><strong>@andrey_93rus</strong></td>
<td>₽300</td>
<td>За участие в развитии</td>
</tr>
<tr>
<td>20</td>
<td><strong>@PhiLin58</strong></td>
<td>₽300</td>
<td>За участие в развитии</td>
+87 -5
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
import os
import re
@@ -11,8 +13,9 @@ from pathlib import Path
class Settings(BaseSettings):
BOT_TOKEN: str
BOT_USERNAME: Optional[str] = None
ADMIN_IDS: str = ""
SUPPORT_USERNAME: str = "@support"
SUPPORT_MENU_ENABLED: bool = True
@@ -130,8 +133,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
@@ -276,6 +280,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:
@@ -459,6 +466,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:
@@ -512,7 +528,14 @@ class Settings(BaseSettings):
def get_trial_warning_hours(self) -> int:
return self.TRIAL_WARNING_HOURS
def get_bot_username(self) -> Optional[str]:
username = getattr(self, "BOT_USERNAME", None)
if not username:
return None
normalized = str(username).strip().lstrip("@")
return normalized or None
def is_notifications_enabled(self) -> bool:
return self.ENABLE_NOTIFICATIONS
@@ -556,6 +579,37 @@ class Settings(BaseSettings):
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"
@@ -791,14 +845,42 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_referred_user_reward_kopeks(self) -> int:
"""Return the referred user reward normalized to kopeks.
Historically the value was stored in kopeks, however some
installations provide it in rubles. To keep backward compatibility we
treat any value greater than or equal to one thousand as already being
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
in rubles and therefore converted to kopeks.
"""
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
try:
value = int(raw_value)
except (TypeError, ValueError):
return 0
if value <= 0:
return 0
if value >= 1000:
return value
return value * 100
def get_referral_settings(self) -> Dict:
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
return {
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": self.REFERRED_USER_REWARD
"referred_user_reward": referred_reward_kopeks,
"referred_user_reward_kopeks": referred_reward_kopeks,
}
def is_referral_notifications_enabled(self) -> bool:
+25
View File
@@ -110,6 +110,31 @@ async def list_discount_offers(
return result.scalars().all()
async def list_active_discount_offers_for_user(
db: AsyncSession,
user_id: int,
) -> List[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
stmt = (
select(DiscountOffer)
.options(
selectinload(DiscountOffer.user),
selectinload(DiscountOffer.subscription),
)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at > now,
)
.order_by(DiscountOffer.expires_at.asc())
)
result = await db.execute(stmt)
return result.scalars().all()
async def count_discount_offers(
db: AsyncSession,
*,
+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()
+19
View File
@@ -50,6 +50,25 @@ async def get_promo_groups_with_counts(
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)
+67 -2
View File
@@ -1,4 +1,5 @@
import logging
import random
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
@@ -38,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:
@@ -70,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,
)
@@ -199,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}
@@ -393,8 +396,70 @@ async def get_server_connected_users(
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': '🇩🇪 Германия',
+173 -13
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,7 +193,14 @@ 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:
@@ -133,7 +223,7 @@ async def extend_subscription(
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value:
if subscription.status == SubscriptionStatus.EXPIRED.value and days > 0:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
@@ -223,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,
@@ -986,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:
@@ -1006,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)
+51 -1
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"
@@ -963,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)
@@ -1252,4 +1264,42 @@ class WebApiToken(Base):
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
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}>"
)
+124
View File
@@ -1062,6 +1062,78 @@ async def create_promo_offer_templates_table():
return False
async def create_main_menu_buttons_table() -> bool:
table_exists = await check_table_exists('main_menu_buttons')
if table_exists:
logger.info("Таблица main_menu_buttons уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE main_menu_buttons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE IF NOT EXISTS main_menu_buttons (
id SERIAL PRIMARY KEY,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE IF NOT EXISTS main_menu_buttons (
id INT AUTO_INCREMENT PRIMARY KEY,
text VARCHAR(64) NOT NULL,
action_type VARCHAR(20) NOT NULL,
action_value TEXT NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'all',
is_active BOOLEAN NOT NULL DEFAULT 1,
display_order INT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE INDEX ix_main_menu_buttons_order ON main_menu_buttons(display_order, id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы main_menu_buttons: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица main_menu_buttons успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы main_menu_buttons: {e}")
return False
async def create_promo_offer_logs_table() -> bool:
table_exists = await check_table_exists('promo_offer_logs')
if table_exists:
@@ -2367,6 +2439,41 @@ async def ensure_server_promo_groups_setup() -> bool:
)
return False
async def add_server_trial_flag_column() -> bool:
column_exists = await check_column_exists('server_squads', 'is_trial_eligible')
if column_exists:
logger.info("Колонка is_trial_eligible уже существует в server_squads")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
column_def = 'BOOLEAN NOT NULL DEFAULT 0'
elif db_type == 'postgresql':
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
else:
column_def = 'BOOLEAN NOT NULL DEFAULT FALSE'
await conn.execute(
text(f"ALTER TABLE server_squads ADD COLUMN is_trial_eligible {column_def}")
)
if db_type == 'postgresql':
await conn.execute(
text("ALTER TABLE server_squads ALTER COLUMN is_trial_eligible SET DEFAULT FALSE")
)
logger.info("✅ Добавлена колонка is_trial_eligible в server_squads")
return True
except Exception as error:
logger.error(f"Ошибка добавления колонки is_trial_eligible: {error}")
return False
async def create_system_settings_table() -> bool:
table_exists = await check_table_exists("system_settings")
if table_exists:
@@ -2802,6 +2909,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===")
trial_column_ready = await add_server_trial_flag_column()
if trial_column_ready:
logger.info("✅ Колонка is_trial_eligible готова")
else:
logger.warning("⚠️ Проблемы с колонкой is_trial_eligible")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PRIVACY_POLICIES ===")
privacy_policies_ready = await create_privacy_policies_table()
if privacy_policies_ready:
@@ -2902,6 +3016,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей promo_offer_templates")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ MAIN_MENU_BUTTONS ===")
main_menu_buttons_created = await create_main_menu_buttons_table()
if main_menu_buttons_created:
logger.info("✅ Таблица main_menu_buttons готова")
else:
logger.warning("⚠️ Проблемы с таблицей main_menu_buttons")
template_columns_ready = await ensure_promo_offer_template_active_duration_column()
if template_columns_ready:
logger.info("✅ Колонка active_discount_hours промо-предложений готова")
@@ -3116,6 +3237,7 @@ async def check_migration_status():
"subscription_conversions_table": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
"privacy_policies_table": False,
"public_offers_table": False,
"users_promo_group_column": False,
@@ -3147,6 +3269,7 @@ async def check_migration_status():
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
status["discount_offers_table"] = await check_table_exists('discount_offers')
status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type')
@@ -3201,6 +3324,7 @@ async def check_migration_status():
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
"server_promo_groups_table": "Связи серверов и промогрупп",
"server_squads_trial_column": "Колонка триального назначения у серверов",
"users_promo_group_column": "Колонка promo_group_id у пользователей",
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
+101 -20
View File
@@ -18,7 +18,10 @@ from app.config import settings
from app.services.remnawave_service import RemnaWaveService
from app.services.payment_service import PaymentService
from app.services.tribute_service import TributeService
from app.services.system_settings_service import bot_configuration_service
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
from app.states import BotConfigStates
from app.utils.decorators import admin_required, error_handler
from app.utils.currency_converter import currency_converter
@@ -107,6 +110,12 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"icon": "",
"categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
"description": "Токен, по которому внешняя админка проверяет запросы.",
"icon": "🛡️",
"categories": ("EXTERNAL_ADMIN",),
},
}
CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
@@ -123,6 +132,7 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
"server",
"maintenance",
"advanced",
"external_admin",
)
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
@@ -269,7 +279,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
settings.REFERRAL_COMMISSION_PERCENT
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
or settings.REFERRAL_INVITER_BONUS_KOPEKS
or settings.REFERRED_USER_REWARD
or settings.get_referred_user_reward_kopeks()
)
return ("🟢", "Программа активна") if active else ("", "Бонусы не заданы")
@@ -687,6 +697,12 @@ async def apply_preset(
try:
await bot_configuration_service.set_value(db, setting_key, value)
applied.append(setting_key)
except ReadOnlySettingError:
logging.getLogger(__name__).info(
"Пропускаем настройку %s из пресета %s: только для чтения",
setting_key,
preset_key,
)
except Exception as error:
logging.getLogger(__name__).warning(
"Не удалось применить пресет %s для %s: %s",
@@ -854,8 +870,14 @@ async def handle_import_message(
errors.append(f"{setting_key}: {error}")
continue
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
applied.append(setting_key)
if bot_configuration_service.is_read_only(setting_key):
skipped.append(setting_key)
continue
try:
await bot_configuration_service.set_value(db, setting_key, value_to_apply)
applied.append(setting_key)
except ReadOnlySettingError:
skipped.append(setting_key)
await db.commit()
@@ -1361,9 +1383,10 @@ def _build_setting_keyboard(
definition = bot_configuration_service.get_definition(key)
rows: list[list[types.InlineKeyboardButton]] = []
callback_token = bot_configuration_service.get_callback_token(key)
is_read_only = bot_configuration_service.is_read_only(key)
choice_options = bot_configuration_service.get_choice_options(key)
if choice_options:
if choice_options and not is_read_only:
current_value = bot_configuration_service.get_current_value(key)
choice_buttons: list[types.InlineKeyboardButton] = []
for option in choice_options:
@@ -1385,7 +1408,7 @@ def _build_setting_keyboard(
for chunk in _chunk(choice_buttons, 2):
rows.append(list(chunk))
if definition.python_type is bool:
if definition.python_type is bool and not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="🔁 Переключить",
@@ -1395,16 +1418,17 @@ def _build_setting_keyboard(
)
])
rows.append([
types.InlineKeyboardButton(
text="✏️ Изменить",
callback_data=(
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
),
)
])
if not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="✏️ Изменить",
callback_data=(
f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}"
),
)
])
if bot_configuration_service.has_override(key):
if bot_configuration_service.has_override(key) and not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="♻️ Сбросить",
@@ -1414,6 +1438,14 @@ def _build_setting_keyboard(
)
])
if is_read_only:
rows.append([
types.InlineKeyboardButton(
text="🔒 Только для чтения",
callback_data="botcfg_group:noop",
)
])
rows.append([
types.InlineKeyboardButton(
text="⬅️ Назад",
@@ -1438,6 +1470,11 @@ def _render_setting_text(key: str) -> str:
f"📌 <b>Текущее:</b> {summary['current']}",
f"📦 <b>По умолчанию:</b> {summary['original']}",
f"✳️ <b>Переопределено:</b> {'Да' if summary['has_override'] else 'Нет'}",
*(
["🔒 <b>Режим:</b> Только для чтения (управляется автоматически)"]
if summary.get("is_read_only")
else []
),
"",
f"📘 <b>Описание:</b> {guidance['description']}",
f"📐 <b>Формат:</b> {guidance['format']}",
@@ -2066,6 +2103,9 @@ async def start_edit_setting(
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
definition = bot_configuration_service.get_definition(key)
summary = bot_configuration_service.get_setting_summary(key)
@@ -2131,13 +2171,23 @@ async def handle_edit_setting(
await state.clear()
return
if bot_configuration_service.is_read_only(key):
await message.answer("⚠️ Эта настройка доступна только для чтения.")
await state.clear()
return
try:
value = bot_configuration_service.parse_user_value(key, message.text or "")
except ValueError as error:
await message.answer(f"⚠️ {error}")
return
await bot_configuration_service.set_value(db, key, value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError:
await message.answer("⚠️ Эта настройка доступна только для чтения.")
await state.clear()
return
await db.commit()
text = _render_setting_text(key)
@@ -2172,13 +2222,23 @@ async def handle_direct_setting_input(
if not key:
return
if bot_configuration_service.is_read_only(key):
await message.answer("⚠️ Эта настройка доступна только для чтения.")
await state.clear()
return
try:
value = bot_configuration_service.parse_user_value(key, message.text or "")
except ValueError as error:
await message.answer(f"⚠️ {error}")
return
await bot_configuration_service.set_value(db, key, value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError:
await message.answer("⚠️ Эта настройка доступна только для чтения.")
await state.clear()
return
await db.commit()
text = _render_setting_text(key)
@@ -2220,7 +2280,14 @@ async def reset_setting(
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
await bot_configuration_service.reset_value(db, key)
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
try:
await bot_configuration_service.reset_value(db, key)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
@@ -2260,9 +2327,16 @@ async def toggle_setting(
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
current = bot_configuration_service.get_current_value(key)
new_value = not bool(current)
await bot_configuration_service.set_value(db, key, new_value)
try:
await bot_configuration_service.set_value(db, key, new_value)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
@@ -2304,6 +2378,9 @@ async def apply_setting_choice(
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
try:
value = bot_configuration_service.resolve_choice_token(key, choice_token)
@@ -2311,7 +2388,11 @@ async def apply_setting_choice(
await callback.answer("Это значение больше недоступно", show_alert=True)
return
await bot_configuration_service.set_value(db, key, value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
+249 -32
View File
@@ -21,6 +21,25 @@ logger = logging.getLogger(__name__)
PriceItem = Tuple[str, str, int]
TRAFFIC_PACKAGE_FIELDS: Tuple[Tuple[int, str], ...] = (
(5, "PRICE_TRAFFIC_5GB"),
(10, "PRICE_TRAFFIC_10GB"),
(25, "PRICE_TRAFFIC_25GB"),
(50, "PRICE_TRAFFIC_50GB"),
(100, "PRICE_TRAFFIC_100GB"),
(250, "PRICE_TRAFFIC_250GB"),
(500, "PRICE_TRAFFIC_500GB"),
(1000, "PRICE_TRAFFIC_1000GB"),
(0, "PRICE_TRAFFIC_UNLIMITED"),
)
TRAFFIC_PACKAGE_FIELD_MAP: Dict[int, str] = {gb: field for gb, field in TRAFFIC_PACKAGE_FIELDS}
TRAFFIC_PACKAGE_ORDER: Tuple[int, ...] = tuple(gb for gb, _ in TRAFFIC_PACKAGE_FIELDS)
TRAFFIC_PACKAGE_ORDER_INDEX: Dict[int, int] = {
gb: index for index, gb in enumerate(TRAFFIC_PACKAGE_ORDER)
}
@dataclass(slots=True)
class ChoiceOption:
value: Any
@@ -178,6 +197,71 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
}
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
if order_index is not None:
return (0, order_index)
return (1, package["gb"])
def _collect_traffic_packages() -> List[Dict[str, Any]]:
raw_packages = settings.get_traffic_packages()
packages_map: Dict[int, Dict[str, Any]] = {}
for package in raw_packages:
gb = int(package.get("gb", 0))
packages_map[gb] = {
"gb": gb,
"price": int(package.get("price") or 0),
"enabled": bool(package.get("enabled", True)),
"field": TRAFFIC_PACKAGE_FIELD_MAP.get(gb),
}
for gb, field in TRAFFIC_PACKAGE_FIELDS:
if not hasattr(settings, field):
continue
price = getattr(settings, field)
existing = packages_map.get(gb)
enabled = existing["enabled"] if existing is not None else True
packages_map[gb] = {
"gb": gb,
"price": int(price),
"enabled": enabled,
"field": field,
}
packages = list(packages_map.values())
packages.sort(key=_traffic_package_sort_key)
return packages
def _serialize_traffic_packages(packages: Iterable[Dict[str, Any]]) -> str:
parts = []
for package in packages:
enabled_flag = "true" if package.get("enabled") else "false"
parts.append(f"{int(package['gb'])}:{int(package['price'])}:{enabled_flag}")
return ",".join(parts)
async def _save_traffic_packages(
db: AsyncSession,
packages: Iterable[Dict[str, Any]],
*,
skip_if_same: bool = False,
) -> bool:
new_value = _serialize_traffic_packages(packages)
current_value = bot_configuration_service.get_current_value("TRAFFIC_PACKAGES_CONFIG") or ""
if skip_if_same and current_value == new_value:
return False
await bot_configuration_service.set_value(db, "TRAFFIC_PACKAGES_CONFIG", new_value)
await db.commit()
return True
def _language_code(language: str | None) -> str:
return (language or "ru").split("-")[0].lower()
@@ -236,23 +320,17 @@ def _get_period_items(lang_code: str) -> List[PriceItem]:
def _get_traffic_items(lang_code: str) -> List[PriceItem]:
traffic_keys: Tuple[Tuple[int, str], ...] = (
(5, "PRICE_TRAFFIC_5GB"),
(10, "PRICE_TRAFFIC_10GB"),
(25, "PRICE_TRAFFIC_25GB"),
(50, "PRICE_TRAFFIC_50GB"),
(100, "PRICE_TRAFFIC_100GB"),
(250, "PRICE_TRAFFIC_250GB"),
(500, "PRICE_TRAFFIC_500GB"),
(1000, "PRICE_TRAFFIC_1000GB"),
(0, "PRICE_TRAFFIC_UNLIMITED"),
)
packages = _collect_traffic_packages()
items: List[PriceItem] = []
for gb, key in traffic_keys:
if hasattr(settings, key):
price = getattr(settings, key)
items.append((key, _format_traffic_label(gb, lang_code), price))
for package in packages:
field = package.get("field")
if not field:
continue
label = _format_traffic_label(package["gb"], lang_code)
icon = "" if package["enabled"] else "⚪️"
items.append((field, f"{icon} {label}", int(package["price"])))
return items
@@ -285,17 +363,17 @@ def _build_period_summary(items: Iterable[PriceItem], lang_code: str, fallback:
return ", ".join(parts) if parts else fallback
def _build_traffic_summary(items: Iterable[PriceItem], lang_code: str, fallback: str) -> str:
parts: List[str] = []
for key, label, price in items:
if key.endswith("UNLIMITED"):
short_label = ""
else:
digits = ''.join(ch for ch in key if ch.isdigit())
unit = "ГБ" if lang_code == "ru" else "GB"
short_label = f"{digits}{unit}" if digits else label
def _build_traffic_summary(lang_code: str, fallback: str) -> str:
packages = _collect_traffic_packages()
enabled_packages = [package for package in packages if package["enabled"]]
parts.append(f"{short_label}: {settings.format_price(price)}")
if not enabled_packages:
return fallback
parts: List[str] = []
for package in enabled_packages:
short_label = _format_traffic_label(package["gb"], lang_code, short=True)
parts.append(f"{short_label}: {settings.format_price(int(package['price']))}")
return ", ".join(parts) if parts else fallback
@@ -407,6 +485,68 @@ def _build_settings_section(
return "\n".join(lines).strip(), keyboard
def _build_traffic_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
texts = get_texts(language)
lang_code = _language_code(language)
packages = _collect_traffic_packages()
title = texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_TITLE",
"🚦 Отображение пакетов трафика",
)
lines: List[str] = [title, ""]
enabled_labels = [
_format_traffic_label(package["gb"], lang_code, short=True)
for package in packages
if package["enabled"]
]
if enabled_labels:
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_ACTIVE",
"Активные пакеты: {items}",
).format(items=", ".join(enabled_labels))
)
else:
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_NONE",
"Активных пакетов нет.",
)
)
lines.append("")
lines.append(
texts.t(
"ADMIN_PRICING_SECTION_TRAFFIC_OPTIONS_PROMPT",
"Нажмите на пакет, чтобы включить или выключить его отображение.",
)
)
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
buttons: List[types.InlineKeyboardButton] = []
for package in packages:
icon = "" if package["enabled"] else "⚪️"
label = _format_traffic_label(package["gb"], lang_code, short=True)
buttons.append(
types.InlineKeyboardButton(
text=f"{icon} {label}",
callback_data=f"admin_pricing_toggle_traffic:{package['gb']}",
)
)
for i in range(0, len(buttons), 3):
keyboard_rows.append(buttons[i : i + 3])
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_pricing")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
return "\n".join(lines), keyboard
def _build_period_options_section(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
texts = get_texts(language)
lang_code = _language_code(language)
@@ -485,7 +625,7 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
fallback = texts.t("ADMIN_PRICING_SUMMARY_EMPTY", "")
summary_periods = _build_period_summary(period_items, lang_code, fallback)
summary_traffic = _build_traffic_summary(traffic_items, lang_code, fallback)
summary_traffic = _build_traffic_summary(lang_code, fallback)
summary_extra = _build_extra_summary(extra_items, fallback)
summary_trial = _format_trial_summary(lang_code)
summary_core = _format_core_summary(lang_code)
@@ -536,6 +676,13 @@ def _build_overview(language: str) -> Tuple[str, types.InlineKeyboardMarkup]:
text=texts.t("ADMIN_PRICING_BUTTON_TRAFFIC", "📦 Пакеты трафика"),
callback_data="admin_pricing_section:traffic",
),
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS",
"🚦 Отображение пакетов",
),
callback_data="admin_pricing_section:traffic_options",
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_PRICING_BUTTON_EXTRA", " Дополнительно"),
callback_data="admin_pricing_section:extra",
@@ -564,6 +711,8 @@ def _build_section(
elif section == "extra":
items = _get_extra_items(lang_code)
title = texts.t("ADMIN_PRICING_SECTION_EXTRA_TITLE", "➕ Дополнительные опции")
elif section == "traffic_options":
return _build_traffic_options_section(language)
elif section in SETTING_ENTRIES_BY_SECTION:
return _build_settings_section(section, language)
elif section == "period_options":
@@ -922,14 +1071,27 @@ async def process_pricing_input(
await bot_configuration_service.set_value(db, key, new_value)
await db.commit()
if key.startswith("PRICE_TRAFFIC_"):
packages = _collect_traffic_packages()
await _save_traffic_packages(db, packages, skip_if_same=True)
section_text, section_keyboard = _build_section(section, db_user.language)
if mode == "price":
label = _resolve_label(section, key, db_user.language)
await message.answer(
texts.t("ADMIN_PRICING_EDIT_SUCCESS", "Цена для {item} обновлена: {price}").format(
item=label,
price=settings.format_price(int(new_value)),
if message_id:
await _render_message_by_id(
message.bot,
message.chat.id,
message_id,
section_text,
section_keyboard,
)
)
try:
await message.delete()
except TelegramBadRequest as error:
logger.debug("Failed to delete pricing input message: %s", error)
await state.clear()
return
else:
entry = SETTING_ENTRY_BY_KEY.get(key)
lang_code = _language_code(db_user.language)
@@ -1043,6 +1205,57 @@ async def select_setting_choice(
await _render_message(callback.message, text, keyboard)
@admin_required
@error_handler
async def toggle_traffic_package(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
) -> None:
try:
_, gb_raw = callback.data.split(":", 1)
gb_value = int(gb_raw)
except (ValueError, TypeError):
await callback.answer()
return
texts = get_texts(db_user.language)
packages = _collect_traffic_packages()
target_index = next((index for index, pkg in enumerate(packages) if pkg["gb"] == gb_value), None)
if target_index is None:
await callback.answer()
return
enabled_count = sum(1 for pkg in packages if pkg["enabled"])
target_package = packages[target_index]
if target_package["enabled"] and enabled_count <= 1:
await callback.answer(
texts.t(
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN",
"Должен оставаться хотя бы один пакет.",
),
show_alert=True,
)
return
target_package["enabled"] = not target_package["enabled"]
await _save_traffic_packages(db, packages)
status_text = (
texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED", "Пакет включен.")
if target_package["enabled"]
else texts.t("ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED", "Пакет отключен.")
)
await callback.answer(status_text)
text, keyboard = _build_traffic_options_section(db_user.language)
await _render_message(callback.message, text, keyboard)
@admin_required
@error_handler
async def toggle_period_option(
@@ -1127,6 +1340,10 @@ def register_handlers(dp: Dispatcher) -> None:
select_setting_choice,
F.data.startswith("admin_pricing_choice:"),
)
dp.callback_query.register(
toggle_traffic_package,
F.data.startswith("admin_pricing_toggle_traffic:"),
)
dp.callback_query.register(
toggle_period_option,
F.data.startswith("admin_pricing_toggle_period:"),
+3 -3
View File
@@ -68,7 +68,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -105,7 +105,7 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -190,7 +190,7 @@ async def show_referral_settings(
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
+42
View File
@@ -35,6 +35,8 @@ def _build_server_edit_view(server):
else "Не выбраны"
)
trial_status = "✅ Да" if server.is_trial_eligible else "⚪️ Нет"
text = f"""
🌐 <b>Редактирование сервера</b>
@@ -51,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 'Не указано'}
@@ -80,6 +83,12 @@ def _build_server_edit_view(server):
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}"
@@ -527,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(
@@ -1255,6 +1296,7 @@ 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_"))
+54 -13
View File
@@ -2080,11 +2080,16 @@ async def extend_user_subscription(
await callback.message.edit_text(
"⏰ <b>Продление подписки</b>\n\n"
"Введите количество дней для продления:\n"
"Например: 30, 7, 90\n"
"Максимум: 365 дней\n\n"
"Введите количество дней для изменения:\n"
"Положительные значения продлят подписку\n"
"Отрицательные сократят срок подписки\n"
"• Диапазон: от -365 до 365 дней (0 недопустимо)\n\n"
"Или нажмите /cancel для отмены",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="-7 дней", callback_data=f"admin_sub_extend_days_{user_id}_-7"),
types.InlineKeyboardButton(text="-30 дней", callback_data=f"admin_sub_extend_days_{user_id}_-30")
],
[
types.InlineKeyboardButton(text="7 дней", callback_data=f"admin_sub_extend_days_{user_id}_7"),
types.InlineKeyboardButton(text="30 дней", callback_data=f"admin_sub_extend_days_{user_id}_30")
@@ -2114,11 +2119,19 @@ async def process_subscription_extension_days(
user_id = int(parts[-2])
days = int(parts[-1])
if days == 0 or days < -365 or days > 365:
await callback.answer("❌ Количество дней должно быть от -365 до 365, исключая 0", show_alert=True)
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
if days > 0:
action_text = f"продлена на {days} дней"
else:
action_text = f"уменьшена на {abs(days)} дней"
await callback.message.edit_text(
f"✅ Подписка пользователя продлена на {days} дней",
f"✅ Подписка пользователя {action_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
@@ -2153,15 +2166,19 @@ async def process_subscription_extension_text(
try:
days = int(message.text.strip())
if days <= 0 or days > 365:
await message.answer("❌ Количество дней должно быть от 1 до 365")
if days == 0 or days < -365 or days > 365:
await message.answer("❌ Количество дней должно быть от -365 до 365, исключая 0")
return
success = await _extend_subscription_by_days(db, user_id, days, db_user.id)
if success:
if days > 0:
action_text = f"продлена на {days} дней"
else:
action_text = f"уменьшена на {abs(days)} дней"
await message.answer(
f"✅ Подписка пользователя продлена на {days} дней",
f"✅ Подписка пользователя {action_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
])
@@ -3115,9 +3132,12 @@ async def _extend_subscription_by_days(db: AsyncSession, user_id: int, days: int
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
if days > 0:
logger.info(f"Админ {admin_id} продлил подписку пользователя {user_id} на {days} дней")
else:
logger.info(f"Админ {admin_id} сократил подписку пользователя {user_id} на {abs(days)} дней")
return True
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
return False
@@ -3240,13 +3260,34 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
logger.error(f"У пользователя {user_id} уже есть подписка")
return False
trial_squads: list[str] = []
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:
trial_squads = [trial_uuid]
except Exception as error:
logger.error(
"Не удалось подобрать сквад при выдаче подписки админом %s: %s",
admin_id,
error,
)
if getattr(settings, "TRIAL_SQUAD_UUID", None):
trial_squads = [settings.TRIAL_SQUAD_UUID]
subscription = await create_paid_subscription(
db=db,
user_id=user_id,
duration_days=days,
traffic_limit_gb=settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=settings.DEFAULT_DEVICE_LIMIT,
connected_squads=[settings.TRIAL_SQUAD_UUID] if settings.TRIAL_SQUAD_UUID else []
connected_squads=trial_squads,
update_server_counters=True,
)
subscription_service = SubscriptionService()
+271 -1
View File
@@ -1,5 +1,7 @@
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
@@ -8,13 +10,18 @@ from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
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.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,
@@ -22,6 +29,7 @@ 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,
@@ -29,10 +37,109 @@ from app.utils.promo_offer import (
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,
@@ -61,6 +168,13 @@ async def show_main_menu(
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,
@@ -74,6 +188,7 @@ async def show_main_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)
@@ -117,6 +232,7 @@ async def show_info_menu(
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,
@@ -126,12 +242,153 @@ async def show_info_menu(
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,
@@ -623,6 +880,13 @@ async def handle_back_to_menu(
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,
@@ -636,6 +900,7 @@ async def handle_back_to_menu(
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
custom_buttons=custom_buttons,
),
parse_mode="HTML",
)
@@ -782,6 +1047,11 @@ def register_handlers(dp: Dispatcher):
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",
+51
View File
@@ -31,12 +31,14 @@ 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__)
@@ -333,6 +335,13 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
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(
@@ -344,6 +353,7 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -362,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
@@ -746,6 +757,13 @@ async def complete_registration_from_callback(
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,
@@ -758,6 +776,7 @@ async def complete_registration_from_callback(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -918,6 +937,13 @@ async def complete_registration_from_callback(
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,
@@ -930,6 +956,7 @@ async def complete_registration_from_callback(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -984,6 +1011,13 @@ async def complete_registration(
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,
@@ -996,6 +1030,7 @@ async def complete_registration(
balance_kopeks=existing_user.balance_kopeks,
subscription=existing_user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1156,6 +1191,13 @@ async def complete_registration(
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,
@@ -1168,6 +1210,7 @@ async def complete_registration(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
),
parse_mode="HTML"
)
@@ -1431,6 +1474,13 @@ async def required_sub_channel_check(
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=is_admin,
@@ -1440,6 +1490,7 @@ async def required_sub_channel_check(
balance_kopeks=user.balance_kopeks,
subscription=user.subscription,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if settings.ENABLE_LOGO_MODE:
+64 -10
View File
@@ -907,18 +907,34 @@ async def show_trial_offer(
await callback.answer()
return
trial_server_name = "🎯 Тестовый сервер"
trial_server_name = texts.t("TRIAL_SERVER_DEFAULT_NAME", "🎯 Тестовый сервер")
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
from app.database.crud.server_squad import (
get_server_squad_by_uuid,
get_trial_eligible_server_squads,
)
if settings.TRIAL_SQUAD_UUID:
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
if trial_squads:
if len(trial_squads) == 1:
trial_server_name = trial_squads[0].display_name
else:
trial_server_name = texts.t(
"TRIAL_SERVER_RANDOM_POOL",
"🎲 Случайный из {count} серверов",
).format(count=len(trial_squads))
elif settings.TRIAL_SQUAD_UUID:
trial_server = await get_server_squad_by_uuid(db, settings.TRIAL_SQUAD_UUID)
if trial_server:
trial_server_name = trial_server.display_name
else:
logger.warning(f"Триальный сервер с UUID {settings.TRIAL_SQUAD_UUID} не найден в БД")
logger.warning(
"Триальный сервер с UUID %s не найден в БД",
settings.TRIAL_SQUAD_UUID,
)
else:
logger.warning("TRIAL_SQUAD_UUID не настроен в конфигурации")
logger.warning("Не настроены сквады для выдачи триалов")
except Exception as e:
logger.error(f"Ошибка получения триального сервера: {e}")
@@ -2501,9 +2517,20 @@ async def handle_extend_subscription(
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
total_original_price = (
base_price_original
+ servers_price_per_month * months_in_period
+ devices_price_per_month * months_in_period
+ traffic_price_per_month * months_in_period
)
price = base_price + total_servers_price + total_devices_price + total_traffic_price
promo_component = _apply_promo_offer_discount(db_user, price)
renewal_prices[days] = promo_component["discounted"]
renewal_prices[days] = {
"final": promo_component["discounted"],
"original": total_original_price,
}
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
@@ -2516,9 +2543,35 @@ async def handle_extend_subscription(
prices_text = ""
for days in available_periods:
if days in renewal_prices:
period_display = format_period_description(days, db_user.language)
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
if days not in renewal_prices:
continue
price_info = renewal_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)
original_price = price_info.get("original", final_price)
else:
final_price = price_info
original_price = final_price
has_discount = original_price > final_price
period_display = format_period_description(days, db_user.language)
if has_discount:
prices_text += (
"📅 "
f"{period_display} - <s>{texts.format_price(original_price)}</s> "
f"{texts.format_price(final_price)}\n"
)
else:
prices_text += (
"📅 "
f"{period_display} - {texts.format_price(final_price)}\n"
)
promo_discounts_text = _build_promo_group_discount_text(
db_user,
@@ -4290,7 +4343,8 @@ async def create_paid_subscription_with_traffic_mode(
duration_days=duration_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads
connected_squads=connected_squads,
update_server_counters=False,
)
logger.info(f"📋 Создана подписка с трафиком: {traffic_limit_gb} ГБ (режим: {settings.TRAFFIC_SELECTION_MODE})")
+34 -8
View File
@@ -121,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)
@@ -230,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"),
@@ -285,6 +291,7 @@ def get_info_menu_keyboard(
show_privacy_policy: bool = False,
show_public_offer: bool = False,
show_faq: bool = False,
show_promo_groups: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -298,6 +305,14 @@ def get_info_menu_keyboard(
)
])
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(
@@ -1695,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")
+21 -1
View File
@@ -305,6 +305,16 @@
"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",
@@ -378,6 +388,8 @@
"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",
@@ -757,5 +769,13 @@
"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_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."
}
+19 -1
View File
@@ -234,6 +234,16 @@
"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>",
@@ -793,5 +803,13 @@
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
"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": "Нажмите на пакет, чтобы включить или выключить его отображение."
}
+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
+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
+23 -7
View File
@@ -113,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)
@@ -140,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)
+59 -30
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:
@@ -1116,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
@@ -1150,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()
@@ -1334,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
+53 -2
View File
@@ -56,9 +56,16 @@ 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] = {
"CORE": "🤖 Основные настройки",
"SUPPORT": "💬 Поддержка и тикеты",
@@ -71,6 +78,7 @@ class BotConfigurationService:
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 PAL24 / PayPalych",
"EXTERNAL_ADMIN": "🛡️ Внешняя админка",
"SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты",
"PERIODS": "📆 Периоды подписок",
"SUBSCRIPTION_PRICES": "💵 Стоимость тарифов",
@@ -118,6 +126,7 @@ class BotConfigurationService:
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
"TRIBUTE": "Tribute и донат-сервисы.",
"TELEGRAM": "Telegram Stars и их стоимость.",
"EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.",
"SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.",
"PERIODS": "Доступные периоды подписок и продлений.",
"SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.",
@@ -184,6 +193,7 @@ class BotConfigurationService:
"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": "TRIAL",
@@ -255,6 +265,7 @@ class BotConfigurationService:
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"EXTERNAL_ADMIN_": "EXTERNAL_ADMIN",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
@@ -394,6 +405,20 @@ class BotConfigurationService:
"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
@@ -405,6 +430,10 @@ class BotConfigurationService:
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):
@@ -455,6 +484,8 @@ class BotConfigurationService:
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)
@@ -860,7 +891,17 @@ 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
@@ -870,7 +911,16 @@ class BotConfigurationService:
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)
@@ -925,6 +975,7 @@ class BotConfigurationService:
"category_key": definition.category_key,
"category_label": definition.category_label,
"has_override": has_override,
"is_read_only": cls.is_read_only(key),
}
+11 -2
View File
@@ -13,7 +13,10 @@ from app.database.crud.user import (
)
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,
@@ -534,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(
+22 -4
View File
@@ -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)
+10
View File
@@ -12,6 +12,7 @@ from .routes import (
campaigns,
config,
health,
main_menu_buttons,
promocodes,
miniapp,
promo_groups,
@@ -40,6 +41,10 @@ OPENAPI_TAGS = [
"name": "settings",
"description": "Получение и изменение конфигурации бота из административной панели.",
},
{
"name": "main-menu",
"description": "Управление кнопками главного меню Telegram-бота.",
},
{
"name": "users",
"description": "Управление пользователями, балансом и статусами подписок.",
@@ -120,6 +125,11 @@ def create_web_api_app() -> FastAPI:
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"])
+2
View File
@@ -1,6 +1,7 @@
from . import (
config,
health,
main_menu_buttons,
miniapp,
promo_offers,
pages,
@@ -17,6 +18,7 @@ from . import (
__all__ = [
"config",
"health",
"main_menu_buttons",
"miniapp",
"promo_offers",
"pages",
+13 -3
View File
@@ -5,7 +5,10 @@ from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.system_settings_service import bot_configuration_service
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.config import (
@@ -94,6 +97,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
current=current,
original=original,
has_override=has_override,
read_only=bot_configuration_service.is_read_only(definition.key),
choices=choices,
)
@@ -153,7 +157,10 @@ async def update_setting(
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
value = _coerce_value(key, payload.value)
await bot_configuration_service.set_value(db, key, value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
return _serialize_definition(definition)
@@ -170,6 +177,9 @@ async def reset_setting(
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
await bot_configuration_service.reset_value(db, key)
try:
await bot_configuration_service.reset_value(db, key)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
return _serialize_definition(definition)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.main_menu_button import (
count_main_menu_buttons,
create_main_menu_button,
delete_main_menu_button,
get_main_menu_button_by_id,
get_main_menu_buttons,
update_main_menu_button,
)
from app.database.models import MainMenuButton
from app.services.main_menu_button_service import MainMenuButtonService
from ..dependencies import get_db_session, require_api_token
from ..schemas.main_menu_buttons import (
MainMenuButtonCreateRequest,
MainMenuButtonListResponse,
MainMenuButtonResponse,
MainMenuButtonUpdateRequest,
)
router = APIRouter()
def _serialize(button: MainMenuButton) -> MainMenuButtonResponse:
return MainMenuButtonResponse(
id=button.id,
text=button.text,
action_type=button.action_type_enum,
action_value=button.action_value,
visibility=button.visibility_enum,
is_active=button.is_active,
display_order=button.display_order,
created_at=button.created_at,
updated_at=button.updated_at,
)
@router.get("", response_model=MainMenuButtonListResponse)
async def list_main_menu_buttons(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> MainMenuButtonListResponse:
total = await count_main_menu_buttons(db)
buttons = await get_main_menu_buttons(db, limit=limit, offset=offset)
return MainMenuButtonListResponse(
items=[_serialize(button) for button in buttons],
total=total,
limit=limit,
offset=offset,
)
@router.post("", response_model=MainMenuButtonResponse, status_code=status.HTTP_201_CREATED)
async def create_main_menu_button_endpoint(
payload: MainMenuButtonCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MainMenuButtonResponse:
button = await create_main_menu_button(
db,
text=payload.text,
action_type=payload.action_type,
action_value=payload.action_value,
visibility=payload.visibility,
is_active=payload.is_active,
display_order=payload.display_order,
)
MainMenuButtonService.invalidate_cache()
return _serialize(button)
@router.patch("/{button_id}", response_model=MainMenuButtonResponse)
async def update_main_menu_button_endpoint(
button_id: int,
payload: MainMenuButtonUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> MainMenuButtonResponse:
button = await get_main_menu_button_by_id(db, button_id)
if not button:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
update_payload = payload.dict(exclude_unset=True)
button = await update_main_menu_button(db, button, **update_payload)
MainMenuButtonService.invalidate_cache()
return _serialize(button)
@router.delete("/{button_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_main_menu_button_endpoint(
button_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
button = await get_main_menu_button_by_id(db, button_id)
if not button:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Main menu button not found")
await delete_main_menu_button(db, button)
MainMenuButtonService.invalidate_cache()
return Response(status_code=status.HTTP_204_NO_CONTENT)
File diff suppressed because it is too large Load Diff
+1
View File
@@ -131,6 +131,7 @@ async def create_subscription(
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
connected_squads=payload.connected_squads or [],
update_server_counters=True,
)
subscription = await _get_subscription(db, subscription.id)
+1
View File
@@ -45,6 +45,7 @@ class SettingDefinition(BaseModel):
current: Any | None = Field(default=None)
original: Any | None = Field(default=None)
has_override: bool
read_only: bool = Field(default=False)
choices: list[SettingChoice] = Field(default_factory=list)
model_config = ConfigDict(extra="forbid")
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, validator
from app.database.models import MainMenuButtonActionType, MainMenuButtonVisibility
def _clean_text(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Text cannot be empty")
return cleaned
def _validate_action_value(value: str) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise ValueError("Action value cannot be empty")
if not cleaned.lower().startswith(("http://", "https://")):
raise ValueError("Action value must start with http:// or https://")
return cleaned
class MainMenuButtonResponse(BaseModel):
id: int
text: str
action_type: MainMenuButtonActionType
action_value: str
visibility: MainMenuButtonVisibility
is_active: bool
display_order: int
created_at: datetime
updated_at: datetime
class MainMenuButtonCreateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=64)
action_type: MainMenuButtonActionType
action_value: str = Field(..., min_length=1, max_length=1024)
visibility: MainMenuButtonVisibility = MainMenuButtonVisibility.ALL
is_active: bool = True
display_order: Optional[int] = Field(None, ge=0)
_normalize_text = validator("text", allow_reuse=True)(_clean_text)
_normalize_action_value = validator("action_value", allow_reuse=True)(_validate_action_value)
class MainMenuButtonUpdateRequest(BaseModel):
text: Optional[str] = Field(None, min_length=1, max_length=64)
action_type: Optional[MainMenuButtonActionType] = None
action_value: Optional[str] = Field(None, min_length=1, max_length=1024)
visibility: Optional[MainMenuButtonVisibility] = None
is_active: Optional[bool] = None
display_order: Optional[int] = Field(None, ge=0)
@validator("text")
def validate_text(cls, value): # noqa: D401,B902
if value is None:
return value
return _clean_text(value)
@validator("action_value")
def validate_action_value(cls, value): # noqa: D401,B902
if value is None:
return value
return _validate_action_value(value)
class MainMenuButtonListResponse(BaseModel):
items: list[MainMenuButtonResponse]
total: int
limit: int
offset: int
+194
View File
@@ -34,11 +34,34 @@ class MiniAppSubscriptionUser(BaseModel):
traffic_limit_label: str
lifetime_used_traffic_gb: float = 0.0
has_active_subscription: bool = False
promo_offer_discount_percent: int = 0
promo_offer_discount_expires_at: Optional[datetime] = None
promo_offer_discount_source: Optional[str] = None
class MiniAppPromoGroup(BaseModel):
id: int
name: str
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
class MiniAppAutoPromoGroupLevel(BaseModel):
id: int
name: str
threshold_kopeks: int
threshold_rubles: float
threshold_label: str
is_reached: bool = False
is_current: bool = False
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: Dict[int, int] = Field(default_factory=dict)
apply_discounts_to_addons: bool = True
class MiniAppConnectedServer(BaseModel):
@@ -47,6 +70,7 @@ class MiniAppConnectedServer(BaseModel):
class MiniAppDevice(BaseModel):
hwid: Optional[str] = None
platform: Optional[str] = None
device_model: Optional[str] = None
app_version: Optional[str] = None
@@ -54,6 +78,16 @@ class MiniAppDevice(BaseModel):
last_ip: Optional[str] = None
class MiniAppDeviceRemovalRequest(BaseModel):
init_data: str = Field(..., alias="initData")
hwid: str
class MiniAppDeviceRemovalResponse(BaseModel):
success: bool = True
message: Optional[str] = None
class MiniAppTransaction(BaseModel):
id: int
type: str
@@ -67,6 +101,158 @@ class MiniAppTransaction(BaseModel):
completed_at: Optional[datetime] = None
class MiniAppPromoOffer(BaseModel):
id: int
status: str
notification_type: Optional[str] = None
offer_type: Optional[str] = None
effect_type: Optional[str] = None
discount_percent: int = 0
bonus_amount_kopeks: int = 0
bonus_amount_label: Optional[str] = None
expires_at: Optional[datetime] = None
claimed_at: Optional[datetime] = None
is_active: bool = False
template_id: Optional[int] = None
template_name: Optional[str] = None
button_text: Optional[str] = None
title: Optional[str] = None
message_text: Optional[str] = None
icon: Optional[str] = None
test_squads: List[MiniAppConnectedServer] = Field(default_factory=list)
active_discount_expires_at: Optional[datetime] = None
active_discount_started_at: Optional[datetime] = None
active_discount_duration_seconds: Optional[int] = None
class MiniAppPromoOfferClaimRequest(BaseModel):
init_data: str = Field(..., alias="initData")
class MiniAppPromoOfferClaimResponse(BaseModel):
success: bool = True
code: Optional[str] = None
class MiniAppPromoCode(BaseModel):
code: str
type: Optional[str] = None
balance_bonus_kopeks: int = 0
subscription_days: int = 0
max_uses: Optional[int] = None
current_uses: Optional[int] = None
valid_until: Optional[datetime] = None
class MiniAppPromoCodeActivationRequest(BaseModel):
init_data: str = Field(..., alias="initData")
code: str
class MiniAppPromoCodeActivationResponse(BaseModel):
success: bool = True
description: Optional[str] = None
promocode: Optional[MiniAppPromoCode] = None
class MiniAppFaqItem(BaseModel):
id: int
title: Optional[str] = None
content: Optional[str] = None
display_order: Optional[int] = None
class MiniAppFaq(BaseModel):
requested_language: str
language: str
is_enabled: bool = True
total: int = 0
items: List[MiniAppFaqItem] = Field(default_factory=list)
class MiniAppRichTextDocument(BaseModel):
requested_language: str
language: str
title: Optional[str] = None
is_enabled: bool = True
content: str = ""
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class MiniAppLegalDocuments(BaseModel):
public_offer: Optional[MiniAppRichTextDocument] = None
service_rules: Optional[MiniAppRichTextDocument] = None
privacy_policy: Optional[MiniAppRichTextDocument] = None
class MiniAppReferralTerms(BaseModel):
minimum_topup_kopeks: int = 0
minimum_topup_label: Optional[str] = None
first_topup_bonus_kopeks: int = 0
first_topup_bonus_label: Optional[str] = None
inviter_bonus_kopeks: int = 0
inviter_bonus_label: Optional[str] = None
commission_percent: float = 0.0
referred_user_reward_kopeks: int = 0
referred_user_reward_label: Optional[str] = None
class MiniAppReferralStats(BaseModel):
invited_count: int = 0
paid_referrals_count: int = 0
active_referrals_count: int = 0
total_earned_kopeks: int = 0
total_earned_label: Optional[str] = None
month_earned_kopeks: int = 0
month_earned_label: Optional[str] = None
conversion_rate: float = 0.0
class MiniAppReferralRecentEarning(BaseModel):
amount_kopeks: int = 0
amount_label: Optional[str] = None
reason: Optional[str] = None
referral_name: Optional[str] = None
created_at: Optional[datetime] = None
class MiniAppReferralItem(BaseModel):
id: int
telegram_id: Optional[int] = None
full_name: Optional[str] = None
username: Optional[str] = None
created_at: Optional[datetime] = None
last_activity: Optional[datetime] = None
has_made_first_topup: bool = False
balance_kopeks: int = 0
balance_label: Optional[str] = None
total_earned_kopeks: int = 0
total_earned_label: Optional[str] = None
topups_count: int = 0
days_since_registration: Optional[int] = None
days_since_activity: Optional[int] = None
status: Optional[str] = None
class MiniAppReferralList(BaseModel):
total_count: int = 0
has_next: bool = False
has_prev: bool = False
current_page: int = 1
total_pages: int = 1
items: List[MiniAppReferralItem] = Field(default_factory=list)
class MiniAppReferralInfo(BaseModel):
referral_code: Optional[str] = None
referral_link: Optional[str] = None
terms: Optional[MiniAppReferralTerms] = None
stats: Optional[MiniAppReferralStats] = None
recent_earnings: List[MiniAppReferralRecentEarning] = Field(default_factory=list)
referrals: Optional[MiniAppReferralList] = None
class MiniAppSubscriptionResponse(BaseModel):
success: bool = True
subscription_id: int
@@ -89,8 +275,16 @@ class MiniAppSubscriptionResponse(BaseModel):
balance_rubles: float = 0.0
balance_currency: Optional[str] = None
transactions: List[MiniAppTransaction] = Field(default_factory=list)
promo_offers: List[MiniAppPromoOffer] = Field(default_factory=list)
promo_group: Optional[MiniAppPromoGroup] = None
auto_assign_promo_groups: List[MiniAppAutoPromoGroupLevel] = Field(default_factory=list)
total_spent_kopeks: int = 0
total_spent_rubles: float = 0.0
total_spent_label: Optional[str] = None
subscription_type: str
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None
faq: Optional[MiniAppFaq] = None
legal_documents: Optional[MiniAppLegalDocuments] = None
referral: Optional[MiniAppReferralInfo] = None
+18
View File
@@ -333,6 +333,16 @@
"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_FAQ": "❓ FAQ",
"FAQ_HEADER": "❓ <b>FAQ</b>",
"FAQ_PAGES_PROMPT": "Select a topic:",
@@ -1022,6 +1032,7 @@
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Available periods",
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Subscription periods",
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Traffic packages",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Package visibility",
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Trial period",
"ADMIN_PRICING_CHOICE_ALREADY": "This option is already active.",
"ADMIN_PRICING_CHOICE_UPDATED": "Selected: {label}",
@@ -1038,6 +1049,9 @@
"ADMIN_PRICING_PERIOD_DISABLED": "Period disabled.",
"ADMIN_PRICING_PERIOD_ENABLED": "Period enabled.",
"ADMIN_PRICING_PERIOD_MIN": "At least one period must remain.",
"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_CORE_TITLE": "⚙️ Core limits",
"ADMIN_PRICING_SECTION_CURRENT": "Current values:",
"ADMIN_PRICING_SECTION_EMPTY": "No values available.",
@@ -1050,6 +1064,10 @@
"ADMIN_PRICING_SECTION_PROMPT": "Select what to update:",
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Settings",
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Traffic packages",
"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.",
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Trial period",
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Reply \"Cancel\" to go back without changes.",
"ADMIN_PRICING_SETTING_CURRENT": "Current value",
+56
View File
@@ -246,6 +246,16 @@
"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_FAQ": "❓ FAQ",
"FAQ_HEADER": "❓ <b>FAQ</b>",
"FAQ_PAGES_PROMPT": "Выберите вопрос:",
@@ -256,6 +266,11 @@
"FAQ_PAGE_TITLE": "<b>{title}</b>",
"FAQ_PAGE_FOOTER": "Страница {current} из {total}",
"FAQ_BACK_TO_LIST": "⬅️ К списку FAQ",
"MENU_PRIVACY_POLICY": "🛡️ Политика конфиденциальности",
"PRIVACY_POLICY_HEADER": "🛡️ <b>Политика конфиденциальности</b>",
"PRIVACY_POLICY_NOT_AVAILABLE": "Политика конфиденциальности временно недоступна.",
"PRIVACY_POLICY_EMPTY_ALERT": "Текст политики конфиденциальности ещё не указан.",
"PRIVACY_POLICY_PAGE_INFO": "Страница {current} из {total}",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
@@ -396,6 +411,8 @@
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"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_SERVER_RANDOM_POOL": "🎲 Случайный из {count} серверов",
"TRIAL_SERVER_DEFAULT_NAME": "🎯 Тестовый сервер",
"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",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
@@ -763,6 +780,37 @@
"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_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SETTINGS_BOT_CONFIG": "🧩 Конфигурация бота",
@@ -986,6 +1034,7 @@
"ADMIN_PRICING_BUTTON_PERIOD_OPTIONS": "🗓 Доступные периоды",
"ADMIN_PRICING_BUTTON_PERIODS": "🗓 Периоды подписки",
"ADMIN_PRICING_BUTTON_TRAFFIC": "📦 Пакеты трафика",
"ADMIN_PRICING_BUTTON_TRAFFIC_OPTIONS": "🚦 Отображение пакетов",
"ADMIN_PRICING_BUTTON_TRIAL": "🎁 Пробный период",
"ADMIN_PRICING_CHOICE_ALREADY": "Это значение уже активно.",
"ADMIN_PRICING_CHOICE_UPDATED": "Выбрано: {label}",
@@ -1002,6 +1051,9 @@
"ADMIN_PRICING_PERIOD_DISABLED": "Период отключен.",
"ADMIN_PRICING_PERIOD_ENABLED": "Период включен.",
"ADMIN_PRICING_PERIOD_MIN": "Должен оставаться хотя бы один период.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_ENABLED": "Пакет включен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_DISABLED": "Пакет отключен.",
"ADMIN_PRICING_TRAFFIC_PACKAGE_MIN": "Должен оставаться хотя бы один пакет.",
"ADMIN_PRICING_SECTION_CORE_TITLE": "⚙️ Базовые лимиты",
"ADMIN_PRICING_SECTION_CURRENT": "Текущие значения:",
"ADMIN_PRICING_SECTION_EMPTY": "Нет доступных значений.",
@@ -1014,6 +1066,10 @@
"ADMIN_PRICING_SECTION_PROMPT": "Выберите что изменить:",
"ADMIN_PRICING_SECTION_SETTINGS_GENERIC": "⚙️ Настройки",
"ADMIN_PRICING_SECTION_TRAFFIC_TITLE": "📦 Пакеты трафика",
"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": "Нажмите на пакет, чтобы включить или выключить его отображение.",
"ADMIN_PRICING_SECTION_TRIAL_TITLE": "🎁 Пробный период",
"ADMIN_PRICING_SETTING_CANCEL_HINT": "Чтобы вернуться без изменений, ответьте «Отмена».",
"ADMIN_PRICING_SETTING_CURRENT": "Текущее значение",
+20
View File
@@ -22,6 +22,7 @@ from app.services.backup_service import backup_service
from app.services.reporting_service import reporting_service
from app.localization.loader import ensure_locale_templates
from app.services.system_settings_service import bot_configuration_service
from app.services.external_admin_service import ensure_external_admin_token
from app.services.broadcast_service import broadcast_service
from app.utils.startup_timeline import StartupTimeline
@@ -186,6 +187,25 @@ async def main():
payment_service = PaymentService(bot)
async with timeline.stage(
"Внешняя админка",
"🛡️",
success_message="Токен внешней админки готов",
) as stage:
try:
bot_user = await bot.get_me()
token = await ensure_external_admin_token(
bot_user.username,
bot_user.id,
)
if token:
stage.log("Токен синхронизирован")
else:
stage.warning("Не удалось получить токен внешней админки")
except Exception as error: # pragma: no cover - защитный блок
stage.warning(f"Ошибка подготовки внешней админки: {error}")
logger.error("❌ Ошибка подготовки внешней админки: %s", error)
webhook_needed = (
settings.TRIBUTE_ENABLED
or settings.is_cryptobot_enabled()
+3953 -99
View File
File diff suppressed because it is too large Load Diff