Compare commits

...

1074 Commits

Author SHA1 Message Date
Egor e1001156b4 Update Dockerfile 2025-10-01 07:05:17 +03:00
Egor ba2c534622 Update Dockerfile 2025-10-01 07:05:02 +03:00
Egor 72f06a784e Update docker-registry.yml 2025-10-01 07:04:44 +03:00
Egor 04e862b6bd Update docker-hub.yml 2025-10-01 07:04:31 +03:00
Egor 7f4af89d60 Update miniapp-setup.md 2025-10-01 06:43:37 +03:00
Egor f68bb699eb Update index.html 2025-10-01 06:32:56 +03:00
Egor c1d19e2ade Merge pull request #648 from Fr1ngg/dev2
Миниапка с подпиской
2025-10-01 06:14:01 +03:00
Egor e4c7e8b27f Merge pull request #647 from Fr1ngg/bedolaga/localize-subscription-button-to-english-w0zeno
Adjust purchase button localization and navigation
2025-10-01 06:10:51 +03:00
Egor c63c483b9b Adjust purchase button localization and navigation 2025-10-01 06:10:37 +03:00
Egor 4b04696feb Merge pull request #646 from Fr1ngg/revert-645-bedolaga/localize-subscription-button-to-english
Revert "Adjust miniapp purchase button behavior"
2025-10-01 06:10:09 +03:00
Egor 9ffbba7259 Revert "Adjust miniapp purchase button behavior" 2025-10-01 06:10:02 +03:00
Egor cc0b927388 Merge pull request #645 from Fr1ngg/bedolaga/localize-subscription-button-to-english
Adjust miniapp purchase button behavior
2025-10-01 06:08:34 +03:00
Egor cbcf2d55da Adjust miniapp purchase button behavior 2025-10-01 06:08:18 +03:00
Egor f7c70f60e1 Merge pull request #644 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp-izs6w9
Add configurable purchase link button to mini app error state
2025-10-01 06:01:30 +03:00
Egor 7c96b0f71c Add purchase link support to mini app error state 2025-10-01 06:01:12 +03:00
Egor 2002032be3 Merge pull request #643 from Fr1ngg/revert-642-bedolaga/add-subscription-purchase-button-in-miniapp
Revert "Add configurable purchase link to the mini app"
2025-10-01 06:00:40 +03:00
Egor ed6ff293ea Revert "Add configurable purchase link to the mini app" 2025-10-01 06:00:29 +03:00
Egor 65e66353b2 Merge pull request #642 from Fr1ngg/bedolaga/add-subscription-purchase-button-in-miniapp
Add configurable purchase link to the mini app
2025-10-01 05:57:08 +03:00
Egor 4fa87804d9 Add miniapp purchase link support 2025-10-01 05:56:51 +03:00
Egor 17587a9b30 Update miniapp-setup.md 2025-10-01 05:53:53 +03:00
Egor 37c552c705 Merge pull request #641 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding-etzom5
Open Happ CryptoLink subscriptions externally
2025-10-01 05:42:46 +03:00
Egor 95c6e72f1b fix: open happ cryptolink externally 2025-10-01 05:42:32 +03:00
Egor 4fa49e6f2e Merge pull request #640 from Fr1ngg/revert-639-bedolaga/remove-happ_cryptolink_redirect_template-binding
Revert "Open HAPP cryptolink from miniapp externally"
2025-10-01 05:41:48 +03:00
Egor 3aaa3d0671 Revert "Open HAPP cryptolink from miniapp externally" 2025-10-01 05:41:40 +03:00
Egor 9c013f19c4 Merge pull request #639 from Fr1ngg/bedolaga/remove-happ_cryptolink_redirect_template-binding
Open HAPP cryptolink from miniapp externally
2025-10-01 05:39:33 +03:00
Egor c7b6929dec Open HAPP cryptolink in external browser 2025-10-01 05:39:17 +03:00
Egor 4dca932ec2 Merge pull request #638 from Fr1ngg/revert-637-bedolaga/fix-hap_cryptolink-redirect-link
Revert "Fix miniapp connect button to open Happ CryptoLink directly"
2025-10-01 05:36:38 +03:00
Egor 997fcf8ec7 Revert "Fix miniapp connect button to open Happ CryptoLink directly" 2025-10-01 05:35:43 +03:00
Egor eb8c8ec4ce Merge pull request #637 from Fr1ngg/bedolaga/fix-hap_cryptolink-redirect-link
Fix miniapp connect button to open Happ CryptoLink directly
2025-10-01 05:34:04 +03:00
Egor f4ed61c4ca Adjust miniapp connect link handling 2025-10-01 05:33:30 +03:00
Egor 70d3434952 Merge pull request #636 from Fr1ngg/revert-635-revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"""
2025-10-01 05:27:04 +03:00
Egor 450d75227b Revert "Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""" 2025-10-01 05:26:48 +03:00
Egor 6e61980fa9 Merge pull request #635 from Fr1ngg/revert-634-revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp""
2025-10-01 05:25:02 +03:00
Egor 777e243bcd Revert "Revert "Add configurable purchase link and direct Happ flow in miniapp"" 2025-10-01 05:24:52 +03:00
Egor 46d9c25968 Merge pull request #634 from Fr1ngg/revert-633-bedolaga/remove-redirect-to-happ_cryptolink
Revert "Add configurable purchase link and direct Happ flow in miniapp"
2025-10-01 05:24:39 +03:00
Egor e26223a7d8 Revert "Add configurable purchase link and direct Happ flow in miniapp" 2025-10-01 05:24:28 +03:00
Egor 2098165229 Delete miniapp/index2.html 2025-10-01 05:19:00 +03:00
Egor c7e9d84585 Merge pull request #633 from Fr1ngg/bedolaga/remove-redirect-to-happ_cryptolink
Add configurable purchase link and direct Happ flow in miniapp
2025-10-01 05:17:29 +03:00
Egor 8a92d5e5a9 Add purchase link support and adjust miniapp connect flow 2025-10-01 05:17:12 +03:00
Egor 8b8f187cb3 Update index.html 2025-10-01 04:42:40 +03:00
Egor 02e3faf194 Create index2.html 2025-10-01 04:25:14 +03:00
Egor 7e8e05a0fc Merge pull request #631 from Fr1ngg/bedolaga/add-service-name-configuration-option-d3ekaj
Add configurable miniapp branding
2025-10-01 04:03:32 +03:00
Egor c3ca12a8a5 Update config.py 2025-10-01 04:03:14 +03:00
Egor d82b24927b Update README.md 2025-10-01 04:02:53 +03:00
Egor 7d9d05b1e8 Update .env.example 2025-10-01 04:02:33 +03:00
Egor cc96919672 Add configurable miniapp branding 2025-10-01 04:01:47 +03:00
Egor 1f0f5a4317 Merge pull request #630 from Fr1ngg/revert-629-bedolaga/add-service-name-configuration-option
Revert "feat: allow configuring miniapp branding"
2025-10-01 04:01:31 +03:00
Egor cd9218ec09 Revert "feat: allow configuring miniapp branding" 2025-10-01 04:01:22 +03:00
Egor 7256a0d1ab Merge pull request #629 from Fr1ngg/bedolaga/add-service-name-configuration-option
feat: allow configuring miniapp branding
2025-10-01 04:01:08 +03:00
Egor e2f5191844 Update config.py 2025-10-01 04:00:48 +03:00
Egor d964a2d53c Update README.md 2025-10-01 04:00:18 +03:00
Egor 7ace06834b Update .env.example 2025-10-01 03:59:49 +03:00
Egor 0a789d3bd1 feat: allow configuring miniapp branding 2025-10-01 03:58:34 +03:00
Egor 9c44c3ebd0 Merge pull request #628 from Fr1ngg/bedolaga/update-miniapp-index.html-template-mdwbtr
Enhance miniapp subscription details
2025-10-01 03:46:09 +03:00
Egor 67650666fc Enhance miniapp subscription details 2025-10-01 03:45:51 +03:00
Egor b5e343cfb0 Merge pull request #627 from Fr1ngg/revert-626-bedolaga/update-miniapp-index.html-template
Revert "Improve miniapp subscription metadata display"
2025-10-01 03:45:36 +03:00
Egor 68f72a88a4 Revert "Improve miniapp subscription metadata display" 2025-10-01 03:45:28 +03:00
Egor a225540c48 Merge pull request #626 from Fr1ngg/bedolaga/update-miniapp-index.html-template
Improve miniapp subscription metadata display
2025-10-01 03:44:30 +03:00
Egor 3fdc1fdeda Enhance miniapp subscription details 2025-10-01 03:44:05 +03:00
Egor 0748e9eed3 Merge pull request #625 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-waqu1h
Add localization and account data to miniapp
2025-10-01 03:17:27 +03:00
Egor f420e07a24 Add localization and account data to miniapp 2025-10-01 03:17:09 +03:00
Egor 885a56ecf4 Merge pull request #624 from Fr1ngg/revert-623-bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Revert "Enhance miniapp page with localization and account data"
2025-10-01 03:16:47 +03:00
Egor 4e4e80f3c4 Revert "Enhance miniapp page with localization and account data" 2025-10-01 03:16:38 +03:00
Egor 4942ac4c5e Merge pull request #623 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-7xmubf
Enhance miniapp page with localization and account data
2025-10-01 03:14:58 +03:00
Egor 20f83ac751 feat: enhance miniapp UI with localization and account data 2025-10-01 03:14:40 +03:00
Egor 6b2b20cca2 Merge pull request #622 from Fr1ngg/revert-621-bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Revert "Add localization, balance, and server info to miniapp"
2025-10-01 03:13:44 +03:00
Egor 9db392b410 Revert "Add localization, balance, and server info to miniapp" 2025-10-01 03:13:34 +03:00
Egor 444e87d70e Merge pull request #621 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect-w2rv5u
Add localization, balance, and server info to miniapp
2025-10-01 03:11:27 +03:00
Egor d8a6b6b08c Enhance miniapp with localization and account data 2025-10-01 03:11:09 +03:00
Egor 2658c298f0 Merge pull request #620 from Fr1ngg/revert-619-bedolaga/add-language-switch-and-subscription-redirect
Revert "Add localization and account data to mini app"
2025-10-01 03:10:44 +03:00
Egor 0ca4f9630a Revert "Add localization and account data to mini app" 2025-10-01 03:10:35 +03:00
Egor 718c9aba92 Merge pull request #619 from Fr1ngg/bedolaga/add-language-switch-and-subscription-redirect
Add localization and account data to mini app
2025-10-01 03:05:52 +03:00
Egor 078f1f8878 Add localization and account info to mini app 2025-10-01 03:05:34 +03:00
Egor c76eeea020 Merge pull request #617 from Fr1ngg/bedolaga/integrate-miniapp-page-with-bot-api
Document miniapp deployment and reverse proxy setup
2025-10-01 02:32:57 +03:00
Egor f919368d0b Document miniapp deployment and reverse proxy setup 2025-10-01 02:32:36 +03:00
Egor ccb442fe44 Create index.html 2025-10-01 02:10:06 +03:00
Egor d8c0dd862b Merge pull request #616 from Fr1ngg/dev2
W
2025-10-01 02:08:39 +03:00
Egor 1ed418b176 Delete miniapp/index.html 2025-10-01 02:08:17 +03:00
Egor 59efd837cd Merge pull request #615 from Fr1ngg/revert-614-bedolaga/add-third-purchase-mode-for-subscriptions
Revert "Add tariff-based subscription mode"
2025-10-01 02:06:32 +03:00
Egor 60d23bedde Revert "Add tariff-based subscription mode" 2025-10-01 02:06:18 +03:00
Egor d08f74179f Merge pull request #614 from Fr1ngg/bedolaga/add-third-purchase-mode-for-subscriptions
Add tariff-based subscription mode
2025-10-01 02:05:00 +03:00
Egor 70ad084bcd Add tariff-aware subscription API handling 2025-10-01 02:04:20 +03:00
Egor 818ef1ccb0 Create index.html 2025-10-01 02:03:30 +03:00
Egor f6369f7b44 Merge pull request #613 from Fr1ngg/bedolaga/add-english-locale-support-14ehhu
Add English localization for subscription management flows
2025-10-01 01:23:19 +03:00
Egor 5f8295a588 Add English localization for subscription settings flows 2025-10-01 01:22:49 +03:00
Egor 5c51788dc3 Merge pull request #612 from Fr1ngg/revert-611-bedolaga/add-english-locale-support
Revert "Add English localization for subscription management interfaces"
2025-10-01 01:22:36 +03:00
Egor 24e6d57273 Revert "Add English localization for subscription management interfaces" 2025-10-01 01:22:28 +03:00
Egor be95d34122 Merge pull request #611 from Fr1ngg/bedolaga/add-english-locale-support
Add English localization for subscription management interfaces
2025-10-01 01:18:14 +03:00
Egor 359cdd106d Add English localization for subscription management menus 2025-10-01 01:17:43 +03:00
Egor 0bc5b10a78 Merge pull request #610 from Fr1ngg/bedolaga/add-english-localization-for-user-management
Add English localization for admin user management view
2025-10-01 01:04:24 +03:00
Egor efd4eda62a Add English localization for admin user view 2025-10-01 01:04:05 +03:00
Egor d4c169e6bb Merge pull request #609 from Fr1ngg/bedolaga/add-localization-to-admin-panel-submenu
Add English localization for admin support menus
2025-10-01 00:08:24 +03:00
Egor c12f3d443c Add English localization for admin support menus 2025-10-01 00:08:03 +03:00
Egor db7ecea006 Merge pull request #608 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue-xgkdfd
Handle additional MulenPay webhook signature formats
2025-09-30 23:57:35 +03:00
Egor 2b4136faa7 Handle additional MulenPay webhook signature formats 2025-09-30 23:57:18 +03:00
Egor ca09ce8cf4 Merge pull request #607 from Fr1ngg/revert-606-bedolaga/fix-payment-callback-signature-issue
Revert "Improve MulenPay webhook signature handling"
2025-09-30 23:57:02 +03:00
Egor 533087b333 Revert "Improve MulenPay webhook signature handling" 2025-09-30 23:56:54 +03:00
Egor 80afcbb737 Merge pull request #606 from Fr1ngg/bedolaga/fix-payment-callback-signature-issue
Improve MulenPay webhook signature handling
2025-09-30 23:51:44 +03:00
Egor 845faf037f Improve MulenPay webhook signature handling 2025-09-30 23:51:29 +03:00
Egor 1f160ced02 Merge pull request #605 from Fr1ngg/bedolaga/add-english-translations-for-admin-panel
Add missing admin panel locale translations
2025-09-30 23:47:25 +03:00
Egor 07e4c07d6e Add admin panel translations for ru/en locales 2025-09-30 23:47:06 +03:00
Egor 78ab84b021 Merge pull request #604 from Fr1ngg/bedolaga/add-card-payment-option-to-paypalych
Support PayPalych card payments with configurable button labels
2025-09-30 23:42:36 +03:00
Egor db5551df23 Add customizable PayPalych payment buttons 2025-09-30 23:42:19 +03:00
Egor ee75395c31 Merge pull request #603 from Fr1ngg/bedolaga/add-missing-translations-for-admin-panel
Add missing admin translations for Russian and English locales
2025-09-30 22:16:22 +03:00
Egor 453bb75b7f Add missing admin translations for Russian and English 2025-09-30 22:15:57 +03:00
Egor 88d2c8325d Merge pull request #602 from Fr1ngg/bedolaga/add-notifications-for-unsubscribed-users
Add monitoring notification for trial users leaving the channel
2025-09-30 21:45:13 +03:00
Egor 8fe9a3dbe2 feat: notify trial users when leaving channel 2025-09-30 21:44:49 +03:00
Egor cf6c282b25 Merge pull request #600 from PEDZEO/main
fix /start moderator
2025-09-30 21:12:03 +03:00
Egor 773e525a6a Merge pull request #599 from SantaSpeen/main
[+] Adding support stock app-config.json
2025-09-30 21:08:44 +03:00
Egor a7d35d51b8 Merge pull request #601 from Fr1ngg/revert-596-bedolaga/fix-all-merge-conflicts
Revert "Resolve duplicate handlers and cleanup imports"
2025-09-30 21:07:46 +03:00
Egor 46eb93dabe Revert "Resolve duplicate handlers and cleanup imports" 2025-09-30 21:07:37 +03:00
Egor 43e63f864e Merge pull request #594 from Gy9vin/main
Расширенный фильтр пользователей
2025-09-30 20:56:45 +03:00
PEDZEO 8774b53570 fix /start moderator 2025-09-30 18:45:44 +03:00
SantaSpeen 2aaec2ca58 Merge pull request #1 from SantaSpeen/slave
Merge into main
2025-09-30 17:20:56 +03:00
SantaSpeen 5efe36878d [~] Update upstream 2025-09-30 17:20:08 +03:00
SantaSpeen b9861249b7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	app/handlers/subscription.py
2025-09-30 17:11:32 +03:00
Egor 2c9b585f4b Merge pull request #597 from Fr1ngg/dev2
fix
2025-09-30 16:51:54 +03:00
Egor dd5b36ff4a Merge pull request #596 from Fr1ngg/bedolaga/fix-all-merge-conflicts
Resolve duplicate handlers and cleanup imports
2025-09-30 16:41:09 +03:00
Egor 144a942a97 Resolve duplicate handlers and cleanup imports 2025-09-30 16:40:08 +03:00
SantaSpeen ff661d84ee [+] Update app-config.json: new get_apps_for_device 2025-09-30 14:46:55 +03:00
SantaSpeen ab1b8619f8 [+] Update get_apps_for_device: Allow app-config.json just from panel 2025-09-30 14:26:22 +03:00
gy9vin 0f621bfe5a Revert "Оживление виджета "Детальная статистика пользователей""
This reverts commit 400cc5a32e.
2025-09-30 12:42:17 +03:00
gy9vin c344f418c5 Расширение фильтров 2025-09-30 12:39:15 +03:00
gy9vin 400cc5a32e Оживление виджета "Детальная статистика пользователей" 2025-09-30 11:55:04 +03:00
Egor b6a383b5d8 Merge pull request #593 from Fr1ngg/dev2
Логи в боте
2025-09-30 04:37:25 +03:00
Egor a8215ddec5 Update system_logs.py 2025-09-30 04:34:09 +03:00
Egor 954c96ed16 Merge pull request #592 from Fr1ngg/bedolaga/add-log-section-to-admin-panel
Add system logs view to admin panel
2025-09-30 04:30:47 +03:00
Egor ed9a3613f5 Add system logs view to admin panel 2025-09-30 04:30:30 +03:00
Egor 29743351de Delete app/webapi/vpn-admin-dashboard.html 2025-09-30 03:52:57 +03:00
Egor fd897464f5 Update Dockerfile 2025-09-30 03:52:36 +03:00
Egor d9b0420202 Update docker-registry.yml 2025-09-30 03:52:24 +03:00
Egor 527d907035 Update docker-hub.yml 2025-09-30 03:52:11 +03:00
Egor 6c3270e6d7 Merge pull request #590 from Fr1ngg/dev2
Dev to main
2025-09-30 03:36:11 +03:00
Egor b7382eb7c3 Merge pull request #589 from Fr1ngg/bedolaga/update-bot-configuration-menu
Update admin bot configuration menu grouping
2025-09-30 03:32:04 +03:00
Egor 0cd7d45338 Refine bot configuration menu structure 2025-09-30 03:31:37 +03:00
Egor 23dcb9699c Merge pull request #588 from Fr1ngg/bedolaga/fix-duplicate-cryptolink-display
Remove duplicate Happ CryptoLink link display
2025-09-30 03:17:49 +03:00
Egor 9603952b2a Adjust Happ CryptoLink message formatting 2025-09-30 03:17:17 +03:00
Egor 299eeee9d3 Merge pull request #587 from Fr1ngg/bedolaga-xjizv4
Convert Happ cryptolink message to happ scheme
2025-09-30 03:11:48 +03:00
Egor 63c7e9c08b Adjust Happ cryptolink message formatting 2025-09-30 03:11:28 +03:00
Egor 42131457bf Merge pull request #586 from Fr1ngg/revert-585-bedolaga-uswi1l
Revert "feat: update Happ cryptolink subscription link handling"
2025-09-30 03:11:17 +03:00
Egor a7370fbe52 Revert "feat: update Happ cryptolink subscription link handling" 2025-09-30 03:11:08 +03:00
Egor 41f0a15cea Merge pull request #585 from Fr1ngg/bedolaga-uswi1l
feat: update Happ cryptolink subscription link handling
2025-09-30 03:08:59 +03:00
Egor dd92f37fc1 feat: adjust Happ cryptolink presentation 2025-09-30 03:08:42 +03:00
Egor 46d4d699dd Merge pull request #584 from Fr1ngg/revert-583-bedolaga-bsbzht
Revert "Update Happ CryptoLink message formatting"
2025-09-30 03:07:37 +03:00
Egor 76b594363b Revert "Update Happ CryptoLink message formatting" 2025-09-30 03:07:28 +03:00
Egor 107b18d642 Merge pull request #583 from Fr1ngg/bedolaga-bsbzht
Update Happ CryptoLink message formatting
2025-09-30 03:05:01 +03:00
Egor 9973491503 Update Happ CryptoLink message formatting 2025-09-30 03:04:26 +03:00
Egor 418081048e Merge pull request #582 from Fr1ngg/bedolaga/add-language-selection-toggle-in-bot-config
feat: allow disabling language selection
2025-09-30 02:51:53 +03:00
Egor f22838b99f feat: make language selection optional 2025-09-30 02:51:31 +03:00
Egor 9bdacea568 Update .env.example 2025-09-30 02:50:26 +03:00
Egor 6bec0ed0c8 Merge pull request #581 from Fr1ngg/bedolaga/add-language-selection-at-start
Add language selection workflow and menu option
2025-09-30 02:38:29 +03:00
Egor 4363955443 Add language selection workflow and menu option 2025-09-30 02:37:57 +03:00
Egor 47a272c7e8 Merge pull request #580 from Fr1ngg/bedolaga/fix-subscription-renewal-error
Prevent async session errors when extending subscriptions
2025-09-30 02:17:24 +03:00
Egor 07ce852917 Fix subscription extension notifications and bot init 2025-09-30 02:17:06 +03:00
Egor a6ada5c9e3 Merge pull request #579 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups-zboyk5
feat: expand admin promo notifications
2025-09-30 02:06:47 +03:00
Egor 90c5b1d003 feat: expand admin notifications for promo events 2025-09-30 02:06:31 +03:00
Egor 326aca31d7 Merge pull request #578 from Fr1ngg/revert-577-bedolaga/add-admin-notifications-for-promo-groups
Revert "Enhance admin notifications with promo group context"
2025-09-30 02:06:20 +03:00
Egor 4fd5ba863b Revert "Enhance admin notifications with promo group context" 2025-09-30 02:06:11 +03:00
Egor 4bd0b221e7 Merge pull request #577 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups
Enhance admin notifications with promo group context
2025-09-30 01:57:14 +03:00
Egor f47511f26a Enhance admin notifications with promo group context 2025-09-30 01:56:52 +03:00
Egor 2d409eaa1f Merge pull request #576 from Fr1ngg/bedolaga/update-subscription-status-to-icons
Move subscription status icons into server user buttons
2025-09-30 01:41:16 +03:00
Egor 9d42c4fb7d Adjust server user list status icons 2025-09-30 01:41:03 +03:00
Egor d3ced8f7e2 Merge pull request #575 from Fr1ngg/bedolaga/add-pagination-to-users-list-page
Add pagination to admin server users list
2025-09-30 01:34:24 +03:00
Egor a5715b2ca8 Add pagination to server users admin view 2025-09-30 01:34:07 +03:00
Egor c18dd604d8 Merge pull request #574 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-g9vyec
Fix server user list to account for JSON-based connections
2025-09-30 01:28:59 +03:00
Egor c2656f4ee6 Fix server users list by including JSON connections 2025-09-30 01:28:43 +03:00
Egor 18641d46d3 Merge pull request #573 from Fr1ngg/revert-572-bedolaga/fix-user-count-display-on-server-list-e72yq9
Revert "Fix server user list counts"
2025-09-30 01:28:28 +03:00
Egor 7ef3a08215 Revert "Fix server user list counts" 2025-09-30 01:28:20 +03:00
Egor 55f1a3b220 Merge pull request #572 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-e72yq9
Fix server user list counts
2025-09-30 01:26:51 +03:00
Egor e2f313e6ea Fix server user list counts 2025-09-30 01:26:37 +03:00
Egor 5f99478433 Merge pull request #571 from Fr1ngg/revert-570-bedolaga/fix-user-count-display-on-server-list-gt974l
Revert "Fix server user list to include JSON connections"
2025-09-30 01:26:12 +03:00
Egor 89f0a3663d Revert "Fix server user list to include JSON connections" 2025-09-30 01:26:03 +03:00
Egor e53dab87e3 Merge pull request #570 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-gt974l
Fix server user list to include JSON connections
2025-09-30 01:24:22 +03:00
Egor 6d85c179bb Fix server user list to include JSON connections 2025-09-30 01:24:05 +03:00
Egor 30dafcef49 Merge pull request #569 from Fr1ngg/revert-568-bedolaga/fix-user-count-display-on-server-list
Revert "Fix admin server user list query"
2025-09-30 01:23:47 +03:00
Egor 32d7a7394e Revert "Fix admin server user list query" 2025-09-30 01:23:36 +03:00
Egor 3cb4f27433 Merge pull request #568 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list
Fix admin server user list query
2025-09-30 01:22:06 +03:00
Egor 50692566d5 Fix server user list to use connected squads 2025-09-30 01:21:49 +03:00
Egor fbb2afd9e1 Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
2025-09-30 01:03:15 +03:00
Egor 3de951b153 Enhance backup coverage and configuration snapshots 2025-09-30 01:02:57 +03:00
Egor 1b86d467b5 Merge pull request #566 from Fr1ngg/bedolaga/investigate-subscription-cancellation-bug
Convert extended trials to paid subscriptions
2025-09-30 00:51:15 +03:00
Egor e4743ced9b Convert extended trials to paid subscriptions 2025-09-30 00:50:57 +03:00
Egor 2351056d56 Merge pull request #565 from Fr1ngg/bedolaga/fix-subscription-promotion-allocation
Fix total spent calculation for promo groups
2025-09-30 00:43:06 +03:00
Egor 748f9a2393 Fix total spent calculation for promo groups 2025-09-30 00:42:46 +03:00
Egor ada15065ea Merge pull request #564 from Fr1ngg/bedolaga/fix-user-statistics-in-admin-panel
Fix admin user subscription stats
2025-09-29 17:37:52 +03:00
Egor 53f1831f59 Fix admin user subscription stats 2025-09-29 17:37:20 +03:00
Egor 860ebf18b6 Merge pull request #563 from Fr1ngg/bedolaga/fix-promotion-group-mechanics-and-discounts
Fix base period discount rounding and summary formatting
2025-09-29 16:00:27 +03:00
Egor ae05ca08ea Round discounted subscription prices up and clean summary 2025-09-29 15:43:50 +03:00
Egor 88e8a56526 Merge pull request #562 from Fr1ngg/bedolaga/fix-transaction-history-logging-for-admin-actions
Log admin-triggered balance deductions in transaction history
2025-09-29 15:11:05 +03:00
Egor fb4d714441 Log admin balance deductions as transactions 2025-09-29 15:10:47 +03:00
Egor 2690f63a82 Merge pull request #561 from Fr1ngg/bedolaga/update-subscription-pricing-calculation
Fix admin subscription purchase cost calculation
2025-09-29 14:59:02 +03:00
Egor ec9855a0a9 Fix admin subscription price calculation 2025-09-29 14:57:47 +03:00
Egor 984ad8e448 Merge pull request #560 from Fr1ngg/bedolaga/add-traffic-reset-on-subscription-payment
Add configurable traffic reset on subscription payments
2025-09-29 14:41:06 +03:00
Egor ca81b32502 Add configurable traffic reset on payments 2025-09-29 14:40:36 +03:00
Egor 70f190501b Merge pull request #559 from Fr1ngg/bedolaga/fix-automation-migration-errors
Fix promo group migration defaults for addon discounts
2025-09-29 14:21:46 +03:00
Egor 432efdbb7c Fix promo group seeding for new databases 2025-09-29 14:21:23 +03:00
Egor 83a821a591 Merge pull request #558 from Fr1ngg/bedolaga/add-trial-and-subscription-days-summation
Add setting to roll remaining trial time into paid subscriptions
2025-09-29 14:02:33 +03:00
Egor 14bdfd4a1e Add configurable trial rollover for paid subscriptions 2025-09-29 14:02:14 +03:00
Egor 66ff2a38d3 Merge pull request #557 from Fr1ngg/bedolaga/update-subscription-link-handling
Respect hidden subscription link setting
2025-09-29 13:40:46 +03:00
Egor 9f7c9cad38 feat: respect hidden subscription link setting 2025-09-29 13:40:27 +03:00
Egor 2132226b15 Merge pull request #556 from Fr1ngg/revert-555-bedolaga/hide-subscription-link-in-notifications
Revert "Hide subscription link when configured"
2025-09-29 13:16:49 +03:00
Egor 849465b2e1 Revert "Hide subscription link when configured" 2025-09-29 13:16:38 +03:00
Egor 402b31625b Merge pull request #555 from Fr1ngg/bedolaga/hide-subscription-link-in-notifications
Hide subscription link when configured
2025-09-29 13:12:42 +03:00
Egor 5fc24dbfae Hide subscription link in success messages when configured 2025-09-29 13:12:24 +03:00
Egor 8ba65cc2fe Merge pull request #554 from Fr1ngg/main
w
2025-09-29 13:02:20 +03:00
Egor ee2bffe08e Merge pull request #550 from PEDZEO/feature/ticket-user-info
Исправил поведение кнопок
2025-09-29 12:58:48 +03:00
PEDZEO c4b5db9002 fix(validation): sanitize имён + HTML-escape только в /start 2025-09-29 02:17:19 +03:00
PEDZEO 1a78bdc194 Исправил поведение кнопок 2025-09-29 01:31:49 +03:00
Egor a3bb749a6a Merge pull request #549 from Fr1ngg/main
w
2025-09-28 22:39:25 +03:00
Egor 626f78b230 Merge pull request #548 from PEDZEO/feature/ticket-user-info
Feature/ticket user info
2025-09-28 22:21:47 +03:00
PEDZEO 5d44285c0a feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:47 +03:00
PEDZEO 2d8091ddcb feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:29 +03:00
Egor fa6a4698ab Merge pull request #546 from FireWookie/dev2
Update API
2025-09-28 16:41:40 +03:00
firewookie f40454313f add ads compaigns remote route 2025-09-28 14:21:53 +05:00
firewookie 8a256b1bc2 add broadcast service 2025-09-28 14:05:35 +05:00
firewookie 71ac7ea464 backup logic and add documentation to stats route 2025-09-28 14:04:17 +05:00
firewookie bbc9f93429 update api 2025-09-28 13:36:53 +05:00
firewookie 3ba9598b5e update api 2025-09-28 13:35:13 +05:00
firewookie ffa51986a8 env to json 2025-09-28 13:34:43 +05:00
Egor 7e20140a00 Merge pull request #544 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue-j3pfif
Add fallback for bootstrap API token authentication
2025-09-28 06:28:06 +03:00
Egor 50d080f54e Improve web API token authentication fallback 2025-09-28 06:27:38 +03:00
Egor 29829b1c8c Merge pull request #543 from Fr1ngg/revert-542-bedolaga/fix-bot-token-authorization-issue
Revert "Ensure bootstrap web API token works without migration"
2025-09-28 06:26:47 +03:00
Egor e84e707d2f Revert "Ensure bootstrap web API token works without migration" 2025-09-28 06:26:37 +03:00
Egor c12566ae53 Merge pull request #542 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue
Ensure bootstrap web API token works without migration
2025-09-28 06:24:31 +03:00
Egor e6976b5262 Handle bootstrap API token fallback 2025-09-28 06:24:13 +03:00
Egor 81cfdcf7ac Merge pull request #541 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-jxb0f9
Synchronize bootstrap web API token with config overrides
2025-09-28 06:16:38 +03:00
Egor 9c1ca5b748 Ensure default web API token stays in sync with settings 2025-09-28 06:16:13 +03:00
Egor a0d14f97f4 Merge pull request #540 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-mvwipi
Handle missing RemnaWave dependencies in web API
2025-09-28 06:03:25 +03:00
Egor 8fdc70fdc3 Handle optional RemnaWave dependencies in web API 2025-09-28 06:03:11 +03:00
Egor d93139a458 Merge pull request #539 from Fr1ngg/revert-538-bedolaga/add-admin-panel-functionality-to-bot-api
Revert "Handle optional RemnaWave dependencies in web API"
2025-09-28 06:02:51 +03:00
Egor b78dc12db9 Revert "Handle optional RemnaWave dependencies in web API" 2025-09-28 06:02:42 +03:00
Egor 2a02f589bf Merge pull request #538 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api
Handle optional RemnaWave dependencies in web API
2025-09-28 06:00:37 +03:00
Egor bcfa4f8400 Handle optional RemnaWave dependencies in web API 2025-09-28 06:00:18 +03:00
Egor 042dc900ef Merge pull request #537 from Fr1ngg/bedolaga/add-endpoint-information-to-redoc
Fix OpenAPI export for RemnaWave endpoints
2025-09-28 05:45:49 +03:00
Egor 6004607bf6 Update __init__.py 2025-09-28 05:45:41 +03:00
Egor 40e4b1ce7f Ensure RemnaWave routes are imported for OpenAPI 2025-09-28 05:45:09 +03:00
Egor a4c5a2295f Merge pull request #536 from Fr1ngg/bedolaga/fix-missing-remnawave-endpoints-in-docs
Fix RemnaWave API docs registration
2025-09-28 05:29:01 +03:00
Egor 3d9a75ff51 Fix RemnaWave API docs registration 2025-09-28 05:28:41 +03:00
Egor 1216a4dd9d Merge pull request #535 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints-6ae7or
Add RemnaWave API documentation to Swagger
2025-09-28 05:09:45 +03:00
Egor 4bc1c402e9 feat: document remnawave endpoints in swagger 2025-09-28 05:09:24 +03:00
Egor 42a758bd68 Merge pull request #534 from Fr1ngg/revert-533-bedolaga/fix-documentation-for-remnawave-endpoints
Revert "Fix RemnaWave routes visibility in docs"
2025-09-28 05:08:18 +03:00
Egor 07a0d0ca43 Revert "Fix RemnaWave routes visibility in docs" 2025-09-28 05:08:08 +03:00
Egor f53b04444b Merge pull request #533 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints
Fix RemnaWave routes visibility in docs
2025-09-28 05:06:32 +03:00
Egor 9877c00c92 Fix RemnaWave routes visibility in docs 2025-09-28 05:06:16 +03:00
Egor 14aa175193 Merge pull request #532 from Fr1ngg/revert-529-revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Revert "Add RemnaWave management endpoints to web API""
2025-09-28 04:57:19 +03:00
Egor 562766a637 Revert "Revert "Add RemnaWave management endpoints to web API"" 2025-09-28 04:57:10 +03:00
Egor 4616911189 Merge pull request #531 from Fr1ngg/revert-530-bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Revert "Add Remnawave component management API"
2025-09-28 04:56:43 +03:00
Egor 1e14a0919f Revert "Add Remnawave component management API" 2025-09-28 04:56:33 +03:00
Egor e1f0bd152c Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
2025-09-28 04:53:36 +03:00
Egor c10e34ad43 Add Remnawave component management API 2025-09-28 04:53:01 +03:00
Egor 714f742560 Merge pull request #529 from Fr1ngg/revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Add RemnaWave management endpoints to web API"
2025-09-28 04:51:51 +03:00
Egor dc5e440fe2 Revert "Add RemnaWave management endpoints to web API" 2025-09-28 04:51:42 +03:00
Egor d53873dc7f Merge pull request #527 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components
Add RemnaWave management endpoints to web API
2025-09-28 04:46:12 +03:00
Egor 10ab557d8d feat(webapi): add remnawave integration endpoints 2025-09-28 04:45:57 +03:00
Egor 81147d42a9 Merge pull request #526 from Fr1ngg/revert-525-bedolaga/-scalar-redoc-api-osbdld
Revert "Replace ReDoc docs with Scalar UI"
2025-09-28 04:30:48 +03:00
Egor 8d5e2037d3 Revert "Replace ReDoc docs with Scalar UI" 2025-09-28 04:30:38 +03:00
Egor 7f03e61b5f Merge pull request #525 from Fr1ngg/bedolaga/-scalar-redoc-api-osbdld
Replace ReDoc docs with Scalar UI
2025-09-28 04:28:00 +03:00
Egor 870ee2fb66 Replace ReDoc docs with Scalar UI 2025-09-28 04:27:43 +03:00
Egor a525079282 Merge pull request #524 from Fr1ngg/revert-523-bedolaga/-scalar-redoc-api
Revert "Replace ReDoc with Scalar API reference"
2025-09-28 04:27:06 +03:00
Egor 39aff2bc1b Revert "Replace ReDoc with Scalar API reference" 2025-09-28 04:26:57 +03:00
Egor b4dc9f1970 Merge pull request #523 from Fr1ngg/bedolaga/-scalar-redoc-api
Replace ReDoc with Scalar API reference
2025-09-28 04:24:32 +03:00
Egor 37ac3e7a81 Replace ReDoc with Scalar API reference 2025-09-28 04:24:13 +03:00
Egor e3e6428d2a Merge pull request #522 from Fr1ngg/bedolaga/enable-swagger-for-api-in-bot
docs: explain enabling swagger ui
2025-09-28 04:20:43 +03:00
Egor 3e3385d1eb docs: explain enabling swagger ui 2025-09-28 04:20:28 +03:00
Egor 0375e74227 Merge pull request #521 from Fr1ngg/revert-505-revert-504-bedolaga/-docs
Revert "Revert "Enable API key auth in web API docs""
2025-09-28 04:02:41 +03:00
Egor b6bdeeefdd Revert "Revert "Enable API key auth in web API docs"" 2025-09-28 04:02:28 +03:00
Egor 24b3660f7b Merge pull request #520 from Fr1ngg/bedolaga/fix-bot-startup-issue
Fix database URL initialization
2025-09-28 03:56:01 +03:00
Egor da2c30cd8d Fix database URL initialization 2025-09-28 03:55:43 +03:00
Egor 1fad95c0bb Merge pull request #519 from Fr1ngg/revert-518-bedolaga/fix-bot-startup-validation-errors-f8krj6
Revert "Allow bot to boot without RemnaWave API settings"
2025-09-28 03:50:14 +03:00
Egor 6a1b1a9a91 Revert "Allow bot to boot without RemnaWave API settings" 2025-09-28 03:50:06 +03:00
Egor 9abf0b6ebe Merge pull request #518 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-f8krj6
Allow bot to boot without RemnaWave API settings
2025-09-28 03:46:33 +03:00
Egor 34e90b335b Handle missing RemnaWave configuration 2025-09-28 03:46:12 +03:00
Egor e84c82f29d Merge pull request #517 from Fr1ngg/revert-506-bedolaga/fix-bot-startup-error-due-to-missing-database_url
Revert "Make DATABASE_URL optional for default setup"
2025-09-28 03:38:31 +03:00
Egor 1c929a1a70 Revert "Make DATABASE_URL optional for default setup" 2025-09-28 03:38:23 +03:00
Egor 1c8878e651 Merge pull request #516 from Fr1ngg/revert-507-bedolaga/fix-missing-database_url-in-settings
Revert "Fix default handling for DATABASE_URL setting"
2025-09-28 03:37:53 +03:00
Egor 84645d84f7 Revert "Fix default handling for DATABASE_URL setting" 2025-09-28 03:37:44 +03:00
Egor 0cc59501bc Merge pull request #515 from Fr1ngg/revert-510-bedolaga/fix-missing-database_url-in-settings-wqxv3w
Revert "Allow optional Remnawave integration settings"
2025-09-28 03:36:54 +03:00
Egor ef6aaa58ea Revert "Allow optional Remnawave integration settings" 2025-09-28 03:36:42 +03:00
Egor 942ce48241 Merge pull request #514 from Fr1ngg/revert-513-bedolaga/fix-bot-startup-validation-errors-2waops
Revert "Handle missing optional RemnaWave settings"
2025-09-28 03:36:19 +03:00
Egor 625ec43c34 Revert "Handle missing optional RemnaWave settings" 2025-09-28 03:36:10 +03:00
Egor f184303415 Merge pull request #513 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-2waops
Handle missing optional RemnaWave settings
2025-09-28 03:35:02 +03:00
Egor 84a17864cd Handle missing optional RemnaWave settings 2025-09-28 03:34:44 +03:00
Egor 1476247519 Merge pull request #512 from Fr1ngg/revert-511-bedolaga/fix-bot-startup-validation-errors
Revert "Fix missing defaults for optional Remnawave settings"
2025-09-28 03:34:05 +03:00
Egor 0ea95005ee Revert "Fix missing defaults for optional Remnawave settings" 2025-09-28 03:33:56 +03:00
Egor ba808f66da Merge pull request #511 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Fix missing defaults for optional Remnawave settings
2025-09-28 03:32:42 +03:00
Egor e8472a502f Make optional Remnawave settings nullable by default 2025-09-28 03:32:23 +03:00
Egor 7d552ab715 Merge pull request #510 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-wqxv3w
Allow optional Remnawave integration settings
2025-09-28 03:27:55 +03:00
Egor 00de3a41df Allow optional Remnawave integration settings 2025-09-28 03:27:25 +03:00
Egor 36f38187d9 Merge pull request #509 from Fr1ngg/revert-508-bedolaga/fix-missing-database_url-in-settings-gvqm03
Revert "Make Remnawave settings optional by default"
2025-09-28 03:27:12 +03:00
Egor dd68b2b788 Revert "Make Remnawave settings optional by default" 2025-09-28 03:27:02 +03:00
Egor 7a9d37c884 Merge pull request #508 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-gvqm03
Make Remnawave settings optional by default
2025-09-28 03:25:20 +03:00
Egor 6a904d4be2 Make Remnawave settings optional by default 2025-09-28 03:25:04 +03:00
Egor b5b8408055 Merge pull request #507 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings
Fix default handling for DATABASE_URL setting
2025-09-28 03:20:07 +03:00
Egor 86a3228579 Fix default handling for DATABASE_URL setting 2025-09-28 03:19:52 +03:00
Egor 560993c0f3 Merge pull request #506 from Fr1ngg/bedolaga/fix-bot-startup-error-due-to-missing-database_url
Make DATABASE_URL optional for default setup
2025-09-28 03:11:15 +03:00
Egor a3fe935149 Make DATABASE_URL optional in settings 2025-09-28 03:10:57 +03:00
Egor a75549aebf Merge pull request #505 from Fr1ngg/revert-504-bedolaga/-docs
Revert "Enable API key auth in web API docs"
2025-09-28 03:02:31 +03:00
Egor be3fc7aa09 Revert "Enable API key auth in web API docs" 2025-09-28 03:02:22 +03:00
Egor 6cbfc6d74b Merge pull request #504 from Fr1ngg/bedolaga/-docs
Enable API key auth in web API docs
2025-09-28 02:59:25 +03:00
Egor 7ff7a051d9 Enable API key auth in web API docs 2025-09-28 02:59:07 +03:00
Egor 9a2f156158 Merge pull request #503 from Fr1ngg/bedolaga/fix-err_empty_response-on-api-endpoint
Expose admin API port for docker deployments
2025-09-28 02:48:58 +03:00
Egor 38b058636c Expose admin API port in docker compose 2025-09-28 02:48:42 +03:00
Egor 32523f5dea Merge pull request #502 from Fr1ngg/bedolaga/fix-web-api-startup-error
Handle uvicorn startup flag regression for admin web API
2025-09-28 02:32:09 +03:00
Egor ff70e38dbc Fix web API startup wait for uvicorn 0.32 2025-09-28 02:31:56 +03:00
Egor 8110f52bc3 Merge pull request #501 from Fr1ngg/bedolaga/fix-bot-api-launch-issue-3qh95y
Fix FastAPI 204 delete endpoints to return empty responses
2025-09-28 02:25:43 +03:00
Egor a5a0176433 Fix 204 responses to avoid response bodies 2025-09-28 02:25:26 +03:00
Egor 2d960e3e30 Merge pull request #500 from Fr1ngg/revert-499-bedolaga/fix-bot-api-launch-issue
Revert "Fix 204 responses for web API deletes"
2025-09-28 02:25:10 +03:00
Egor b5fa978023 Revert "Fix 204 responses for web API deletes" 2025-09-28 02:25:01 +03:00
Egor 9cc49e9f66 Merge pull request #499 from Fr1ngg/bedolaga/fix-bot-api-launch-issue
Fix 204 responses for web API deletes
2025-09-28 02:24:17 +03:00
Egor dbf63e518f Fix 204 responses for web API deletes 2025-09-28 02:23:58 +03:00
Egor 3e218b6e6d Merge pull request #498 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-vi0z1h
Handle RemnaWave server removal and add connected users view
2025-09-28 02:08:25 +03:00
Egor 2f52f6b8de Remove missing RemnaWave servers and list connected users 2025-09-28 02:08:05 +03:00
Egor 5d377cf885 Merge pull request #497 from Fr1ngg/revert-496-bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Revert "Handle removed RemnaWave squads and expose server users"
2025-09-28 02:07:50 +03:00
Egor 62ecacc3cc Revert "Handle removed RemnaWave squads and expose server users" 2025-09-28 02:07:43 +03:00
Egor 4ab6d45e50 Merge pull request #496 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Handle removed RemnaWave squads and expose server users
2025-09-28 02:02:55 +03:00
Egor d1bed5e41a Handle removed RemnaWave squads and expose server users 2025-09-28 02:02:37 +03:00
Egor b907f884ba Merge pull request #495 from Fr1ngg/revert-494-bedolaga/fix-server-deletion-issue-in-subscriptions
Revert "Handle RemnaWave server removal and expose server user list"
2025-09-28 02:02:08 +03:00
Egor 83a76c928d Revert "Handle RemnaWave server removal and expose server user list" 2025-09-28 02:01:57 +03:00
Egor 90d3f3aef2 Merge pull request #494 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions
Handle RemnaWave server removal and expose server user list
2025-09-28 01:56:59 +03:00
Egor 263df606d2 Remove missing RemnaWave servers during sync and add user list view 2025-09-28 01:56:40 +03:00
Egor 2a38969e3d Merge pull request #493 from Fr1ngg/main
w
2025-09-28 01:50:53 +03:00
Egor 300feec18e Merge pull request #492 from Fr1ngg/dev3
Dev3
2025-09-28 01:38:14 +03:00
Egor 7c56452eb4 Merge pull request #491 from Fr1ngg/bedolaga/fix-payment-button-and-status-issues
Fix PayPalych SBP payments
2025-09-28 01:34:02 +03:00
Egor 548ec540f2 Fix PayPalych SBP payments 2025-09-28 01:33:38 +03:00
Egor bceba34cc3 Merge pull request #490 from Fr1ngg/main
w
2025-09-28 01:14:29 +03:00
Egor b07ce2be5c Merge pull request #487 from yazhog/main
Пропуск выбора стран при оформлении подписки если доступен только 1 сквад, фикс уведомлений при пополнении баланса
2025-09-28 01:13:50 +03:00
Egor 9472cf5e8c Merge pull request #484 from PEDZEO/feature/ticket-report
Добавлен отчет по тикетам
2025-09-28 01:12:51 +03:00
yazhog 82f34e8c6e Merge pull request #44 from yazhog/codex/update-payment-options-layout
Reorder subscription return button in top-up selection
2025-09-27 16:48:18 +03:00
yazhog 02b718dac5 Adjust top-up keyboard button order 2025-09-27 16:44:52 +03:00
yazhog a65a23acb2 Merge pull request #43 from yazhog/codex/refactor-admin-notification-subscription-handling
Avoid lazy subscription load in admin notifications
2025-09-27 15:58:11 +03:00
yazhog 12523e7515 Avoid lazy subscription load in admin notifications 2025-09-27 15:54:38 +03:00
yazhog d3497f924a Merge pull request #42 from yazhog/codex/skip-country-selection-if-one-available
Skip country selection step when only one option is available
2025-09-27 13:29:20 +03:00
yazhog 75faa54b85 Skip country selection when only one option 2025-09-27 13:10:03 +03:00
Pedzeo d22f9aaf62 Добавлен отчет по тикетам 2025-09-26 17:14:00 +03:00
Egor 68f5e2ab80 Add files via upload 2025-09-26 15:17:29 +03:00
Egor 4e2b71cb85 Update Dockerfile 2025-09-26 05:42:14 +03:00
Egor e229a7ee65 Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
2025-09-26 05:37:28 +03:00
Egor abf1f53218 Add web admin API schemas and docs 2025-09-26 05:37:08 +03:00
Egor 2859219f80 Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
2025-09-26 05:18:55 +03:00
Egor e71b7f4533 Revert "Revert "Add administrative web API and database support"" 2025-09-26 05:18:41 +03:00
Egor 5ba652fab6 Merge pull request #478 from Fr1ngg/revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert "Add administrative web API and database support"
2025-09-26 05:18:32 +03:00
Egor 0a3913a2aa Revert "Add administrative web API and database support" 2025-09-26 05:18:21 +03:00
Egor 118ef8a29f Merge pull request #477 from Fr1ngg/bedolaga/add-api-for-bot-integration-vlrm04
Add administrative web API and database support
2025-09-26 05:17:33 +03:00
Egor b5f7e06605 Add administrative web API and database support 2025-09-26 05:16:58 +03:00
Egor d258d27406 Merge pull request #476 from Fr1ngg/revert-475-revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert "Revert 474 revert 473 bedolaga/add api for bot integration"
2025-09-26 05:16:43 +03:00
Egor 554fb28cdb Revert "Revert 474 revert 473 bedolaga/add api for bot integration" 2025-09-26 05:16:36 +03:00
Egor 808901c38b Merge pull request #475 from Fr1ngg/revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert 474 revert 473 bedolaga/add api for bot integration
2025-09-26 05:11:38 +03:00
Egor 68508d8a86 Revert "Revert "Add web admin API for bot management"" 2025-09-26 05:11:13 +03:00
Egor 0daf8d7691 Merge pull request #474 from Fr1ngg/revert-473-bedolaga/add-api-for-bot-integration
Revert "Add web admin API for bot management"
2025-09-26 05:10:12 +03:00
Egor 5aeb2038c5 Revert "Add web admin API for bot management" 2025-09-26 05:09:59 +03:00
Egor d3a1060117 Merge pull request #473 from Fr1ngg/bedolaga/add-api-for-bot-integration
Add web admin API for bot management
2025-09-26 05:09:54 +03:00
Egor 4d22e99076 feat: add web admin api server 2025-09-26 05:09:26 +03:00
Egor 879607982e Update Dockerfile 2025-09-26 00:23:25 +03:00
Egor 5482b31fcc Update docker-registry.yml 2025-09-26 00:23:14 +03:00
Egor f5be0e6807 Update docker-hub.yml 2025-09-26 00:22:56 +03:00
Egor c3a60871c9 Merge pull request #444 from Fr1ngg/dev2
Тест платежек в настройках конфигурации бота + перенос обязательных переменных ремны из env
2025-09-25 23:47:48 +03:00
Egor 2628444866 Merge pull request #443 from Fr1ngg/bedolaga/fix-invalid-payload-error-during-star-balance-test
Allow admin Stars test invoices
2025-09-25 23:44:30 +03:00
Egor c1a7360c66 Allow admin Stars test invoices 2025-09-25 23:44:14 +03:00
Egor b8c2bdc9e9 Merge pull request #442 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-331od6
Add payment testing actions to admin bot configuration
2025-09-25 23:39:58 +03:00
Egor 573735ffa9 Add payment testing actions to admin bot configuration 2025-09-25 23:39:41 +03:00
Egor 9b3a9883b0 Merge pull request #441 from Fr1ngg/revert-440-bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:39:25 +03:00
Egor 8cc54d376a Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:39:18 +03:00
Egor 26910dbbcc Merge pull request #440 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Add payment testing actions to admin bot configuration
2025-09-25 23:38:21 +03:00
Egor b131fea7d1 Add payment testing actions to admin bot configuration 2025-09-25 23:38:05 +03:00
Egor 84bc36d855 Merge pull request #439 from Fr1ngg/revert-438-bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:37:51 +03:00
Egor ea5bd22c28 Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:37:43 +03:00
Egor e9f1064d73 Merge pull request #438 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Add payment testing actions to admin bot configuration
2025-09-25 23:36:24 +03:00
Egor f3cba203dd Add payment testing actions to admin bot configuration 2025-09-25 23:36:08 +03:00
Egor 0ff15850b7 Merge pull request #437 from Fr1ngg/revert-436-bedolaga/add-payment-testing-options-in-admin-settings
Revert "Add payment testing tools to admin configuration"
2025-09-25 23:35:47 +03:00
Egor 383563c399 Revert "Add payment testing tools to admin configuration" 2025-09-25 23:35:38 +03:00
Egor 40bbb54f56 Merge pull request #436 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings
Add payment testing tools to admin configuration
2025-09-25 23:32:35 +03:00
Egor a09eafb9c1 Add admin payment testing tools 2025-09-25 23:32:16 +03:00
Egor 98b15d5c82 Merge pull request #435 from Fr1ngg/bedolaga/move-remnawave-settings-to-admin-config
Make RemnaWave settings configurable via admin panel
2025-09-25 23:16:49 +03:00
Egor b5cb547fe2 Make RemnaWave settings configurable via admin panel 2025-09-25 23:16:31 +03:00
Egor a7af39fb34 Merge pull request #434 from Fr1ngg/dev2
Динамическая конфигурация бота в бд)()()()(
2025-09-25 22:09:40 +03:00
Egor 02fa7de016 Merge pull request #433 from Fr1ngg/bedolaga/reorganize-bot-admin-sections-and-submenus
Reorganize bot configuration categories
2025-09-25 21:20:08 +03:00
Egor b14ee9a6a4 Reorganize bot configuration categories 2025-09-25 21:19:42 +03:00
Egor be2181c4cb Merge pull request #432 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues-zs6d4j
Fix admin config callbacks and add selectable options
2025-09-25 21:08:20 +03:00
Egor 6cd65812ac Fix admin config callbacks and add selectable options 2025-09-25 21:08:01 +03:00
Egor b282fd1525 Merge pull request #431 from Fr1ngg/revert-430-bedolaga/fix-bot-settings-editing-issues
Revert "Fix bot config callbacks and add selectable options"
2025-09-25 21:07:30 +03:00
Egor 7c38e672e3 Revert "Fix bot config callbacks and add selectable options" 2025-09-25 21:07:21 +03:00
Egor c2685476b0 Merge pull request #430 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues
Fix bot config callbacks and add selectable options
2025-09-25 20:52:24 +03:00
Egor a80e2176de Fix bot config callbacks and add selectable options 2025-09-25 20:52:09 +03:00
Egor bf4de82442 Merge pull request #429 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-d8gjen
Allow admins to update bot config via plain text input
2025-09-25 18:16:18 +03:00
Egor 22c736a41c Allow direct admin bot config updates via text input 2025-09-25 18:16:01 +03:00
Egor a1c5f9d987 Merge pull request #428 from Fr1ngg/revert-427-bedolaga/fix-bot-configuration-command-handling-9kzryd
Revert "Enable direct bot configuration edits from setting view"
2025-09-25 18:15:48 +03:00
Egor 1ca1df1403 Revert "Enable direct bot configuration edits from setting view" 2025-09-25 18:15:36 +03:00
Egor be3b554cde Merge pull request #427 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-9kzryd
Enable direct bot configuration edits from setting view
2025-09-25 18:14:28 +03:00
Egor efc8f04836 Enable direct bot configuration edits from setting view 2025-09-25 18:14:12 +03:00
Egor 1cf7138430 Merge pull request #426 from Fr1ngg/revert-425-bedolaga/fix-bot-configuration-command-handling
Revert "Allow direct editing of bot config settings"
2025-09-25 18:13:56 +03:00
Egor 64c8145f1a Revert "Allow direct editing of bot config settings" 2025-09-25 18:13:47 +03:00
Egor dc62ad927d Merge pull request #425 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling
Allow direct editing of bot config settings
2025-09-25 18:13:13 +03:00
Egor 61188d8a97 Allow direct editing of bot config settings 2025-09-25 18:12:57 +03:00
Egor f92d5e996e Merge pull request #424 from Fr1ngg/revert-419-revert-418-bedolaga/improve-bot-settings-layout
Revert "Revert "Improve bot configuration admin menu grouping""
2025-09-25 18:04:14 +03:00
Egor 817fb82476 Revert "Revert "Improve bot configuration admin menu grouping"" 2025-09-25 18:04:04 +03:00
Egor 9635f9464e Merge pull request #423 from Fr1ngg/revert-422-bedolaga/improve-bot-settings-layout-yxarpo
Revert "Improve admin bot configuration menu grouping and labels"
2025-09-25 18:03:56 +03:00
Egor 33e0d58e4d Revert "Improve admin bot configuration menu grouping and labels" 2025-09-25 18:03:48 +03:00
Egor 5fb951328d Merge pull request #422 from Fr1ngg/bedolaga/improve-bot-settings-layout-yxarpo
Improve admin bot configuration menu grouping and labels
2025-09-25 18:00:50 +03:00
Egor 5f5255d107 Improve admin bot configuration menu grouping and labels 2025-09-25 18:00:26 +03:00
Egor 4bf7f83f1e Merge pull request #421 from Fr1ngg/revert-420-bedolaga/improve-bot-settings-layout-z8kptq
Revert "Improve admin bot configuration navigation clarity"
2025-09-25 17:59:03 +03:00
Egor cce6d99a74 Revert "Improve admin bot configuration navigation clarity" 2025-09-25 17:58:53 +03:00
Egor 4060425ac4 Merge pull request #420 from Fr1ngg/bedolaga/improve-bot-settings-layout-z8kptq
Improve admin bot configuration navigation clarity
2025-09-25 17:50:21 +03:00
Egor 5023bec388 Fix imports for bot config grouping 2025-09-25 17:50:07 +03:00
Egor e0d90874bd Merge pull request #419 from Fr1ngg/revert-418-bedolaga/improve-bot-settings-layout
Revert "Improve bot configuration admin menu grouping"
2025-09-25 17:49:47 +03:00
Egor dd62ba1efd Revert "Improve bot configuration admin menu grouping" 2025-09-25 17:49:37 +03:00
Egor ceb8191b43 Merge pull request #418 from Fr1ngg/bedolaga/improve-bot-settings-layout
Improve bot configuration admin menu grouping
2025-09-25 17:46:25 +03:00
Egor da2e55bb25 Improve bot configuration admin menu grouping 2025-09-25 17:46:09 +03:00
Egor 8a3a990e39 Merge pull request #417 from Fr1ngg/revert-412-revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Revert "Add admin bot configuration management UI""
2025-09-25 17:11:32 +03:00
Egor e3cd2b0b92 Revert "Revert "Add admin bot configuration management UI"" 2025-09-25 17:11:15 +03:00
Egor 1777128590 Merge pull request #416 from Fr1ngg/revert-415-bedolaga/move-env-settings-to-admin-panel-zgsjdr
Revert "Add admin-configurable runtime settings"
2025-09-25 17:10:53 +03:00
Egor 04a5fe9d77 Revert "Add admin-configurable runtime settings" 2025-09-25 17:10:41 +03:00
Egor 595a4eb3a3 Merge pull request #415 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-zgsjdr
Add admin-configurable runtime settings
2025-09-25 17:04:51 +03:00
Egor ede9259347 Import json in app settings service 2025-09-25 17:04:34 +03:00
Egor d87556fd9f Merge pull request #414 from Fr1ngg/revert-413-bedolaga/move-env-settings-to-admin-panel-qubb3n
Revert "Add admin-managed bot configuration settings"
2025-09-25 16:58:03 +03:00
Egor 198ce23625 Revert "Add admin-managed bot configuration settings" 2025-09-25 16:57:55 +03:00
Egor 1be83b7b48 Merge pull request #413 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-qubb3n
Add admin-managed bot configuration settings
2025-09-25 16:56:27 +03:00
Egor 2b2b94936f Add admin-managed bot configuration settings 2025-09-25 16:55:45 +03:00
Egor 84df8f727e Merge pull request #412 from Fr1ngg/revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Add admin bot configuration management UI"
2025-09-25 16:42:39 +03:00
Egor 84aaaf6254 Revert "Add admin bot configuration management UI" 2025-09-25 16:42:30 +03:00
Egor ce867efaea Merge pull request #411 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel
Add admin bot configuration management UI
2025-09-25 16:39:57 +03:00
Egor 492d9f5170 Add admin UI for runtime bot configuration 2025-09-25 16:39:34 +03:00
Egor dcbbf7ba44 Merge pull request #410 from Fr1ngg/dev2
Деактивация тральной подписки в случае отписки от канала
2025-09-25 16:09:00 +03:00
Egor 04e7f818c3 Merge pull request #409 from Fr1ngg/bedolaga/improve-subscription-verification-service
Handle trial subscription status on channel membership changes
2025-09-25 16:05:47 +03:00
Egor 2713bdf08e Handle trial subscription status on channel membership changes 2025-09-25 16:05:30 +03:00
Egor b2bfaf0e81 Merge pull request #408 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-9k5u2q
Check trial channel subscription status
2025-09-25 15:54:29 +03:00
Egor 25a4be076b Check trial channel subscription status 2025-09-25 15:54:11 +03:00
Egor cbd721a10f Merge pull request #407 from Fr1ngg/revert-406-bedolaga/update-subscription-service-for-trial-users-hese0j
Revert "Add trial channel subscription monitoring"
2025-09-25 15:54:00 +03:00
Egor 3ff8cb5e61 Revert "Add trial channel subscription monitoring" 2025-09-25 15:53:51 +03:00
Egor 6f144f64b1 Merge pull request #406 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-hese0j
Add trial channel subscription monitoring
2025-09-25 15:51:31 +03:00
Egor 93200d1450 Add trial channel subscription monitoring 2025-09-25 15:51:13 +03:00
Egor 3f60533c50 Merge pull request #405 from Fr1ngg/revert-404-bedolaga/update-subscription-service-for-trial-users
Revert "Add trial subscription channel membership monitoring"
2025-09-25 15:50:59 +03:00
Egor 41ab0abac8 Revert "Add trial subscription channel membership monitoring" 2025-09-25 15:50:49 +03:00
Egor a5e4fb24ca Merge pull request #404 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users
Add trial subscription channel membership monitoring
2025-09-25 15:48:56 +03:00
Egor 6d2ce895e4 Add trial subscription channel membership monitoring 2025-09-25 15:48:40 +03:00
Egor f41a4197b6 Merge pull request #403 from Fr1ngg/dev2
Скидка на докупку доп услуг в промогруппе
2025-09-25 15:34:17 +03:00
Egor 200c728342 Merge pull request #402 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts-kvwc17
Improve promo group discount UI and toggle handling
2025-09-25 15:30:46 +03:00
Egor d8571c4f3e Improve promo group discount UI and toggle handling 2025-09-25 15:30:30 +03:00
Egor 233ceaf400 Merge pull request #401 from Fr1ngg/revert-400-bedolaga/refactor-activation-status-toggle-in-discounts
Revert "Improve promo group addon discount toggle UX"
2025-09-25 15:30:10 +03:00
Egor e8bca6926a Revert "Improve promo group addon discount toggle UX" 2025-09-25 15:30:00 +03:00
Egor 9e8aecae4d Merge pull request #400 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts
Improve promo group addon discount toggle UX
2025-09-25 15:27:18 +03:00
Egor e75b44695e Fix promo group discount summary formatting 2025-09-25 15:26:55 +03:00
Egor 18784b3ef7 Merge pull request #399 from Fr1ngg/bedolaga/fix-repeat-user-assignment-to-promo-group
Prevent repeated automatic promo group reassignment
2025-09-25 15:06:13 +03:00
Egor 98a241a301 Prevent repeated auto promo group assignments 2025-09-25 15:05:53 +03:00
Egor 70ee783a7a Merge pull request #398 from Fr1ngg/revert-397-bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Revert "Track last auto promo group to avoid duplicate assignments"
2025-09-25 14:56:19 +03:00
Egor 00cd0bb5b0 Revert "Track last auto promo group to avoid duplicate assignments" 2025-09-25 14:56:08 +03:00
Egor 02b7365ba3 Merge pull request #397 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Track last auto promo group to avoid duplicate assignments
2025-09-25 14:55:12 +03:00
Egor a6da530848 Track last auto promo group to avoid duplicate assignments 2025-09-25 14:54:46 +03:00
Egor f6e6af5e42 Merge pull request #395 from Fr1ngg/revert-394-bedolaga/fix-multiple-user-promo-group-assignment
Revert "Prevent auto promo group overrides after manual change"
2025-09-25 14:43:53 +03:00
Egor 6f834aeec0 Revert "Prevent auto promo group overrides after manual change" 2025-09-25 14:43:45 +03:00
Egor 8bfdde0d51 Merge pull request #394 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment
Prevent auto promo group overrides after manual change
2025-09-25 14:36:47 +03:00
Egor 137ac3c58c Respect manual promo group overrides 2025-09-25 14:36:26 +03:00
Egor d7a0d9f7a5 Merge pull request #393 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-shqdqq
Fix server management button for promo groups with multiple servers
2025-09-25 14:23:47 +03:00
Egor 9b477a700d Ensure server management button respects promo group servers 2025-09-25 14:23:32 +03:00
Egor 77a9dcf5af Merge pull request #392 from Fr1ngg/revert-391-bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Revert "Fix server management visibility for promo groups"
2025-09-25 14:22:45 +03:00
Egor 1c1f7ade78 Revert "Fix server management visibility for promo groups" 2025-09-25 14:22:36 +03:00
Egor 4aa593f8c6 Merge pull request #391 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Fix server management visibility for promo groups
2025-09-25 14:18:27 +03:00
Egor cefd39f7e9 Ensure server management button appears when multiple options exist 2025-09-25 14:18:11 +03:00
Egor cfeb8fd9a3 Merge pull request #390 from Fr1ngg/revert-389-bedolaga/fix-server-switch-function-in-promo-group
Revert "Fix server management visibility for promo groups"
2025-09-25 14:17:22 +03:00
Egor 241a053252 Revert "Fix server management visibility for promo groups" 2025-09-25 14:17:15 +03:00
Egor b53726276c Merge pull request #389 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group
Fix server management visibility for promo groups
2025-09-25 14:16:25 +03:00
Egor 7316e4aa48 Fix server management visibility for promo groups 2025-09-25 14:16:10 +03:00
Egor 889629e3c1 Merge pull request #388 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-1ead69
Fix addon discount calculations and server availability
2025-09-25 14:06:37 +03:00
Egor fbecf5bf7a Fix addon discount calculations and server availability 2025-09-25 14:06:18 +03:00
Egor faae87212e Merge pull request #387 from Fr1ngg/revert-381-bedolaga/fix-discount-logic-for-additional-services-9agrnv
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 14:06:04 +03:00
Egor bfa355e956 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 14:05:55 +03:00
Egor a2c0001f5a Merge pull request #381 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-9agrnv
Fix addon discounts for server/device/traffic purchases
2025-09-25 14:02:54 +03:00
Egor 5fa2339018 Merge pull request #386 from Fr1ngg/revert-385-bedolaga/fix-discount-logic-for-additional-services-hdjty5
Revert "Fix add-on discount handling across subscription flows"
2025-09-25 14:01:49 +03:00
Egor 9ba183b6a6 Revert "Fix add-on discount handling across subscription flows" 2025-09-25 14:01:40 +03:00
Egor 6d142a7f38 Merge pull request #385 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-hdjty5
Fix add-on discount handling across subscription flows
2025-09-25 14:00:59 +03:00
Egor 6f157e2923 Fix addon discount application and cleanup formatting 2025-09-25 14:00:41 +03:00
Egor dccb070376 Merge pull request #384 from Fr1ngg/revert-383-bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Revert "Fix addon discount calculations for extra services"
2025-09-25 13:55:14 +03:00
Egor f0b70c9424 Revert "Fix addon discount calculations for extra services" 2025-09-25 13:55:06 +03:00
Egor 9a847ec6b6 Merge pull request #383 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Fix addon discount calculations for extra services
2025-09-25 13:52:54 +03:00
Egor 4a94d6a966 Fix addon discounts for servers, devices, and traffic 2025-09-25 13:52:26 +03:00
Egor ae654452e4 Merge pull request #382 from Fr1ngg/revert-380-bedolaga/fix-discount-logic-for-additional-services
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 13:51:56 +03:00
Egor 2e71fd89c9 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 13:51:46 +03:00
Egor caf497ed28 Merge pull request #380 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services
Fix addon discounts for server/device/traffic purchases
2025-09-25 13:49:29 +03:00
Egor bb5dd7176b Fix addon discount calculations and availability checks 2025-09-25 13:49:12 +03:00
Egor ac83a65d3b Fix addon discount calculations and availability checks 2025-09-25 13:49:02 +03:00
Egor 147cbfa625 Merge pull request #379 from Fr1ngg/revert-372-revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Revert "Add addon discount toggle for promo groups""
2025-09-25 13:31:02 +03:00
Egor 497775c382 Revert "Revert "Add addon discount toggle for promo groups"" 2025-09-25 13:30:52 +03:00
Egor 3a9b8dc0f8 Merge pull request #378 from Fr1ngg/revert-377-bedolaga/add-discount-toggle-for-additional-services-wadtco
Revert "Add promo group add-on discount toggle"
2025-09-25 13:30:36 +03:00
Egor 0fd3452dd6 Revert "Add promo group add-on discount toggle" 2025-09-25 13:30:28 +03:00
Egor 1d5c277df0 Merge pull request #377 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-wadtco
Add promo group add-on discount toggle
2025-09-25 13:26:18 +03:00
Egor 6483efc099 Add promo group add-on discount toggle 2025-09-25 13:25:58 +03:00
Egor 149a9f599d Merge pull request #376 from Fr1ngg/revert-375-bedolaga/add-discount-toggle-for-additional-services-pqdau3
Revert "Add localization for promo group add-on discount toggle"
2025-09-25 13:24:29 +03:00
Egor 1bd55c4d80 Revert "Add localization for promo group add-on discount toggle" 2025-09-25 13:24:21 +03:00
Egor ef9abe8f91 Merge pull request #375 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-pqdau3
Add localization for promo group add-on discount toggle
2025-09-25 13:21:17 +03:00
Egor 461b820ef1 feat: localize promo group addon discount toggle 2025-09-25 13:18:40 +03:00
Egor 0535353124 Merge pull request #374 from Fr1ngg/revert-373-bedolaga/add-discount-toggle-for-additional-services-ghttwf
Revert "Add promo group add-on discount toggle"
2025-09-25 13:10:12 +03:00
Egor 19804607ac Revert "Add promo group add-on discount toggle" 2025-09-25 13:10:03 +03:00
Egor 272de0bd33 Merge pull request #373 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-ghttwf
Add promo group add-on discount toggle
2025-09-25 13:07:22 +03:00
Egor b53a54a237 feat: add toggle for promo group addon discounts 2025-09-25 13:07:03 +03:00
Egor e57c6d0ade Merge pull request #372 from Fr1ngg/revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Add addon discount toggle for promo groups"
2025-09-25 13:06:02 +03:00
Egor 18556f54d3 Revert "Add addon discount toggle for promo groups" 2025-09-25 13:05:54 +03:00
Egor a5c081887e Merge pull request #371 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Add addon discount toggle for promo groups
2025-09-25 13:03:53 +03:00
Egor 80efd4ab86 Add addon discount toggle for promo groups 2025-09-25 13:03:31 +03:00
Egor 3df541689b Merge pull request #370 from Fr1ngg/revert-369-bedolaga/add-discount-toggle-for-additional-services-tx3ab5
Revert "feat: add promo group addon discount toggle"
2025-09-25 12:51:30 +03:00
Egor 06c90dc3ca Revert "feat: add promo group addon discount toggle" 2025-09-25 12:51:22 +03:00
Egor d3599a6a0c Merge pull request #369 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-tx3ab5
feat: add promo group addon discount toggle
2025-09-25 12:46:21 +03:00
Egor afab5f2515 fix: import addon discount helper in traffic switch 2025-09-25 12:46:04 +03:00
Egor 37e7e74d28 Merge pull request #368 from Fr1ngg/revert-367-bedolaga/add-discount-toggle-for-additional-services
Revert "Add promo group toggle for add-on discounts"
2025-09-25 12:45:38 +03:00
Egor 25eff6929b Revert "Add promo group toggle for add-on discounts" 2025-09-25 12:45:24 +03:00
Egor 72888ff02d Merge pull request #367 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services
Add promo group toggle for add-on discounts
2025-09-25 12:43:47 +03:00
Egor 9c572a2771 Add promo group option for add-on discounts 2025-09-25 12:43:13 +03:00
Egor 0f6d6256a2 Merge pull request #366 from Fr1ngg/dev2
Режим happ_cryptolink
2025-09-25 12:12:48 +03:00
Egor 2191e63803 Merge pull request #365 from Fr1ngg/bedolaga/fix-missing-localization-keys
Add missing Happ subscription localization keys
2025-09-25 12:09:21 +03:00
Egor 43d75dc3ec Add missing Happ subscription localization keys 2025-09-25 12:09:04 +03:00
Egor 59cae2fde6 Update README.md 2025-09-25 11:59:48 +03:00
Egor 4f4c9e5426 Update .env.example 2025-09-25 11:59:11 +03:00
Egor df03f16a9d Merge pull request #364 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink-95birt
Add redirect button support for Happ cryptolink mode
2025-09-25 11:53:03 +03:00
Egor 57db753218 Support Happ redirect button for cryptolink mode 2025-09-25 11:52:47 +03:00
Egor 5d4a2b51ef Merge pull request #363 from Fr1ngg/revert-362-bedolaga/add-direct-connection-button-for-happ_cryptolink
Revert "Add optional Happ redirect button for cryptolink mode"
2025-09-25 11:52:20 +03:00
Egor 67c6185f8b Revert "Add optional Happ redirect button for cryptolink mode" 2025-09-25 11:52:11 +03:00
Egor e3d18f7a1d Merge pull request #362 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink
Add optional Happ redirect button for cryptolink mode
2025-09-25 11:51:19 +03:00
Egor 4bcefaa7b8 Add Happ redirect support for cryptolink mode 2025-09-25 11:50:30 +03:00
Egor 9d9551c072 Merge pull request #360 from Fr1ngg/revert-359-bedolaga/fix-unsupported-url-protocol-error-6s98ne
Revert "Handle unsupported Happ cryptolink URLs in Telegram"
2025-09-25 11:39:42 +03:00
Egor 4e6bc5ae71 Revert "Handle unsupported Happ cryptolink URLs in Telegram" 2025-09-25 11:39:34 +03:00
Egor c353c69180 Merge pull request #359 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-6s98ne
Handle unsupported Happ cryptolink URLs in Telegram
2025-09-25 11:38:34 +03:00
Egor 9c0e489f3b Fix Happ cryptolink button to avoid unsupported URL 2025-09-25 11:38:19 +03:00
Egor d4028a9d9f Merge pull request #358 from Fr1ngg/revert-357-bedolaga/fix-unsupported-url-protocol-error-8xd80s
Revert "Handle Happ cryptolink without unsupported Telegram URL"
2025-09-25 11:37:49 +03:00
Egor 25960c180f Revert "Handle Happ cryptolink without unsupported Telegram URL" 2025-09-25 11:37:40 +03:00
Egor 926bd0cc55 Merge pull request #357 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-8xd80s
Handle Happ cryptolink without unsupported Telegram URL
2025-09-25 11:36:45 +03:00
Egor 0d714b4dff Handle Happ cryptolink without unsupported Telegram URL 2025-09-25 11:36:26 +03:00
Egor f5ccc1f103 Merge pull request #356 from Fr1ngg/revert-355-bedolaga/fix-unsupported-url-protocol-error
Revert "Handle non-http Happ subscription links"
2025-09-25 11:36:16 +03:00
Egor 565e6d963b Revert "Handle non-http Happ subscription links" 2025-09-25 11:36:07 +03:00
Egor c057d1e3d7 Merge pull request #355 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error
Handle non-http Happ subscription links
2025-09-25 11:34:55 +03:00
Egor 10c3151d9e Handle non-http Happ subscription links 2025-09-25 11:34:37 +03:00
Egor cbf88226b4 Merge pull request #354 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen-qc79aw
Add Happ cryptolink keyboard with platform buttons
2025-09-25 11:23:22 +03:00
Egor 4ad53ca762 Add Happ cryptolink keyboard and update download options 2025-09-25 11:23:03 +03:00
Egor a2ace90898 Merge pull request #353 from Fr1ngg/revert-352-bedolaga/fix-issues-with-happ-connection-screen
Revert "Add Happ cryptolink keyboard and Mac download option"
2025-09-25 11:22:33 +03:00
Egor 814e38ef59 Revert "Add Happ cryptolink keyboard and Mac download option" 2025-09-25 11:22:23 +03:00
Egor 871e1ae675 Merge pull request #352 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen
Add Happ cryptolink keyboard and Mac download option
2025-09-25 11:21:33 +03:00
Egor 67c973124a Add Happ cryptolink keyboard and Mac download option 2025-09-25 11:21:18 +03:00
Egor 42cfc3745d Merge pull request #351 from Fr1ngg/bedolaga/fix-window-issue-in-happ_cryptolink-mode
Add Happ cryptolink subscription translations
2025-09-25 11:09:47 +03:00
Egor ccb4dfa267 Add Happ cryptolink subscription translations 2025-09-25 11:09:31 +03:00
Egor 8a9606db97 Merge pull request #350 from Fr1ngg/revert-343-revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs""
2025-09-25 11:04:22 +03:00
Egor c3846a998a Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs"" 2025-09-25 11:04:12 +03:00
Egor 0bcaab3c50 Merge pull request #349 from Fr1ngg/revert-348-bedolaga/fix-subscription-link-generation-issue-nwaeuv
Revert "Add Happ cryptoLink proxy support"
2025-09-25 11:03:58 +03:00
Egor 6dc5150804 Revert "Add Happ cryptoLink proxy support" 2025-09-25 11:03:49 +03:00
Egor 7bba18673a Merge pull request #348 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-nwaeuv
Add Happ cryptoLink proxy support
2025-09-25 11:02:46 +03:00
Egor de8853bd7c Add Happ cryptoLink proxy support 2025-09-25 11:02:27 +03:00
Egor 6e938b2693 Merge pull request #347 from Fr1ngg/revert-346-bedolaga/fix-subscription-link-generation-issue-xh6pew
Revert "Handle Happ crypto links in subscription flows"
2025-09-25 11:01:50 +03:00
Egor cb41f9263f Revert "Handle Happ crypto links in subscription flows" 2025-09-25 11:01:41 +03:00
Egor fe3f837a58 Merge pull request #346 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-xh6pew
Handle Happ crypto links in subscription flows
2025-09-25 11:01:09 +03:00
Egor 65374e38b3 Handle Happ crypto links in subscription flows 2025-09-25 11:00:52 +03:00
Egor 24fd4ef842 Merge pull request #345 from Fr1ngg/revert-344-bedolaga/fix-subscription-link-generation-issue-ls1p2r
Revert "Hide Happ cryptolinks behind connect button"
2025-09-25 11:00:34 +03:00
Egor 5682971722 Revert "Hide Happ cryptolinks behind connect button" 2025-09-25 11:00:27 +03:00
Egor c286c44041 Merge pull request #344 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-ls1p2r
Hide Happ cryptolinks behind connect button
2025-09-25 10:59:21 +03:00
Egor 1dc3013376 Hide Happ cryptolinks behind connect button 2025-09-25 10:59:03 +03:00
Egor ea61734f3a Merge pull request #343 from Fr1ngg/revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Handle Happ cryptolink buttons without unsupported URLs"
2025-09-25 10:58:50 +03:00
Egor ec521b84e7 Revert "Handle Happ cryptolink buttons without unsupported URLs" 2025-09-25 10:58:24 +03:00
Egor 62189f8642 Merge pull request #342 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue
Handle Happ cryptolink buttons without unsupported URLs
2025-09-25 10:50:13 +03:00
Egor a8e1085884 Handle Happ cryptolink buttons without unsupported URLs 2025-09-25 10:49:54 +03:00
Egor 2bd76d118a Merge pull request #341 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode-a0mn2q
Document happ cryptolink mode in env example
2025-09-25 10:25:23 +03:00
Egor b258bd95bd Document happ cryptolink mode in env example 2025-09-25 10:25:06 +03:00
Egor ab566986c7 Merge pull request #340 from Fr1ngg/revert-339-bedolaga/add-happ_cryptolink-output-mode
Revert "Add Happ crypto link connection mode with download prompts"
2025-09-25 08:51:42 +03:00
Egor 033230f73e Revert "Add Happ crypto link connection mode with download prompts" 2025-09-25 08:51:34 +03:00
Egor 063d77928c Merge pull request #339 from Fr1ngg/bedolaga/add-happ_cryptolink-output-mode
Add Happ crypto link connection mode with download prompts
2025-09-25 08:50:42 +03:00
Egor ff71f4b637 Add Happ crypto link mode and download flow 2025-09-25 08:50:22 +03:00
Egor 88f4b2b2fa Merge pull request #338 from Fr1ngg/revert-337-bedolaga/add-happ_cryptolink-subscription-mode
Revert "Add Happ cryptoLink connect mode with download flow"
2025-09-25 04:33:43 +03:00
Egor b64bda64dc Revert "Add Happ cryptoLink connect mode with download flow" 2025-09-25 04:33:28 +03:00
Egor dfeea949dd Merge pull request #337 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode
Add Happ cryptoLink connect mode with download flow
2025-09-25 04:32:05 +03:00
Egor 54d798f358 Add Happ cryptoLink mode and download support 2025-09-25 04:31:44 +03:00
Egor 4546450587 Merge pull request #336 from Fr1ngg/revert-333-bedolaga/add-happ_cryptolink-subscription-output-mode
Revert "feat: add Happ crypto link mode and download flow"
2025-09-25 04:12:53 +03:00
Egor ec6b6e6b09 Revert "feat: add Happ crypto link mode and download flow" 2025-09-25 04:12:42 +03:00
Egor cb5d4df4f0 Merge pull request #335 from Fr1ngg/revert-334-bedolaga/implement-universal-migration-for-subscriptions
Revert "Add happ_crypto_link column handling to universal migration"
2025-09-25 04:12:34 +03:00
Egor 1a060759f9 Revert "Add happ_crypto_link column handling to universal migration" 2025-09-25 04:12:24 +03:00
Egor 44fc2fd771 Merge pull request #334 from Fr1ngg/bedolaga/implement-universal-migration-for-subscriptions
Add happ_crypto_link column handling to universal migration
2025-09-25 04:09:09 +03:00
Egor 92dbf3269b Add happ_crypto_link column handling to universal migration 2025-09-25 04:08:49 +03:00
Egor c6b4de05f3 Merge pull request #333 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-output-mode
feat: add Happ crypto link mode and download flow
2025-09-25 04:00:07 +03:00
Egor ae3c34c2fe chore: tidy migration imports 2025-09-25 03:59:03 +03:00
Egor 34c19ff041 Update Dockerfile 2025-09-24 22:53:55 +03:00
Egor 80c9857475 Update docker-registry.yml 2025-09-24 22:53:40 +03:00
Egor c147a6c010 Update docker-hub.yml 2025-09-24 22:53:21 +03:00
Egor 88a720543e Merge pull request #320 from Fr1ngg/2zxk2o-bedolaga/modify-welcome-message-for-new-users
Skip trial welcome message for campaign newcomers
2025-09-24 22:26:52 +03:00
Egor e780e3731d Skip trial welcome message for campaign newcomers 2025-09-24 22:26:14 +03:00
Egor 853cda603e Merge pull request #319 from Fr1ngg/bedolaga-ovk4pz
Add configurable MulenPay payment limits
2025-09-24 21:51:47 +03:00
Egor 5f8287f389 Add configurable MulenPay payment limits 2025-09-24 21:51:19 +03:00
Egor 8fa7cbc060 Merge pull request #318 from Fr1ngg/165bmh-bedolaga/fix-mulenpay-status-check-error
Handle long MulenPay status messages
2025-09-24 21:40:50 +03:00
Egor fe1b0fcac3 Handle long MulenPay status messages 2025-09-24 21:40:22 +03:00
Egor 6f9a1a907d Merge pull request #317 from Fr1ngg/dev5
Dev5
2025-09-24 21:12:08 +03:00
Egor 82bb72fc82 Merge pull request #316 from Fr1ngg/yd19lw-bedolaga/fix-paid_at-column-missing-error
Ensure mulenpay schema includes paid_at column
2025-09-24 21:09:46 +03:00
Egor 6812ff84b9 Ensure mulenpay schema includes paid_at column 2025-09-24 21:08:57 +03:00
Egor 3096985466 Merge pull request #315 from Fr1ngg/dev5
Dev5
2025-09-24 20:56:56 +03:00
Egor ce9e6d4379 Merge pull request #314 from Fr1ngg/ospgos-bedolaga/fix-missing-column-mulen_payment_id
Ensure mulenpay schema has mulen_payment_id column
2025-09-24 20:54:26 +03:00
Egor 1f376d4b5d Ensure mulenpay schema has mulen_payment_id column 2025-09-24 20:54:09 +03:00
Egor cfb830a694 Merge pull request #313 from Fr1ngg/dyb67m-bedolaga/split-ddl-into-separate-statements
Split server squad promo group DDL statements
2025-09-24 18:40:37 +03:00
Egor c072b4a932 Split promo group table creation statements 2025-09-24 18:40:24 +03:00
Egor 9fe67eaeb2 Merge pull request #312 from Fr1ngg/dev5
Фикс приветственного сообщения при переходе по ссылке рк
2025-09-24 18:05:20 +03:00
Egor 389501239c Merge pull request #311 from Fr1ngg/3xfosq-bedolaga/fix-bot-crash-with-welcome-message
Handle missing subscription URL in main menu keyboard
2025-09-24 18:00:26 +03:00
Egor 9512813b58 Handle missing subscription URL in main menu keyboard 2025-09-24 18:00:09 +03:00
Egor bebf636b4e Merge pull request #310 from Fr1ngg/dev5
Активация/деактивация серверов для определенных промогрупп
2025-09-24 17:34:55 +03:00
Egor 1db1acf44d Merge pull request #309 from Fr1ngg/3xafxc-bedolaga/add-country-settings-configuration
Add promo group access control for servers
2025-09-24 17:24:43 +03:00
Egor be58a1c04e Add promo group access control for servers 2025-09-24 17:24:24 +03:00
Egor fbdc98f8bd Merge pull request #308 from Fr1ngg/revert-307-zqf0tt-bedolaga/add-country-settings-configuration
Revert "Restrict admin server selection by promo group"
2025-09-24 17:23:27 +03:00
Egor ba79cf292f Revert "Restrict admin server selection by promo group" 2025-09-24 17:23:19 +03:00
Egor 93b870a139 Merge pull request #307 from Fr1ngg/zqf0tt-bedolaga/add-country-settings-configuration
Restrict admin server selection by promo group
2025-09-24 17:22:42 +03:00
Egor cfa50739d8 Restrict admin country management by promo groups 2025-09-24 17:22:12 +03:00
Egor 86eedf1985 Merge pull request #306 from Fr1ngg/revert-305-nuuguw-bedolaga/add-country-settings-configuration
Revert "Restrict server access by promo group"
2025-09-24 17:21:46 +03:00
Egor 1bc7a1c143 Revert "Restrict server access by promo group" 2025-09-24 17:21:37 +03:00
Egor d6241d31f8 Merge pull request #305 from Fr1ngg/nuuguw-bedolaga/add-country-settings-configuration
Restrict server access by promo group
2025-09-24 17:18:41 +03:00
Egor a6465b0b0b Tweak server edit menu promo group display 2025-09-24 17:18:12 +03:00
Egor d1b28b669f Merge pull request #304 from Fr1ngg/dev5
Расширение уведомлений + настройка
2025-09-24 09:51:45 +03:00
Egor 68452974f1 Merge pull request #303 from Fr1ngg/0qkjeg-bedolaga/add-logo-to-monitoring-notifications
Add logo support to monitoring notifications
2025-09-24 09:48:54 +03:00
Egor 09810d0b6c Add logo support to monitoring notifications 2025-09-24 09:48:35 +03:00
Egor 7358c4e3fa Merge pull request #302 from Fr1ngg/93hmyg-bedolaga/add-test-notification-sending-in-admin-panel
Add notification previews in monitoring settings
2025-09-24 09:36:29 +03:00
Egor 7380f4a15e Add notification previews in monitoring settings 2025-09-24 09:36:12 +03:00
Egor dbb11957ae Merge pull request #301 from Fr1ngg/bzdqp9-bedolaga/add-back-button-after-settings-update
Add back button after updating notification settings
2025-09-24 09:28:28 +03:00
Egor 151b72baff Add back button after updating notification settings 2025-09-24 09:28:14 +03:00
Egor d716e261b4 Merge pull request #300 from Fr1ngg/3boc4w-bedolaga/fix-bad-request-error-in-authmiddleware
Handle editing notification message without text
2025-09-24 09:20:52 +03:00
Egor 7ba21d058c Handle editing notification message without text 2025-09-24 09:20:27 +03:00
Egor bbc64f685e Merge pull request #299 from Fr1ngg/revert-298-07mey9-bedolaga/fix-bad-request-error-in-authmiddleware
Revert "Handle Telegram edit errors for admin notification settings"
2025-09-24 09:20:17 +03:00
Egor 2c28fda5e3 Revert "Handle Telegram edit errors for admin notification settings" 2025-09-24 09:20:08 +03:00
Egor 5a9ae02f61 Merge pull request #298 from Fr1ngg/07mey9-bedolaga/fix-bad-request-error-in-authmiddleware
Handle Telegram edit errors for admin notification settings
2025-09-24 09:18:33 +03:00
Egor da6d971de4 Handle empty text edit errors for admin notification settings 2025-09-24 09:18:11 +03:00
Egor b4e7dfd099 Merge pull request #297 from Fr1ngg/revert-296-cy9wh5-bedolaga/fix-bad-request-error-in-authmiddleware
Revert "Handle missing text when updating notification settings"
2025-09-24 09:17:56 +03:00
Egor 39577b31bd Revert "Handle missing text when updating notification settings" 2025-09-24 09:17:48 +03:00
Egor 6b039c1cb0 Merge pull request #296 from Fr1ngg/cy9wh5-bedolaga/fix-bad-request-error-in-authmiddleware
Handle missing text when updating notification settings
2025-09-24 09:16:12 +03:00
Egor 2cbff15837 Handle missing text when updating notification settings 2025-09-24 09:15:47 +03:00
Egor 921a36c94d Merge pull request #295 from Fr1ngg/5yuyol-bedolaga/fix-subscription-check-after-discount-edit
Allow admins to bypass channel subscription check
2025-09-24 09:09:26 +03:00
Egor 14412871bf Allow admins to bypass channel subscription check 2025-09-24 09:09:02 +03:00
Egor afed43571d Merge pull request #294 from Fr1ngg/6xcvey-bedolaga/fix-notification-editing-error
Handle business connection id when editing monitoring notifications
2025-09-24 09:02:49 +03:00
Egor 773dc11888 Fix business connection id handling in notification editor 2025-09-24 09:01:15 +03:00
Egor e0f6dd4097 Merge pull request #293 from Fr1ngg/revert-292-8tc2kv-bedolaga/fix-notification-editing-error
Revert "Fix notification edit message call"
2025-09-24 09:00:58 +03:00
Egor 711ad05983 Revert "Fix notification edit message call" 2025-09-24 09:00:47 +03:00
Egor 2d0a9f334b Merge pull request #292 from Fr1ngg/8tc2kv-bedolaga/fix-notification-editing-error
Fix notification edit message call
2025-09-24 08:58:48 +03:00
Egor 97996048ae Fix notification edit message call 2025-09-24 08:58:32 +03:00
Egor 66bd7f758e Merge pull request #291 from Fr1ngg/wosx55-bedolaga/fix-notification-settings-input-error
Fix admin notification value edits triggering unknown command response
2025-09-24 08:52:48 +03:00
Egor c51158bad1 Fix notification edit input blocked by unknown handler 2025-09-24 08:52:32 +03:00
Egor ccf881c054 Merge pull request #290 from Fr1ngg/revert-289-9mj3ph-bedolaga/fix-notification-settings-input-error
Revert "Fix admin notification editing state filter"
2025-09-24 08:51:53 +03:00
Egor 1f780a0ae8 Revert "Fix admin notification editing state filter" 2025-09-24 08:51:44 +03:00
Egor 17ac08af8f Merge pull request #289 from Fr1ngg/9mj3ph-bedolaga/fix-notification-settings-input-error
Fix admin notification editing state filter
2025-09-24 08:51:02 +03:00
Egor 214488cf80 Fix admin notification editing state filter 2025-09-24 08:49:58 +03:00
Egor c6c842adc9 Merge pull request #288 from Fr1ngg/revert-287-akwh56-bedolaga/fix-notification-settings-input-error
Revert "Limit unknown message handler to default state"
2025-09-24 08:49:46 +03:00
Egor 4cb23dc2b1 Revert "Limit unknown message handler to default state" 2025-09-24 08:49:36 +03:00
Egor 7c9285061a Merge pull request #287 from Fr1ngg/akwh56-bedolaga/fix-notification-settings-input-error
Limit unknown message handler to default state
2025-09-24 08:47:52 +03:00
Egor 0edfa973e4 Limit unknown message handler to default state 2025-09-24 08:47:33 +03:00
Egor 0be20d514e Merge pull request #286 from Fr1ngg/xm0o19-bedolaga/fix-subscription-reminder-errors
Handle unreachable Telegram users in monitoring notifications
2025-09-24 08:40:21 +03:00
Egor e414ae40d4 Handle unreachable users in monitoring notifications 2025-09-24 08:40:04 +03:00
Egor b54b2a2dc2 Merge pull request #285 from Fr1ngg/33x8fg-bedolaga/add-automated-user-notifications
Add monitoring settings shortcut to admin settings submenu
2025-09-24 08:29:54 +03:00
Egor f545c0d7ad Add monitoring settings shortcut to admin settings submenu 2025-09-24 08:29:39 +03:00
Egor 18142201bd Merge pull request #284 from Fr1ngg/revert-283-wmf5on-bedolaga/add-automated-user-notifications
Revert "Implement subscription follow-up notifications and admin configuration"
2025-09-24 08:29:10 +03:00
Egor 50320ba868 Revert "Implement subscription follow-up notifications and admin configuration" 2025-09-24 08:29:00 +03:00
Egor 12b05a7d0a Merge pull request #283 from Fr1ngg/wmf5on-bedolaga/add-automated-user-notifications
Implement subscription follow-up notifications and admin configuration
2025-09-24 08:27:17 +03:00
Egor 7e19a5a1ff Add monitoring settings entry to admin menus 2025-09-24 08:26:55 +03:00
Egor 096e3a1629 Merge pull request #282 from Fr1ngg/revert-281-qmxipx-bedolaga/add-automated-user-notifications
Revert "Add notification scheduling and admin controls"
2025-09-24 08:21:30 +03:00
Egor 51a5c4a65e Revert "Add notification scheduling and admin controls" 2025-09-24 08:21:22 +03:00
Egor e5472b4c67 Merge pull request #281 from Fr1ngg/qmxipx-bedolaga/add-automated-user-notifications
Add notification scheduling and admin controls
2025-09-24 08:15:41 +03:00
Egor ebf4c07142 Add notification scheduling and admin controls 2025-09-24 08:15:17 +03:00
Egor 4ab8aac38f Merge pull request #280 from Fr1ngg/revert-279-74ifk5-bedolaga/add-automated-user-notifications
Revert "Implement subscription follow-up notifications and admin configuration"
2025-09-24 08:14:42 +03:00
Egor 8d32b1463a Revert "Implement subscription follow-up notifications and admin configuration" 2025-09-24 08:14:33 +03:00
Egor ec09142445 Merge pull request #279 from Fr1ngg/74ifk5-bedolaga/add-automated-user-notifications
Implement subscription follow-up notifications and admin configuration
2025-09-24 08:11:08 +03:00
Egor 1e3c713c95 Add subscription follow-up notifications and admin controls 2025-09-24 08:10:50 +03:00
Egor 1747170ec3 Merge pull request #278 from Fr1ngg/revert-277-rbs1li-bedolaga/add-automated-user-notifications
Revert "Add automated inactivity and post-expiration notifications"
2025-09-24 08:10:19 +03:00
Egor 1e436189b7 Revert "Add automated inactivity and post-expiration notifications" 2025-09-24 08:10:11 +03:00
Egor a247485a38 Merge pull request #277 from Fr1ngg/rbs1li-bedolaga/add-automated-user-notifications
Add automated inactivity and post-expiration notifications
2025-09-24 08:06:20 +03:00
Egor b352cb2701 Add automated inactivity and post-expiration notifications 2025-09-24 08:05:50 +03:00
Egor b0b2c3f8d8 Merge pull request #276 from Fr1ngg/dev5
Отчеты (В топики тоже)
2025-09-24 07:40:07 +03:00
Egor aba98031f7 Merge pull request #275 from Fr1ngg/00n617-bedolaga/add-close-button-for-reports
Add close action for admin reports and move menu entry
2025-09-24 07:37:36 +03:00
Egor 66ec241c48 Add close action for admin reports and move menu entry 2025-09-24 07:37:21 +03:00
Egor 2ee80b41e0 Merge pull request #274 from Fr1ngg/cozprs-bedolaga/add-daily-summary-report-feature
Add scheduled admin reports and manual sending
2025-09-24 07:26:34 +03:00
Egor c1aa08b266 Add scheduled admin reports and manual sending 2025-09-24 07:26:18 +03:00
Egor 7b7bdc3d88 Merge pull request #273 from Fr1ngg/revert-272-4r6xny-bedolaga/add-daily-summary-report-feature
Revert "Add automated admin reports"
2025-09-24 07:25:53 +03:00
Egor 71588fda63 Revert "Add automated admin reports" 2025-09-24 07:25:44 +03:00
Egor d4b1b20016 Merge pull request #272 from Fr1ngg/4r6xny-bedolaga/add-daily-summary-report-feature
Add automated admin reports
2025-09-24 07:24:32 +03:00
Egor 952face2ae Add automated admin reports 2025-09-24 07:24:10 +03:00
Egor fa8c7bd460 Merge pull request #271 from Fr1ngg/dev5
Изменение в докупке доп услуг, если средств не хватает - предлагает пополнить баланс и способы
2025-09-24 06:53:22 +03:00
Egor de8c506618 Merge pull request #270 from Fr1ngg/6eudmz-bedolaga/update-insufficient-funds-notification
Enhance add-on top-up flow with preset amounts
2025-09-24 06:39:22 +03:00
Egor 18a7d179f8 Enhance add-on top-up flow with preset amounts 2025-09-24 06:39:06 +03:00
Egor 56c6e97874 Merge pull request #269 from Fr1ngg/dev5
Dev5
2025-09-24 06:29:03 +03:00
Egor b574c01264 Merge pull request #268 from Fr1ngg/hmojzu-bedolaga/add-promo-group-assignment-on-completion
Run promo group assignment after completing transactions
2025-09-24 06:28:31 +03:00
Egor 6d64ba8ffd Run promo group assignment on transaction completion 2025-09-24 06:28:11 +03:00
Egor 6d0a85b7be Merge pull request #267 from Fr1ngg/dev5
Dev5
2025-09-24 06:26:01 +03:00
Egor 33d5da9530 Merge pull request #266 from Fr1ngg/mbqvvx-bedolaga/fix-auto-assignment-logic-for-promo-groups
Allow auto promo reassignment for upgrades
2025-09-24 06:25:04 +03:00
Egor 2a92004fbf Allow auto promo reassignment for upgrades 2025-09-24 06:24:38 +03:00
Egor 97ec8594d9 Merge pull request #265 from Fr1ngg/dev5
Автовыдача промогрупп + доработанное редактирование промопогрупп
2025-09-24 06:17:41 +03:00
Egor 713a2cd924 Merge pull request #264 from Fr1ngg/vie7rm-bedolaga/add-auto-allocation-for-promo-groups
Add promo group auto assignment and improved editing
2025-09-24 06:13:07 +03:00
Egor 0d4fd3d6e9 Add promo group auto assignment and improved editing 2025-09-24 06:12:45 +03:00
Egor 7cc9d8dc18 Merge pull request #263 from Fr1ngg/revert-262-nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups
Revert "Add promo group auto assignment after top-ups"
2025-09-24 05:49:51 +03:00
Egor f896d59609 Revert "Add promo group auto assignment after top-ups" 2025-09-24 05:49:30 +03:00
Egor 707858900a Merge pull request #262 from Fr1ngg/nus2xf-bedolaga/add-auto-assignment-feature-for-promo-groups
Add promo group auto assignment after top-ups
2025-09-24 05:43:06 +03:00
Egor de0b361062 Add auto promo group assignment on top-up 2025-09-24 05:42:49 +03:00
Egor 111b038fec Merge pull request #261 from Fr1ngg/dev5
Скидка на дни периода для промогрупп
2025-09-24 05:12:18 +03:00
Egor a2f90d5de0 Merge pull request #260 from Fr1ngg/bedolaga-3h7w7d
feat: add period-based discounts for promo groups
2025-09-24 05:04:36 +03:00
Egor 084363b3d6 feat: add period-based discounts for promo groups 2025-09-24 05:04:19 +03:00
Egor 9c0dfa7aa3 Merge pull request #259 from Fr1ngg/dev5
external_link_miniapp для мониторинга серверов - (открывает ссылку во встроенном мини-приложении Telegram)
2025-09-24 04:29:09 +03:00
Egor c4bf18cf0f Merge pull request #258 from Fr1ngg/p1psk9-bedolaga/add-external_link_miniapp-to-server-monitoring-menu
Add mini app server status mode
2025-09-24 04:25:11 +03:00
Egor 9663eb4931 Add mini app server status mode 2025-09-24 04:24:50 +03:00
Egor da846df924 Merge pull request #257 from Fr1ngg/dev5
Фикс удаления юзера + бекапы/восстановление дополненное
2025-09-24 04:12:47 +03:00
Egor a5bb663f9e Merge pull request #256 from Fr1ngg/p280hy-bedolaga/fix-user-deletion-function
Fix admin user deletion and improve backups
2025-09-24 04:04:40 +03:00
Egor b234905860 Fix admin user deletion and improve backups 2025-09-24 04:04:20 +03:00
Egor f658cfcd03 Update .env.example 2025-09-24 03:55:54 +03:00
Egor 56297cde91 Merge pull request #255 from Fr1ngg/dev5
Dev5
2025-09-24 03:51:45 +03:00
Egor f51c74d09e Merge pull request #254 from Fr1ngg/xvtjb1-bedolaga/fix-database-session-management-in-process
Ensure Pal24 webhook closes DB session
2025-09-24 03:49:22 +03:00
Egor b06da932cd Ensure Pal24 webhook closes DB session 2025-09-24 03:49:01 +03:00
Egor f869b73bf5 Merge pull request #253 from Fr1ngg/o0107y-bedolaga/fix-premature-db-session-closure-in-webhook
Fix Pal24 webhook DB session handling
2025-09-24 03:32:10 +03:00
Egor 4f40cb8862 Fix Pal24 webhook DB session handling 2025-09-24 03:31:46 +03:00
Egor 161e9b575c Merge pull request #252 from Fr1ngg/dev5
PayPalych
2025-09-24 03:24:24 +03:00
Egor 42ccd3f3c1 Merge pull request #251 from Fr1ngg/flrwap-bedolaga/refactor-paypal-balance-top-up-process
Fix PayPalych webhook to reuse main event loop
2025-09-24 03:10:32 +03:00
Egor 5dcb37032e Fix PayPalych webhook to reuse main event loop 2025-09-24 03:08:33 +03:00
Egor e42bf2de73 Merge pull request #250 from Fr1ngg/jvzqka-bedolaga/integrate-paypalych-api-into-telegram-bot
Add PayPalych payment integration
2025-09-24 02:57:43 +03:00
Egor 4225404673 Add PayPalych payment integration 2025-09-24 02:56:51 +03:00
Egor cbfa388568 Merge pull request #249 from Fr1ngg/dev5
MulenPay
2025-09-24 01:44:42 +03:00
Egor d471a84125 Update README.md 2025-09-24 01:40:21 +03:00
Egor 2cc18422fd Update .env.example 2025-09-24 01:35:56 +03:00
Egor 4ba60dab16 Merge pull request #248 from Fr1ngg/yicik4-bedolaga/add-hmac-verification-to-mulen-pay-webhook
Secure Mulen Pay webhook with signature validation
2025-09-24 01:30:06 +03:00
Egor 588e4acbd3 Secure Mulen Pay webhook with signature validation 2025-09-24 01:29:42 +03:00
Egor 3a67bbe298 Merge pull request #247 from Fr1ngg/qj2j17-bedolaga/integrate-mulenpay-for-balance-top-ups
Add Mulen Pay integration
2025-09-24 01:21:08 +03:00
Egor eb730ecd6e Add Mulen Pay integration for balance top-ups 2025-09-24 01:14:15 +03:00
Egor 112da2c873 Merge pull request #240 from Fr1ngg/dev5
Dev5
2025-09-23 23:42:26 +03:00
Egor 37ff3c0d17 Merge pull request #239 from Fr1ngg/y717db-bedolaga/fix-stars-balance-top-up-issue
Fix Telegram Stars payments handler priority
2025-09-23 23:40:33 +03:00
Egor 1451bfac12 Fix Telegram Stars payments handler priority 2025-09-23 23:40:17 +03:00
Egor c445e24ff6 Merge pull request #238 from Fr1ngg/kko1hi-bedolaga/fix-duplicate-imports-in-pricing_utils.py
Fix duplicate imports and avoid cyclic referral code dependency
2025-09-23 23:24:49 +03:00
Egor bb5af66656 Fix duplicate import and referral code generation 2025-09-23 23:24:24 +03:00
Egor 85e8b7fc24 Merge pull request #237 from Fr1ngg/pe7u0s-bedolaga/fix-notification-status-for-first-deposit
Fix top-up status detection in admin notifications
2025-09-23 23:19:31 +03:00
Egor c4802fe442 Fix top-up status detection in admin notifications 2025-09-23 23:19:13 +03:00
Egor 91ee1d96b3 Merge pull request #236 from Fr1ngg/1mkicc-bedolaga/remove-my-subscription-button-on-trial-activation
Remove redundant subscription menu button after activation
2025-09-23 23:17:11 +03:00
Egor 19fe40b172 Remove subscription button from post-purchase prompts 2025-09-23 23:16:51 +03:00
Egor 355ac7a0fa Merge pull request #233 from PEDZEO/my-fix
Система модерации и улучшения тикетов
2025-09-23 23:03:15 +03:00
Egor 361a8a6297 Merge pull request #235 from Fr1ngg/dev5
Фикс
2025-09-23 23:02:14 +03:00
Egor 0b68aa85c7 Merge pull request #234 from Fr1ngg/bedolaga-gim3jh
Show checkout resume button for expired subscriptions
2025-09-23 22:54:51 +03:00
Egor 3105b811d4 Show checkout resume button for expired subscriptions 2025-09-23 22:54:33 +03:00
PEDZEO b438b6df61 Merge branch 'main' into my-fix 2025-09-23 16:02:16 +03:00
PEDZEO 15bda0560a feat: модерация, обновленное меню тикетов, SLA и управление уведомлениями 2025-09-23 15:39:16 +03:00
Egor 3433ea6aef Merge pull request #231 from Fr1ngg/revert-230-dev5
Revert "Dev5"
2025-09-23 05:53:29 +03:00
Egor 745ebf64ef Revert "Dev5" 2025-09-23 05:53:13 +03:00
Egor 80ff06ef24 Merge pull request #230 from Fr1ngg/dev5
Dev5
2025-09-23 05:42:15 +03:00
Egor f270ac9f9d Merge pull request #229 from Fr1ngg/ze53nx-bedolaga/restrict-bot-responses-in-topics
Ignore topic messages
2025-09-23 05:39:22 +03:00
Egor 7d5d89302b Ignore topic messages 2025-09-23 05:38:59 +03:00
Egor 155482779e Update Dockerfile 2025-09-23 04:34:08 +03:00
Egor d1bacf140c Update docker-registry.yml 2025-09-23 04:33:57 +03:00
Egor 55a38a5e05 Update docker-hub.yml 2025-09-23 04:33:36 +03:00
Egor f21d451c13 Merge pull request #224 from Fr1ngg/dev5
Доп фильтр в рассылках по нулевому используемому трафику
2025-09-23 04:19:19 +03:00
Egor 29c3e00c27 Merge pull request #223 from Fr1ngg/revert-218-vrfwqu-bedolaga/add-auto-apply-user-groups-feature
Revert "feat: support auto promo group assignment by spending"
2025-09-23 04:17:26 +03:00
Egor 44d249ad5b Revert "feat: support auto promo group assignment by spending" 2025-09-23 04:17:17 +03:00
Egor 4333412169 Merge pull request #222 from Fr1ngg/revert-221-tkt9ga-bedolaga/add-button-selection-for-promo-group-creation
Revert "Add auto-assign controls and prevent repeated promo group reassignment"
2025-09-23 04:16:55 +03:00
Egor 50c7eaa29a Revert "Add auto-assign controls and prevent repeated promo group reassignment" 2025-09-23 04:16:47 +03:00
Egor b2e168dbec Merge pull request #221 from Fr1ngg/tkt9ga-bedolaga/add-button-selection-for-promo-group-creation
Add auto-assign controls and prevent repeated promo group reassignment
2025-09-23 04:14:35 +03:00
Egor a1d4e9c7b3 Add auto-assign controls and prevent repeated promo group reassignment 2025-09-23 04:14:15 +03:00
Egor 82d1b48314 Merge pull request #220 from Fr1ngg/revert-219-6eo1zk-bedolaga/add-button-selection-for-promo-group-creation
Revert "Add single-use promo group auto assignment"
2025-09-23 04:13:18 +03:00
Egor eaffaa11b7 Revert "Add single-use promo group auto assignment" 2025-09-23 04:13:09 +03:00
Egor 2c13995625 Merge pull request #219 from Fr1ngg/6eo1zk-bedolaga/add-button-selection-for-promo-group-creation
Add single-use promo group auto assignment
2025-09-23 04:12:08 +03:00
Egor 7b0892e379 Add single-use promo group auto assignment 2025-09-23 04:11:40 +03:00
Egor 483ccab3f7 Merge pull request #218 from Fr1ngg/vrfwqu-bedolaga/add-auto-apply-user-groups-feature
feat: support auto promo group assignment by spending
2025-09-23 03:53:24 +03:00
Egor 7f62af6955 feat: support auto promo group assignment by spending 2025-09-23 03:53:00 +03:00
Egor 857dfc7e27 Merge pull request #217 from Fr1ngg/bedolaga-eehnom
Add extended broadcast filters for subscription states
2025-09-23 03:34:01 +03:00
Egor f47ca40974 Merge branch 'dev5' into bedolaga-eehnom 2025-09-23 03:33:47 +03:00
Egor 0256203f44 Fix broadcast target parsing for zero-traffic filters 2025-09-23 03:33:16 +03:00
Egor 5bda94794e Merge pull request #216 from Fr1ngg/revert-215-bedolaga-0l8zql
Revert "Adjust zero-traffic audience filters"
2025-09-23 03:32:56 +03:00
Egor 0d218980f7 Revert "Adjust zero-traffic audience filters" 2025-09-23 03:32:31 +03:00
Egor 73d8c30d9c Merge pull request #215 from Fr1ngg/bedolaga-0l8zql
Adjust zero-traffic audience filters
2025-09-23 03:31:37 +03:00
Egor 7abafbeaa1 Merge branch 'dev5' into bedolaga-0l8zql 2025-09-23 03:31:28 +03:00
Egor 2a7728a76c Adjust zero-traffic audience filters 2025-09-23 03:30:37 +03:00
Egor d266997720 Merge pull request #214 from Fr1ngg/bedolaga-hyr7z5
Add extended broadcast filters for subscription states
2025-09-23 03:24:47 +03:00
Egor d6fb26d425 Add extended broadcast filters for subscription states 2025-09-23 03:24:28 +03:00
Egor 1eb3658ce5 Merge pull request #213 from Fr1ngg/revert-212-bedolaga-99q9z7
Revert "Add broadcast filters for expired and zero traffic subscriptions"
2025-09-23 03:23:20 +03:00
Egor badf679bc3 Revert "Add broadcast filters for expired and zero traffic subscriptions" 2025-09-23 03:23:12 +03:00
Egor 1e542a0aa1 Merge pull request #212 from Fr1ngg/bedolaga-99q9z7
Add broadcast filters for expired and zero traffic subscriptions
2025-09-23 03:21:29 +03:00
Egor cdbeca245f Add broadcast filters for expired and zero traffic subscriptions 2025-09-23 03:21:02 +03:00
Egor 5a75626625 Merge pull request #210 from Fr1ngg/wy1u0m-bedolaga/add-referral-system-enhancements-and-analytics
Fix campaign stats to include subscription payments
2025-09-23 03:08:51 +03:00
Egor 17e3985671 Fix campaign stats to include subscription payments 2025-09-23 03:08:15 +03:00
Egor 5a5f151b38 Merge pull request #209 from Fr1ngg/xbnsin-bedolaga/add-referral-system-enhancements-and-analytics
Add advertising campaign stats to admin user info
2025-09-23 02:54:41 +03:00
Egor 97c8ddf4e1 Add advertising campaign stats to admin user info 2025-09-23 02:54:19 +03:00
Egor df7414b670 Merge pull request #208 from Fr1ngg/revert-207-sha36e-bedolaga/add-referral-system-enhancements-and-analytics
Revert "Enhance campaign analytics and registration visibility"
2025-09-23 02:54:00 +03:00
Egor 8dfe2f2934 Revert "Enhance campaign analytics and registration visibility" 2025-09-23 02:53:52 +03:00
Egor 3f3968dda2 Merge pull request #207 from Fr1ngg/sha36e-bedolaga/add-referral-system-enhancements-and-analytics
Enhance campaign analytics and registration visibility
2025-09-23 02:50:33 +03:00
Egor 3930564f9b Fix duplicate campaign registration assignment 2025-09-23 02:49:16 +03:00
Egor 90edc1dc49 Merge pull request #206 from Fr1ngg/dev5
Фикс текстовой надписи (14 дней) при продлении
2025-09-23 02:28:08 +03:00
Egor c9f7906e10 Merge pull request #205 from Fr1ngg/yebrv1-bedolaga/remove-1-month-from-renewal-button
Fix 14-day period description
2025-09-23 02:25:38 +03:00
Egor 1758eabe0e Fix 14-day period description 2025-09-23 02:25:18 +03:00
Egor 7ec73c9bc7 Merge pull request #204 from Fr1ngg/dev5
Обновление мониторинга серверов в режиме xray
2025-09-23 02:14:09 +03:00
Egor 1b0ad8f8b6 Merge pull request #203 from Fr1ngg/9j3nj4-bedolaga/add-refresh-button-and-server-status
feat: enhance server status view
2025-09-23 02:10:22 +03:00
Egor 555f76a4ec feat: enhance server status view 2025-09-23 02:09:22 +03:00
Egor bfb9c5d09c Create docker-compose.local.yml 2025-09-23 01:37:58 +03:00
Egor 9a72a7e3e7 Merge pull request #198 from Fr1ngg/dev5
Фикс блокировки/разблокировки в тикет системе + дополнение переводов
2025-09-23 01:28:36 +03:00
Egor b48c3f1235 Merge pull request #197 from Fr1ngg/oz3a4v-bedolaga/fix-localization-key-issues-for-russian
Add missing ticket management localization strings
2025-09-23 01:27:48 +03:00
Egor 85f59566a6 Add missing ticket management localization strings 2025-09-23 01:27:30 +03:00
Egor 1c3f2c7500 Update texts.py 2025-09-23 01:21:47 +03:00
Egor 47ca096e48 Merge pull request #196 from Fr1ngg/vxzeis-bedolaga/fix-user-unblock-errors-in-ticket-system
Fix admin ticket rendering after user block toggles
2025-09-23 01:16:24 +03:00
Egor a9da2a0e63 Merge branch 'dev5' into vxzeis-bedolaga/fix-user-unblock-errors-in-ticket-system 2025-09-23 01:16:07 +03:00
Egor aaea98049e Fix FSM context usage in admin ticket handlers 2025-09-23 01:15:26 +03:00
PEDZEO 93668da99f Resolve merge conflicts: integrate server status and ticket system features
- Added server status handler and service
- Integrated server status keyboard functions
- Merged support settings and ticket configurations
- Updated bot.py with new handler registrations
- Resolved conflicts in config.py and inline.py
2025-09-23 01:13:49 +03:00
Egor 53c7df0b8e Merge pull request #195 from Fr1ngg/q3k8jm-bedolaga/fix-user-unblock-errors-in-ticket-system
Fix admin ticket rendering after user block toggles
2025-09-23 01:08:57 +03:00
Egor 86e8ac39c3 Handle admin ticket view after block toggles 2025-09-23 01:08:28 +03:00
Egor a9a10e3e90 Merge pull request #187 from Fr1ngg/dev5
Полноценная тикет-система by @PEDZEO
2025-09-23 00:39:57 +03:00
Egor 1de81a6aa4 Merge pull request #185 from yazhog/main
Автовыбор количества устройств в заказе, если TRIAL_DEVICE_LIMIT < DEFAULT_DEVICE_LIMIT
2025-09-23 00:38:22 +03:00
Egor 540a0e35a1 Merge pull request #184 from PEDZEO/dev3
Fix ticket system: add noop handlers, pagination, validation, and adm…
2025-09-23 00:30:09 +03:00
PEDZEO f0ae702933 Fix ticket system: add noop handlers, pagination, validation, and admin back button routing 2025-09-22 21:40:10 +03:00
PEDZEO 19be4348aa Fix ticket system: add noop handlers, pagination, validation, and admin back button routing 2025-09-22 21:20:50 +03:00
Egor 84ed1fc5ca Merge pull request #182 from Fr1ngg/dev3
Мониторинг серверов
2025-09-22 17:51:11 +03:00
Egor de38f8219c Merge pull request #181 from Fr1ngg/h47qjb-bedolaga/add-server-status-button-with-modes
Add server status menu integration
2025-09-22 17:29:52 +03:00
Egor 12061f0752 Add server status menu integration 2025-09-22 17:29:20 +03:00
Egor a5174f10d5 Merge pull request #180 from Fr1ngg/dev3
Фикс отключения скидки на дл. периоды для базовых юзеров
2025-09-22 14:54:40 +03:00
Egor 69eeaf6c46 Merge pull request #179 from Fr1ngg/v2tigz-bedolaga/fix-discount-settings-in-env
Respect base promo period discount flag
2025-09-22 14:53:43 +03:00
Egor ac25e1b4c6 Respect base promo period discount flag 2025-09-22 14:53:23 +03:00
Egor 26e0800d70 Update Dockerfile 2025-09-21 08:19:43 +03:00
Egor d0197aacd7 Update docker-registry.yml 2025-09-21 08:19:16 +03:00
Egor b097c5db44 Update docker-hub.yml 2025-09-21 08:19:06 +03:00
Egor 302f64ad5c Merge pull request #178 from Fr1ngg/dev3
Вывод скидок за длительный период, за промогруппу при покупке/продлении подписки
2025-09-21 08:09:02 +03:00
Egor 23cd2097da Merge pull request #177 from Fr1ngg/embv4q-bedolaga/add-navigation-to-promo-groups-after-creation
Add return button after creating promo group
2025-09-21 08:05:56 +03:00
Egor 58257a6eac Add return button after creating promo group 2025-09-21 08:01:44 +03:00
Egor ea7b982b7b Merge pull request #176 from Fr1ngg/0h9kvj-bedolaga/display-promo-group-discounts-on-subscription-page
Show promo group discounts during subscription flow
2025-09-21 07:56:00 +03:00
Egor 1fcd76db9e Show promo discounts during subscription flow 2025-09-21 07:55:31 +03:00
Egor 66d92cfa51 Merge pull request #175 from Fr1ngg/dev3
Фикс покупки/продления подпсики для режима FIXED трафика
2025-09-21 06:43:43 +03:00
Egor 9dc5ee22ee Merge pull request #174 from Fr1ngg/p3trbh-bedolaga/fix-pricing-calculation-error-in-fixed-mode
Handle missing traffic package prices
2025-09-21 06:38:57 +03:00
Egor b5df729d97 Handle missing traffic package prices 2025-09-21 06:38:34 +03:00
Egor 56e3541b80 Merge pull request #173 from Fr1ngg/dev3
Фикс продления подписки
2025-09-21 06:31:24 +03:00
Egor 9681bce540 Merge pull request #172 from Fr1ngg/2osn6z-bedolaga/fix-subscription-renewal-error
Fix subscription extension success message
2025-09-21 06:27:40 +03:00
Egor 7330c729e0 Fix subscription extension success message 2025-09-21 06:27:10 +03:00
Egor 977a19a4c9 Merge pull request #171 from Fr1ngg/revert-170-a3l29i-bedolaga/fix-subscription-renewal-error
Revert "Fix edit_text patch signature"
2025-09-21 06:25:49 +03:00
Egor d226fedf75 Revert "Fix edit_text patch signature" 2025-09-21 06:24:59 +03:00
Egor c9ee1da728 Merge pull request #170 from Fr1ngg/a3l29i-bedolaga/fix-subscription-renewal-error
Fix edit_text patch signature
2025-09-21 06:14:37 +03:00
Egor d6753501e7 Fix edit_text patch signature 2025-09-21 06:13:59 +03:00
yazhog c3f62a4ff6 Merge branch 'Fr1ngg:main' into main 2025-09-20 19:31:51 +03:00
Egor 48101eee39 Merge pull request #168 from Fr1ngg/dev3
Фикс удаления промогрупп
2025-09-20 10:38:01 +03:00
Egor 16e771f1c3 Merge pull request #167 from Fr1ngg/dbzwiq-bedolaga/fix-promo-group-deletion-not-working
Fix promo group deletion confirm handling
2025-09-20 10:31:45 +03:00
Egor 0aa49a6a94 Fix promo group delete confirmation handler 2025-09-20 10:31:31 +03:00
Egor c810619a13 Merge pull request #166 from Fr1ngg/revert-165-pa311q-bedolaga/fix-promo-group-deletion-not-working
Revert "Handle promo group deletion when default group is missing"
2025-09-20 10:30:58 +03:00
Egor 817d277091 Revert "Handle promo group deletion when default group is missing" 2025-09-20 10:30:35 +03:00
Egor d3a822e8b3 Merge pull request #165 from Fr1ngg/pa311q-bedolaga/fix-promo-group-deletion-not-working
Handle promo group deletion when default group is missing
2025-09-20 10:28:31 +03:00
Egor ec8c37744a Handle missing default promo group on deletion 2025-09-20 10:27:54 +03:00
Egor 732c36630c Merge pull request #163 from Fr1ngg/dev3
Промогруппы + Скидка базовым юзерам на доп услуги за продолжительный период подписки
2025-09-20 09:46:59 +03:00
Egor 498d2f04b6 Update .env.example 2025-09-20 09:42:14 +03:00
Egor d167147725 Update README.md 2025-09-20 09:41:45 +03:00
Egor 19800971d6 Merge pull request #162 from Fr1ngg/bedolaga
Add configurable base promo group period discounts
2025-09-20 09:12:58 +03:00
Egor 4b457240b9 Add configurable base promo group period discounts 2025-09-20 09:12:26 +03:00
Egor b63c5867d4 Merge pull request #161 from Fr1ngg/feature
Add promo group management to admin user profile
2025-09-20 08:35:23 +03:00
Egor 598dd8b6c1 Add promo group management to admin user profile 2025-09-20 08:35:06 +03:00
Egor 6cd399cea7 Merge pull request #160 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-anc9e0
Expand universal migration to provision promo groups
2025-09-20 08:25:39 +03:00
Egor 466033d04c Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:25:29 +03:00
Egor 99bca0220b Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:25:14 +03:00
Egor a10b9f0ad7 Expand universal migration to provision promo groups 2025-09-20 08:23:57 +03:00
Egor 825d549822 Delete migrations/alembic/versions/3d9b35c6bd8f_add_paid_price_to_subscription.py 2025-09-20 08:15:21 +03:00
Egor 03cb06e757 Delete migrations/alembic/versions/cbd1be472f3d_add_cascade_to_sent_notifications.py 2025-09-20 08:15:09 +03:00
Egor 113b33e68d Delete migrations/alembic/versions/1f5f3a3f5a4d_add_promo_groups_and_user_fk.py 2025-09-20 08:14:37 +03:00
Egor f3a327c9c7 Merge pull request #158 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column-rg4e47
Add promo groups migration
2025-09-20 08:07:51 +03:00
Egor 2ca4a989e2 Handle existing sent_notifications table in migration 2025-09-20 08:07:23 +03:00
Egor 7339f2cd4f Merge pull request #157 from Fr1ngg/codex/fix-bot-start-error-due-to-missing-column
Add promo groups migration
2025-09-20 07:57:26 +03:00
Egor 827897b771 Add promo groups table and link users 2025-09-20 07:57:06 +03:00
Egor eb5064d591 Merge pull request #156 from Fr1ngg/codex/add-promo-group-section-in-admin-panel
Add admin promo groups and integrate discounts
2025-09-20 07:49:12 +03:00
Egor 177862f178 Add admin promo groups and integrate discounts 2025-09-20 07:48:41 +03:00
Egor 7276dcf1e0 Merge pull request #155 from Fr1ngg/main
w
2025-09-20 06:58:06 +03:00
Egor 7587aec913 Update Dockerfile 2025-09-20 03:31:36 +03:00
Egor 7186c93078 Update docker-registry.yml 2025-09-20 03:31:22 +03:00
Egor 727e20584f Update docker-hub.yml 2025-09-20 03:31:11 +03:00
Egor 9c13775eec Merge pull request #153 from Fr1ngg/codex/update-readme-with-available-features
docs: expand functionality overview
2025-09-20 03:09:16 +03:00
Egor e767ef7d92 docs: expand functionality overview 2025-09-20 03:08:24 +03:00
Egor ce22df4d60 Update inline.py 2025-09-20 02:45:49 +03:00
Egor 7c4567ae70 Merge pull request #150 from Fr1ngg/dev3
Фиксы покупки подписки в апдейте с локалью
2025-09-20 02:24:19 +03:00
Egor af1125f6ed Merge pull request #151 from Fr1ngg/codex/fix-subscription-country-change-issue
Fix NameError in manage countries keyboard
2025-09-20 02:23:54 +03:00
Egor ee65e65108 Fix missing texts initialization in manage countries keyboard 2025-09-20 02:23:20 +03:00
Egor 5c1f7d54ba Merge pull request #149 from Fr1ngg/codex/fix-subscription-purchase-button-issue
Fix subscription checkout resume callbacks registration
2025-09-20 02:15:48 +03:00
Egor 21e5af9e9e Fix subscription checkout resume callbacks registration 2025-09-20 02:15:38 +03:00
Egor f7d674adf4 Merge pull request #148 from Fr1ngg/codex/fix-subscription-purchase-error
Add missing localization for subscription checkout button
2025-09-20 02:09:10 +03:00
Egor 5a7de9731a Add missing subscription checkout localization 2025-09-20 02:08:10 +03:00
Egor f987dc2716 фикс регистрации
Dev3
2025-09-20 01:58:14 +03:00
Egor bbdc1274e4 Merge pull request #146 from Fr1ngg/codex/fix-registration-issue-for-new-users-1a7pwj
Fix async rules loading for new users
2025-09-20 01:49:46 +03:00
Egor feb95fe105 Fix async rules loading for new users 2025-09-20 01:49:29 +03:00
Egor 91b0d3782a Merge pull request #131 from yazhog/feature/locale
Feature/locale
2025-09-20 01:02:40 +03:00
yazhog 6d613cab85 Merge pull request #41 from yazhog/codex/set-device-limit-on-subscription-upgrade
Ensure trial-to-paid upgrade defaults to paid device limit
2025-09-19 19:17:09 +03:00
yazhog a0b6aa13a3 Adjust default device selection when upgrading from trial 2025-09-19 18:28:36 +03:00
yazhog a794f5da70 Merge pull request #40 from yazhog/codex/add-localization-for-campaign-keys
Add campaign localization strings to internal locales
2025-09-19 18:06:47 +03:00
yazhog e571edb354 Remove legacy YAML locale files 2025-09-19 18:06:37 +03:00
yazhog 84dece39a7 Add campaign localization strings to internal locales 2025-09-19 18:04:28 +03:00
yazhog f49bf21caa Merge branch 'main' into feature/locale 2025-09-19 18:03:17 +03:00
Egor 64da0ef94f Merge pull request #142 from Fr1ngg/dev3
Правки в меню редактирования профиля юзера
2025-09-19 15:48:46 +03:00
Egor 3177b5ab80 Merge pull request #141 from Fr1ngg/codex/refactor-user-edit-menu-for-subscriptions
Unify admin subscription and settings menu
2025-09-19 15:36:55 +03:00
Egor ec1b459689 Unify admin subscription and settings menu 2025-09-19 15:36:11 +03:00
Egor 08f7f87035 Merge pull request #140 from Fr1ngg/main
w
2025-09-19 15:25:12 +03:00
Egor 59189cae2a Merge pull request #114 from Gy9vin/dev
Добавлена фильтрация пользователей по балансу
2025-09-19 15:16:26 +03:00
Egor 573dcd034a Merge pull request #138 from Fr1ngg/dev_test
Редактирование РК + фикс удаления
2025-09-19 13:40:12 +03:00
Egor 0f29a8a33b Merge pull request #137 from Fr1ngg/codex/fix-editing-of-ad-campaigns
Fix campaign edit menu for captioned messages
2025-09-19 13:36:38 +03:00
Egor 98ec7e4165 Fix campaign edit menu for captioned messages 2025-09-19 13:36:27 +03:00
Egor 46c7d53f6e Merge pull request #136 from Fr1ngg/revert-135-codex/fix-campaign-deletion-and-add-editing-yipzjx
Revert "Handle campaign edit menu for media messages"
2025-09-19 13:27:07 +03:00
Egor 61758b8c2a Revert "Handle campaign edit menu for media messages" 2025-09-19 13:26:54 +03:00
Egor 42ac9555ac Merge pull request #135 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing-yipzjx
Handle campaign edit menu for media messages
2025-09-19 13:25:20 +03:00
Egor f7416f2255 Merge branch 'dev_test' into codex/fix-campaign-deletion-and-add-editing-yipzjx 2025-09-19 13:25:12 +03:00
Egor 509c92217f Fix campaign edit menu fallback for non-text messages 2025-09-19 13:23:30 +03:00
Egor c0d5ae1e9e Merge pull request #134 from Fr1ngg/codex/fix-campaign-deletion-and-add-editing
Fix campaign deletion confirmation and add editing workflow
2025-09-19 13:15:35 +03:00
Egor cf9fbc5ccc Fix campaign deletion confirmation and add editing workflow 2025-09-19 13:15:17 +03:00
gy9vin ad19be83d2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-19 13:14:00 +03:00
yazhog 3dd5e41bda Merge pull request #39 from yazhog/codex/fix-syntaxerror-in-subscription.py
Fix subscription success text formatting
2025-09-19 12:38:27 +03:00
yazhog 639a45611e Fix subscription success text formatting 2025-09-19 12:38:08 +03:00
Egor 773299c317 Merge pull request #130 from Fr1ngg/dev_test
Рекламные компании
2025-09-19 12:34:53 +03:00
yazhog ff39b10761 Merge pull request #38 from yazhog/codex/fix-f-string-syntaxerror-in-subscription.py
Fix trial success text formatting
2025-09-19 12:33:32 +03:00
yazhog 5fd5472fd3 Fix trial success text formatting 2025-09-19 12:33:20 +03:00
Egor adf94802b0 Merge pull request #129 from Fr1ngg/codex/add-advertising-link-functionality-to-admin-eu5f3n
Refresh user state after campaign bonuses
2025-09-19 12:30:22 +03:00
Egor 15378a412f Merge branch 'dev_test' into codex/add-advertising-link-functionality-to-admin-eu5f3n 2025-09-19 12:30:06 +03:00
Egor 32da16f652 Refresh user state after campaign bonuses 2025-09-19 12:29:28 +03:00
yazhog 075d92e4bd Merge pull request #37 from yazhog/codex/extract-text-for-localization
Localize user-facing texts and payment method copy
2025-09-19 12:28:04 +03:00
yazhog a7cbcd284c Localize subscription, referral, and payment texts 2025-09-19 12:27:42 +03:00
Egor 5682d508a3 Merge pull request #128 from Fr1ngg/codex/add-advertising-link-functionality-to-admin
Clean up campaign-related imports
2025-09-19 12:16:12 +03:00
Egor c738b5703f Fix import cleanup 2025-09-19 12:15:56 +03:00
yazhog 6875fce2cb Merge pull request #36 from yazhog/codex/locate-missing-localization-keys
Add missing English localization strings for trial activation
2025-09-19 12:09:31 +03:00
yazhog 57b7ad6732 Add missing English localizations for trial activation 2025-09-19 12:09:11 +03:00
yazhog 8377db323b Merge pull request #35 from yazhog/codex/fix-language-setting-in-interface
Respect DEFAULT_LANGUAGE in onboarding
2025-09-19 12:01:13 +03:00
yazhog 78d768ea4d Respect DEFAULT_LANGUAGE in onboarding 2025-09-19 12:01:01 +03:00
yazhog 9dee654eee Merge pull request #34 from yazhog/codex/move-interface-text-to-locales
Localize interface strings in payments and shared handlers
2025-09-19 11:46:25 +03:00
Egor a02f49916a Merge pull request #126 from Fr1ngg/main
w
2025-09-19 11:44:19 +03:00
yazhog 8d25e9b760 Localize stars payment and common handler texts 2025-09-19 11:43:54 +03:00
Egor 62ebbb2b5f Merge pull request #125 from Fr1ngg/dev_test
Возможность указывать ссылку на саппорт, а не только юзернейм
2025-09-19 11:42:02 +03:00
Egor e62caa725f Merge pull request #124 from Fr1ngg/codex/allow-links-in-support-section
Allow support contact to use URLs
2025-09-19 11:38:25 +03:00
Egor db5f81e6be Allow support contact to use URLs 2025-09-19 11:38:11 +03:00
Egor 15cba3da22 Merge pull request #123 from Fr1ngg/dev_test
Сохранение истории покупки в кеше + предложение продолжить покупку после пополнения баланса
2025-09-19 11:16:11 +03:00
Egor ca93182f68 Merge pull request #122 from Fr1ngg/codex/update-bot-to-return-to-order-summary-6na3g2
Show resume checkout option after Tribute top-ups
2025-09-19 11:08:53 +03:00
Egor b1a2b6774d Show resume checkout option after Tribute top-ups 2025-09-19 11:08:41 +03:00
yazhog ebc98099d3 Merge pull request #33 from yazhog/codex/refactor-localization-for-configurable-default-language
Use configurable default language
2025-09-19 10:58:07 +03:00
yazhog e2137f94f8 Use configurable default language 2025-09-19 10:57:49 +03:00
Egor 3fdc081548 Merge pull request #121 from Fr1ngg/codex/update-bot-to-return-to-order-summary-qgf9b5
Ensure subscription checkout resume after top-ups
2025-09-19 10:54:33 +03:00
Egor 57cc2d2e3a Fix subscription checkout resume after top-ups 2025-09-19 10:54:16 +03:00
Egor b883d2a95c Merge pull request #120 from Fr1ngg/codex/update-bot-to-return-to-order-summary
Add subscription checkout resume support
2025-09-19 10:40:22 +03:00
Egor cfcb35c7ee Add subscription checkout draft resume support 2025-09-19 10:40:05 +03:00
gy9vin 7f4899b885 Добавлена фильтрация пользователей по балансу 2025-09-19 08:40:29 +03:00
Egor 719d656a64 Update docker-registry.yml 2025-09-19 05:39:04 +03:00
Egor 04476ed463 Update docker-hub.yml 2025-09-19 05:38:46 +03:00
Egor 7ef7b1d107 Update Dockerfile 2025-09-19 05:10:54 +03:00
Egor 0cb9ab5704 Update docker-registry.yml 2025-09-19 05:10:45 +03:00
Egor 4d109b948a Update docker-hub.yml 2025-09-19 05:10:33 +03:00
Egor efd8b191ff Update subscription.py 2025-09-19 04:46:31 +03:00
Egor 8273a31512 Update inline.py 2025-09-19 04:41:44 +03:00
Egor 7baebd85c7 Update balance.py 2025-09-19 04:40:56 +03:00
Egor e43f6afd8a Update subscription.py 2025-09-19 04:39:24 +03:00
Egor ec3430db27 Update states.py 2025-09-19 04:34:32 +03:00
Egor a2c0551349 Merge pull request #106 from Fr1ngg/dependabot/pip/python-dependencies-14836bb5a9
deps(deps): bump the python-dependencies group with 12 updates
2025-09-19 03:46:55 +03:00
Egor a66719b865 Merge pull request #105 from Fr1ngg/dependabot/docker/python-3.13-slim
docker(deps): bump python from 3.11-slim to 3.13-slim
2025-09-19 03:46:12 +03:00
Egor b201543014 Merge pull request #104 from Fr1ngg/dependabot/github_actions/github-actions-e8a2446fba
ci(deps): bump the github-actions group with 2 updates
2025-09-19 03:45:55 +03:00
Egor e269f31056 Update dependabot.yml 2025-09-19 03:44:57 +03:00
dependabot[bot] 0c1db66be6 deps(deps): bump the python-dependencies group with 12 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.12.15
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: asyncpg
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.16.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiosqlite
  dependency-version: 0.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.11.9
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dateutil
  dependency-version: 2.9.0.post0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:47 +00:00
dependabot[bot] 5e46507f04 docker(deps): bump python from 3.11-slim to 3.13-slim
Bumps python from 3.11-slim to 3.13-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.13-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:43:05 +00:00
dependabot[bot] f3616abec2 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [docker/build-push-action](https://github.com/docker/build-push-action).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

Updates `docker/build-push-action` from 5 to 6
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-19 00:42:59 +00:00
Egor 86ec46dbe7 Create dependabot.yml 2025-09-19 03:42:10 +03:00
Egor 863b5df0c2 Delete .github/workflows/dependabot.yml 2025-09-19 03:41:41 +03:00
Egor 929b3b8e95 Merge pull request #101 from Gy9vin/dev
Добавлена функция покупки подписки администратором с преобразованием …
2025-09-19 03:40:20 +03:00
Egor f79393c46e Create dependabot.yml 2025-09-19 03:38:52 +03:00
Egor 559dd1df71 Update docker-registry.yml 2025-09-19 03:30:37 +03:00
Egor cb959bdee7 Update docker-hub.yml 2025-09-19 03:30:14 +03:00
Egor ac1b0ec8b3 Update users.py 2025-09-19 03:25:06 +03:00
yazhog fe779a29b4 Merge branch 'Fr1ngg:main' into feature/locale 2025-09-19 01:12:27 +03:00
yazhog b4a1d10037 Промежутки.. 2025-09-19 01:04:43 +03:00
gy9vin 03856d30ec Добавлена функция покупки подписки администратором с преобразованием триала в безлимитную подписку 2025-09-18 23:50:01 +03:00
yazhog e2e3e444ec Промежуточный коммит 2025-09-18 22:05:20 +03:00
Egor cd98d150d6 Merge pull request #100 from Fr1ngg/dev
Починка покупки подписки
2025-09-18 16:29:07 +03:00
Egor 92814ae535 Update subscription.py 2025-09-18 16:22:22 +03:00
Egor 3268c2a509 Update user_utils.py 2025-09-18 16:20:06 +03:00
Egor a09b6c1431 Update subscription.py 2025-09-18 16:19:07 +03:00
yazhog 31c560093d Промежуточный этап локализации 2025-09-18 09:09:55 +03:00
Egor 728bd0450d Merge pull request #95 from Legacyyy777/DEV
№6
2025-09-18 03:13:43 +03:00
Egor 3df3fdbcfe Merge pull request #99 from Fr1ngg/dev
Dev
2025-09-18 03:13:00 +03:00
Egor 296756dac4 Merge pull request #96 from Gy9vin/dev
Пополнение баланса кнопками
2025-09-18 03:11:39 +03:00
Egor 7cb766f3d0 Merge pull request #98 from yazhog/main
Фикс отправки кнопок в рассылках, скрытие в детализации подписки нулевых значений за сервера/траффик
2025-09-18 03:10:49 +03:00
Egor 5e94b287b9 Delete .github/workflows/deploy.yml 2025-09-18 03:08:50 +03:00
yazhog e9eb8f17d3 Merge pull request #31 from yazhog/codex/remove-zero-cost-server-traffic-options-from-calculations
Hide zero-cost options from subscription pricing breakdown
2025-09-18 00:46:18 +03:00
yazhog dfbaf9de21 Filter zero-cost subscription pricing details 2025-09-18 00:37:02 +03:00
yazhog 4dcca0da4a Merge pull request #30 from yazhog/codex/replace-button-text-from-to
Rename referrals button text to Партнерка
2025-09-18 00:33:04 +03:00
yazhog ea85d4f62e Rename referrals button to partnerka 2025-09-18 00:30:01 +03:00
yazhog bef1a3ecd9 Merge pull request #29 from yazhog/codex/analyze-button-attachment-to-message-delivery
Make home button optional in broadcasts
2025-09-18 00:07:50 +03:00
yazhog 22be75508e Add optional broadcast buttons for connect, subscription, and support 2025-09-17 22:51:18 +03:00
yazhog dba8241c5c Make home button optional in broadcasts 2025-09-17 22:19:55 +03:00
gy9vin b3d0348821 2 кнопки баланса в ряду 2025-09-17 18:54:20 +03:00
gy9vin 3006a438e2 Merge branch 'dev' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot into dev 2025-09-17 18:34:24 +03:00
gy9vin d2f74481cd Добавление кнопок пополнения с суммой подписки 2025-09-17 18:31:21 +03:00
Legacyyy777 d29ad223a7 Добавлено уведомление пользователю о пополнении/списании баланса. Реализована отправка сообщений через Telegram-бота с информацией о транзакции и администраторе, выполнившем операцию. 2025-09-17 19:22:43 +05:00
Legacyyy777 c62ccd0790 Merge branch 'Fr1ngg:main' into DEV 2025-09-17 18:14:03 +05:00
Egor 8ff240cbe9 Add files via upload 2025-09-17 16:06:22 +03:00
Egor 4a5769d37e Create docker-hub.yml 2025-09-17 16:05:51 +03:00
Egor 7d27ee7937 Delete .github/workflows directory 2025-09-17 16:04:00 +03:00
Egor a927a6fa70 Create docker-registry.yml 2025-09-17 16:02:47 +03:00
Egor be4db68d00 Delete .github/ISSUE_TEMPLATE/bug_report.md 2025-09-17 16:01:36 +03:00
Egor 6e3807cf78 Delete .github/ISSUE_TEMPLATE/custom.md 2025-09-17 16:01:28 +03:00
Egor ff9adfc30f Delete .github/ISSUE_TEMPLATE/feature_request.md 2025-09-17 16:01:18 +03:00
Egor a6037b60eb Update yookassa_service.py 2025-09-17 15:58:27 +03:00
Egor a0b95cc24c Update payment_service.py 2025-09-17 15:57:27 +03:00
Egor a2536b576e Update inline.py 2025-09-17 15:55:39 +03:00
Egor 0e544040fc Update balance.py 2025-09-17 15:54:58 +03:00
Egor 940957fa98 Update config.py 2025-09-17 15:52:29 +03:00
Legacyyy777 2e6b808f6d Merge branch 'Fr1ngg:main' into DEV 2025-09-17 17:10:15 +05:00
Egor 9ff4734659 Merge pull request #93 from Fr1ngg/main
к
2025-09-17 15:09:21 +03:00
Egor 7a08f483b9 Merge pull request #91 from Fr1ngg/dev
Dev
2025-09-17 14:47:06 +03:00
Egor c2b10e969e Add files via upload 2025-09-17 14:36:29 +03:00
Egor 7bf9b10963 Update user_utils.py 2025-09-17 14:31:44 +03:00
Egor 6402cbef06 Update subscription_service.py 2025-09-17 14:28:42 +03:00
Legacyyy777 b8855bf73b Merge branch 'DEV' of https://github.com/Legacyyy777/remnawave-bedolaga-telegram-bot_777 into DEV 2025-09-17 16:10:19 +05:00
Legacyyy777 5453efadb5 Добавлен файл конфигурации для автоматического деплоя бота на VPS при пуше в ветку DEV. 2025-09-17 16:07:56 +05:00
Egor 8246405c31 Merge pull request #88 from Gy9vin/dev
Добавление СБП отдельной кнопкой для yookassa и указание кастомных сумм для yookasse
2025-09-17 14:01:58 +03:00
Egor 777f16f3c1 Delete Dockerfile 2025-09-17 13:53:01 +03:00
Egor 11860ff21a Delete .github/workflows/docker-registry.yml 2025-09-17 13:52:43 +03:00
Egor 0a6df56dd2 Delete .github/workflows/docker-hub.yml 2025-09-17 13:52:27 +03:00
Egor 9dd643ecab Режим удаления пользователей из панели Remnawave by @Legacyyy777
№5
2025-09-17 13:46:27 +03:00
Egor d8d01384b1 Delete .github/workflows/deploy.yml 2025-09-17 13:44:22 +03:00
Legacyyy777 d0b3bbd488 Update .env.example 2025-09-17 14:29:22 +05:00
Legacyyy777 e8c458f2c4 Добавлен новый параметр REMNAWAVE_USER_DELETE_MODE в конфигурацию и обновлен процесс удаления пользователя RemnaWave с учетом выбранного режима удаления или деактивации. Обработаны возможные ошибки при взаимодействии с Remnawave. 2025-09-17 14:25:37 +05:00
Legacyyy777 e324e50713 Изменен процесс удаления пользователя RemnaWave: добавлена попытка удаления пользователя из панели Remnawave с обработкой ошибок и резервным вариантом деактивации через SubscriptionService. 2025-09-17 14:04:55 +05:00
Legacyyy777 d506ba7317 Добавлен файл конфигурации для деплоя бота на VPS при пуше в ветку DEV 2025-09-17 13:46:37 +05:00
Legacyyy777 2c960813de restore deploy.yml for my fork deploy 2025-09-17 13:38:39 +05:00
gy9vin 1d98bc75b0 Fix gpg 2025-09-17 11:31:31 +03:00
gy9vin 19dddb3cc0 changing the minimum and maximum amount for yookassa 2025-09-17 11:26:25 +03:00
gy9vin 863dd2423d adding the SBP method for YooKassa 2025-09-17 10:48:58 +03:00
Egor 4cc5e92b83 Update backup_service.py 2025-09-17 03:49:21 +03:00
Egor fe626f3442 Update Dockerfile 2025-09-17 02:00:41 +03:00
Egor 50ad9a7a92 Update docker-registry.yml 2025-09-17 02:00:33 +03:00
Egor a73ec96f5c Update docker-hub.yml 2025-09-17 02:00:18 +03:00
Egor 1d072d4544 Merge pull request #87 from Fr1ngg/dev
Вывод названия триального сквада при активации и в разделе с подпиской
2025-09-17 01:46:11 +03:00
Egor 4997acd8bc Update texts.py 2025-09-17 01:44:17 +03:00
Egor 94e202c596 Update subscription.py 2025-09-17 01:43:47 +03:00
Egor 9044060816 Update user_service.py 2025-09-17 01:09:15 +03:00
Egor 3be1a34725 Merge pull request #86 from Fr1ngg/dev
Правка бекапов/восстановления, удаления юзеров, исправление вывода курса за звезды+динамическая инфа в способах оплаты
2025-09-17 01:00:25 +03:00
Egor 41405b83ef Update balance.py 2025-09-17 00:51:08 +03:00
Egor 2133355fbb Update telegram_stars.py 2025-09-17 00:50:34 +03:00
Egor dd7e9ad43f Add files via upload 2025-09-17 00:39:30 +03:00
Egor 460b8208d3 Update inline.py 2025-09-17 00:39:07 +03:00
Egor 5c040a3b03 Update balance.py 2025-09-17 00:38:08 +03:00
Egor 7dd7643a76 Update user_service.py 2025-09-17 00:25:01 +03:00
Egor 12a6ea1097 Update backup_service.py 2025-09-17 00:10:54 +03:00
Egor fb71414096 Merge pull request #84 from yazhog/main
Фикс кнопки назад в покупке подписки, когда только 1 страна доступна
2025-09-16 22:43:34 +03:00
Egor a814a95c34 Merge pull request #83 from Legacyyy777/DEV
№4
2025-09-16 22:43:01 +03:00
Egor 3732359043 Delete .github/workflows/deploy.yml 2025-09-16 22:42:30 +03:00
yazhog 18d637c2fb Merge pull request #20 from yazhog/codex/fix-foreign-key-violation-on-user-delete
Prevent user deletion from failing when welcome texts exist
2025-09-16 16:06:56 +03:00
yazhog 48a7d2a112 Merge pull request #27 from yazhog/codex/disable-service-if-channel_is_required_sub-is-false
Conditionally register channel checker middleware
2025-09-16 14:18:53 +03:00
yazhog 4639020a8e Conditionally register channel checker middleware 2025-09-16 14:11:44 +03:00
yazhog 27da3def3c Merge pull request #26 from yazhog/codex/define-subscription-behavior-for-single-country
Skip country step on back when only one option
2025-09-16 14:03:22 +03:00
yazhog 77027adad3 Skip country step on back when only one option 2025-09-16 14:00:38 +03:00
Legacyyy777 76dd83ac58 Улучшение обработки недостатка средств на балансе: добавлено отображение конкретной суммы недостающих средств 2025-09-16 14:52:38 +05:00
Legacyyy777 eb40143b76 Keep my deploy.yml 2025-09-16 14:40:39 +05:00
Egor 1cda346174 Merge pull request #82 from yazhog/main
Фикс автобэкапов
2025-09-16 10:49:10 +03:00
yazhog f534abdb6f Merge branch 'Fr1ngg:main' into main 2025-09-16 10:43:30 +03:00
yazhog 5204a38528 Merge pull request #25 from yazhog/codex/document-backup-creation-process
Schedule initial auto backup at configured time
2025-09-16 10:41:25 +03:00
yazhog fcb9c1e3b7 Schedule initial auto backup at configured time 2025-09-16 10:20:11 +03:00
Egor 5af41f8d4e Update channel_checker.py 2025-09-16 07:45:09 +03:00
Egor 592c9de77a Merge pull request #81 from Fr1ngg/dev
Фикс проверки подписки на группу
2025-09-16 07:26:20 +03:00
Egor f67f4edc21 Update README.md 2025-09-16 07:25:38 +03:00
Egor a251835a79 Update .env.example 2025-09-16 07:25:07 +03:00
Egor ba8a57e381 Add files via upload 2025-09-16 07:23:14 +03:00
Legacyyy777 75f581924e Update deploy.yml 2025-09-16 08:25:36 +05:00
Egor 10f2a8327c Update channel_checker.py 2025-09-16 06:20:45 +03:00
Egor 127a16b4d6 Update README.md 2025-09-16 06:16:49 +03:00
Egor f9ded2b857 Delete .github/workflows/deploy.yml 2025-09-16 06:12:01 +03:00
Egor 622412992f Merge pull request #80 from Legacyyy777/DEV
№3
2025-09-16 06:11:01 +03:00
Legacyyy777 5156c3481c Улучшена обработка медиафайлов V2 2025-09-16 07:45:16 +05:00
Legacyyy777 1d3d4ac36e Добавлено отображение предпросмотра медиафайлов 2025-09-16 07:29:39 +05:00
Legacyyy777 3449dc9d2c Улучшение обработки медиа в сообщениях 2025-09-16 07:24:32 +05:00
Legacyyy777 acd01910ac restore deploy.yml for my fork deploy 2025-09-16 07:04:32 +05:00
Egor e16bcafce0 Update README.md 2025-09-16 03:36:38 +03:00
Egor b8e664aad8 Update Dockerfile 2025-09-16 03:18:56 +03:00
Egor b365394ff4 Update docker-registry.yml 2025-09-16 03:18:46 +03:00
Egor e53549d7cc Update docker-hub.yml 2025-09-16 03:18:28 +03:00
Egor 743e99af85 Merge pull request #78 from yazhog/main
Шаблон описания пользователя в панели Remnawave, с плейсхолдерами
2025-09-16 01:38:53 +03:00
Egor 9616fe8d44 Merge pull request #79 from Fr1ngg/dev
Dev
2025-09-16 01:32:10 +03:00
Egor 4ae6361599 Merge pull request #76 from FireWookie/codex/required-sub-channel
Codex/required sub channel
2025-09-16 01:29:40 +03:00
yazhog 89c12e6ba9 Merge pull request #24 from yazhog/codex/add-user-description-template-to-.env.example
Add configurable RemnaWave user description template
2025-09-15 22:02:04 +03:00
yazhog 5d6bc82d8a Support username_clean placeholder for RemnaWave descriptions 2025-09-15 21:54:13 +03:00
yazhog a4f3fe5a49 Run RemnaWave middleware updates asynchronously 2025-09-15 21:43:30 +03:00
yazhog 3a58a3490a Update RemnaWave description from auth middleware 2025-09-15 21:31:49 +03:00
yazhog e2408702ea Update description when refreshing RemnaWave users 2025-09-15 21:22:01 +03:00
yazhog 3f1f27e5aa Merge pull request #23 from yazhog/revert-22-codex/find-user-mapping-between-telegram-bot-and-remnawave-api
Revert "Format RemnaWave username with Telegram names"
2025-09-15 20:41:01 +03:00
yazhog bbd0ec2edd Revert "Format RemnaWave username with Telegram names" 2025-09-15 20:40:49 +03:00
yazhog eb22b19d98 Merge pull request #22 from yazhog/codex/find-user-mapping-between-telegram-bot-and-remnawave-api
Format RemnaWave username with Telegram names
2025-09-15 16:30:50 +03:00
yazhog 45b9d44fd0 Transliterate names for RemnaWave usernames 2025-09-15 16:16:43 +03:00
yazhog a92003f0f5 Sanitize RemnaWave username for API 2025-09-15 16:09:08 +03:00
yazhog 4ac9387ef2 Relax RemnaWave username format 2025-09-15 16:01:23 +03:00
yazhog a24b5e8145 Format RemnaWave username with Telegram names 2025-09-15 15:52:04 +03:00
firewookie 7244e3978b upd channel check 2025-09-15 16:37:21 +05:00
firewookie 41cabf5e0e add middleware and keyboard 2025-09-14 23:42:47 +05:00
yazhog 99c7dc4b29 Clear welcome text references when deleting user 2025-09-14 16:47:40 +03:00
Egor 338bc03cd6 Update messages.py 2025-09-14 14:26:29 +03:00
Egor 985260144e Update messages.py 2025-09-14 13:07:30 +03:00
Egor b487925fbf Add files via upload 2025-09-14 09:21:22 +03:00
Egor 07a89443b3 Merge pull request #74 from Fr1ngg/dev
Фиксы сервиса мониторинга
2025-09-14 09:13:11 +03:00
Egor 54ebb2d96d Update admin.py 2025-09-14 09:11:25 +03:00
Egor 34a7cd1f6c Add files via upload 2025-09-14 09:09:16 +03:00
Egor 455b8fc709 Add files via upload 2025-09-14 09:06:13 +03:00
Egor 591b422302 Add files via upload 2025-09-14 09:02:39 +03:00
Egor fc73c34a03 Update admin.py 2025-09-14 08:32:30 +03:00
Egor cc1c6e3088 Update admin.py 2025-09-14 08:05:44 +03:00
160 changed files with 45834 additions and 5059 deletions
+112 -1
View File
@@ -5,12 +5,24 @@
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -28,6 +40,7 @@ POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
@@ -46,6 +59,19 @@ REMNAWAVE_PASSWORD=
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=
# Шаблон описания пользователя в панели Remnawave
# Доступные плейсхолдеры:
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
@@ -63,8 +89,13 @@ MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# Плюсовать дни триала при покупке платной подписке
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -93,6 +124,11 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -134,6 +170,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -175,6 +214,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -185,7 +231,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# CRYPTOBOT
CRYPTOBOT_ENABLED=true
CRYPTOBOT_ENABLED=false
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
@@ -196,6 +242,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
MULENPAY_SECRET_KEY=
MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
MULENPAY_DESCRIPTION="Пополнение баланса"
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# PAYPALYCH / PAL24
PAL24_ENABLED=false
PAL24_API_TOKEN=
PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -210,10 +285,24 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
@@ -231,6 +320,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
MONITORING_LOGS_RETENTION_DAYS=30
NOTIFICATION_CACHE_HOURS=24
# ===== СТАТУС СЕРВЕРОВ =====
# Режимы: disabled, external_link, external_link_miniapp, xray
SERVER_STATUS_MODE=disabled
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
SERVER_STATUS_EXTERNAL_URL=
# URL метрик XrayChecker (для режима xray)
SERVER_STATUS_METRICS_URL=
# Данные Basic Auth (опционально)
SERVER_STATUS_METRICS_USERNAME=
SERVER_STATUS_METRICS_PASSWORD=
# Проверять SSL сертификат при запросе метрик
SERVER_STATUS_METRICS_VERIFY_SSL=true
# Таймаут запроса к метрикам (в секундах)
SERVER_STATUS_REQUEST_TIMEOUT=10
# Количество серверов на странице в режиме интеграции
SERVER_STATUS_ITEMS_PER_PAGE=10
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
@@ -238,8 +344,11 @@ MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -251,6 +360,8 @@ APP_CONFIG_CACHE_TTL=3600
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
BACKUP_TIME=03:00
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
BACKUP_MAX_KEEP=7
BACKUP_COMPRESSION=true
BACKUP_INCLUDE_LOGS=false
-38
View File
@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
-10
View File
@@ -1,10 +0,0 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+71
View File
@@ -0,0 +1,71 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 10
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "python"
allow:
- dependency-type: "all"
groups:
python-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 5
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
day: "tuesday"
time: "09:00"
timezone: "Europe/Moscow"
open-pull-requests-limit: 3
reviewers:
- "Fr1ngg"
assignees:
- "Fr1ngg"
commit-message:
prefix: "docker"
include: "scope"
labels:
- "dependencies"
- "docker"
+5 -5
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -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.3.2-$(git rev-parse --short HEAD)"
VERSION="v2.4.2-$(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.3.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.2-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.3.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
@@ -68,7 +68,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
+31 -44
View File
@@ -24,18 +24,15 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -50,31 +47,26 @@ jobs:
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.2"
echo "🚀 Собираем версию из main: $VERSION"
VERSION="v2.4.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
VERSION="v2.4.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.3.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
VERSION="v2.4.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR - only build without push"
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
echo "✅ Push/Tag - will push"
fi
- name: Extract metadata
@@ -93,11 +85,11 @@ jobs:
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
platforms: linux/amd64,linux/arm64
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -105,18 +97,12 @@ jobs:
VERSION=${{ steps.version.outputs.version }}
BUILD_DATE=${{ steps.version.outputs.build_date }}
VCS_REF=${{ steps.version.outputs.short_sha }}
cache-from: |
type=gha
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-contexts: |
alpine=docker-image://alpine:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
if: github.event_name == 'pull_request'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -125,30 +111,31 @@ jobs:
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Build Summary
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
+4
View File
@@ -8,10 +8,14 @@
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!locales/
!locales/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
+4 -4
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim AS builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
FROM python:3.13-slim
ARG VERSION="v2.3.2"
ARG VERSION="v2.4.2"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8081 8082 8083
EXPOSE 8080 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
+354 -73
View File
@@ -13,7 +13,7 @@
[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE)
[![GitHub Stars](https://img.shields.io/github/stars/Fr1ngg/remnawave-bedolaga-telegram-bot?style=social)](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💬 Поддержка](#-поддержка-и-сообщество)
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
</div>
@@ -35,7 +35,7 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + MulenPay + PayPalych + P2P
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
@@ -54,6 +54,7 @@
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
- ✍️ **Проверка на подписку** - проверяет подписку на канал
### 📚 Поддерживаемые методы авторизации
@@ -107,6 +108,24 @@ docker compose logs
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
### 🌐 Интеграция веб-админки
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
### 📱 Telegram Mini App с подпиской
Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
|------------|----------|--------|
| `SERVER_STATUS_MODE` | Режим работы кнопки: `disabled`, `external_link` (открывает ссылку в браузере), `external_link_miniapp` (открывает ссылку во встроенном мини-приложении Telegram) или `xray` (интеграция с XrayChecker). | `xray` |
| `SERVER_STATUS_EXTERNAL_URL` | Прямая ссылка на внешний мониторинг (используется в режимах `external_link` и `external_link_miniapp`). | `https://status.example.com` |
| `SERVER_STATUS_METRICS_URL` | URL страницы метрик XrayChecker (Prometheus формат). | `https://sub.example.com/metrics` |
| `SERVER_STATUS_METRICS_USERNAME` / `SERVER_STATUS_METRICS_PASSWORD` | Данные Basic Auth, если страница метрик защищена паролем. | `status` / `secret` |
| `SERVER_STATUS_ITEMS_PER_PAGE` | Количество серверов, показываемых на одной странице в режиме интеграции. | `10` |
### 🛡️ Защита панели Remnawave
Для панелей, защищенных через [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy):
@@ -158,6 +177,12 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -228,12 +253,24 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== TELEGRAM BOT =====
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -251,6 +288,7 @@ POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
@@ -269,6 +307,19 @@ REMNAWAVE_PASSWORD=
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=
# Шаблон описания пользователя в панели Remnawave
# Доступные плейсхолдеры:
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
@@ -288,6 +339,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -316,6 +369,11 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
@@ -357,6 +415,9 @@ YOOKASSA_SECRET_KEY=
YOOKASSA_RETURN_URL=
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Включить оплату через СБП (Систему быстрых платежей)
YOOKASSA_SBP_ENABLED=false
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
@@ -398,6 +459,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
# Быстрый выбор суммы пополнения через YooKassa
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -408,7 +476,7 @@ PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# CRYPTOBOT
CRYPTOBOT_ENABLED=true
CRYPTOBOT_ENABLED=false
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
@@ -419,6 +487,35 @@ CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
MULENPAY_SECRET_KEY=
MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
MULENPAY_DESCRIPTION="Пополнение баланса"
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# PAYPALYCH / PAL24
PAL24_ENABLED=false
PAL24_API_TOKEN=
PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -433,10 +530,24 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
@@ -454,6 +565,23 @@ NOTIFICATION_RETRY_ATTEMPTS=3
MONITORING_LOGS_RETENTION_DAYS=30
NOTIFICATION_CACHE_HOURS=24
# ===== СТАТУС СЕРВЕРОВ =====
# Режимы: disabled, external_link, external_link_miniapp, xray
SERVER_STATUS_MODE=disabled
# Ссылка на внешний мониторинг (для режимов external_link и external_link_miniapp)
SERVER_STATUS_EXTERNAL_URL=
# URL метрик XrayChecker (для режима xray)
SERVER_STATUS_METRICS_URL=
# Данные Basic Auth (опционально)
SERVER_STATUS_METRICS_USERNAME=
SERVER_STATUS_METRICS_PASSWORD=
# Проверять SSL сертификат при запросе метрик
SERVER_STATUS_METRICS_VERIFY_SSL=true
# Таймаут запроса к метрикам (в секундах)
SERVER_STATUS_REQUEST_TIMEOUT=10
# Количество серверов на странице в режиме интеграции
SERVER_STATUS_ITEMS_PER_PAGE=10
# ===== РЕЖИМ ТЕХНИЧЕСКИХ РАБОТ =====
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
@@ -461,8 +589,11 @@ MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -474,6 +605,8 @@ APP_CONFIG_CACHE_TTL=3600
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
BACKUP_TIME=03:00
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
BACKUP_MAX_KEEP=7
BACKUP_COMPRESSION=true
BACKUP_INCLUDE_LOGS=false
@@ -496,9 +629,8 @@ LOG_FILE=logs/bot.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL= # Укажите домен вебхука
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
```
</details>
@@ -516,35 +648,39 @@ WEBHOOK_PATH=/webhook
🛒 **Умная покупка подписок**
- 📅 Гибкие периоды (14-360 дней)
- 📊 Выбор трафика или фиксированный лимит
- 🌍 Автоматический выбор серверов(для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
- 📱 Настройка количества устройств и серверов прямо при оформлении
- 🧾 Понятный калькулятор стоимости с учетом всех опций
🧪 **Тестовая подписка**
- Настраиваемый триал-период
- Уведомления об истечении
- Плавный переход на платную версию
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
💰 **Удобные платежи**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa
- 💰 CryptoBot
- 🎁 Реферальные бонусы
- Детальная история транзакций
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa (включая СБП и онлайн-чек)
- 💳 MulenPay
- 💳 PayPalych (Pal24)
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
- 🎁 Реферальные и промо-бонусы
- Детальная история транзакций и чеков
📱 **Управление подписками**
- 📈 Статистика использования в реальном времени (Используемый трафик, подключенные устройства)
- 🔄 Автопродление с баланса
- 🔄 Управление трафиком
- 🌍 Переключение серверов на лету
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
- 🔄 Управление трафиком и лимитами без участия оператора
- 🌍 Переключение серверов Remnawave на лету
- 📱 Управление устройствами + сброс HWID
- 🧩 Смена языка и персональных настроек
🎁 **Бонусная система**
- 🎫 Промокоды на деньги/дни/триал
- 👥 Защищенная реферальная программа с комиссиями
- 🔔 Своевременные уведомления
- 📊 Детальная статистика рефералов
- 🎫 Промокоды на деньги/дни/длинный триал
- 👥 Защищенная реферальная программа с комиссиями и антифродом
- 📊 Детальная статистика рефералов и кампаний
- 📨 Персональные рассылки и уведомления о новых бонусах
</td>
<td width="50%" valign="top">
@@ -552,61 +688,154 @@ WEBHOOK_PATH=/webhook
### ⚙️ **Для администраторов**
📊 **Мощная аналитика**
- 👥 Детальная статистика пользователей
- 💰 Анализ подписок и платежей
- 🖥️ Мониторинг серверов Remnawave
- 📈 Финансовые отчеты и тренды
- 👥 Детальная статистика пользователей и подписок
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, MulenPay, PayPalych, CryptoBot)
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами
- 💰 Управление балансами и ручные начисления
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ)
- 🌍 Мультивыбор серверов
- 🔄 Сброс HWID устройств
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
- 🔄 Сброс HWID устройств и обновление конфигураций
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
- 🚫 Блокировка/разблокировка/удаление
- 🔄 Изменение типа подписки триал/платная и ручное продление
- 🚫 Блокировка/разблокировка/удаление аккаунтов
🎫 **Промо-система**
- 🎁 Создание промокодов (деньги/дни/длинный триал)
- 📊 Детальная статистика использования
- ⚙️ Полное редактирование промокодов
🎯 **Маркетинг и продажи**
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
- 📊 Детальная статистика использования промокодов и кампаний
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
🖥️ **Умный мониторинг**
- 💚 Состояние Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных
- 🌐 Управление сквадами с актуальным статусом
- 🚧 **Автоматический режим тех. работ**
- 📋 Логи и диагностика
- 💚 Контроль доступности Remnawave панели в реальном времени
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
- 📦 Проверка обновлений проекта и уведомления о новых релизах
📨 **Коммуникации**
- 📢 Рассылки по сегментам
- 🔔 Автоуведомления о продлении
- 💬 Система поддержки с HTML разметкой
- 📝 Настройка правил сервиса
📨 **Коммуникации и поддержка**
- 📢 Рассылки по сегментам и мгновенные оповещения
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
- 💬 Система поддержки с HTML разметкой и история обращений
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала
- 💎 Покупка подписки
- 🔄 Конверсия из триала в платную
- Продление подписки
- 💰 Пополнение баланса
- ♻️ Выход обновлений бота
- 🚧 Потеря соелинения с апи Remnawave
- 🗄️ **Бекапы/Восстановление бд**
- 🗄️ Отправка бд файлов в отдельный чат/топики
- 🎯 Активация триала, продления и конверсии в платную подписку
- 💎 Покупка подписки и пополнение баланса
- 🔄 Изменения параметров подписки пользователем
- 🚧 Потеря соединения с Remnawave и включение техработ
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
🗄️ **Бекапы/Восстановление**
- Ручной запуск бекапа
- Восстановление бд
- Включение/Отключение автобекапов
🗄️ **Бекапы и восстановление**
- Ручной запуск бекапа с выбором содержимого (включая логи)
- Автоматические бекапы по расписанию и управление retention-политикой
- Восстановление из бекапа без остановки сервиса
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
💳 **Автоплатежи и биллинг**
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
- Принудительная проверка готовности автосписаний
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
- Настройка минимального баланса и логирование всех списаний
</td>
</tr>
</table>
### 🤖 Автоматизация и экосистема
- 🔄 **Мониторинг Remnawave** — регулярная проверка API, автоматическое включение/выключение техработ и сообщения в топики.
- 🛡️ **Антифрод** — валидация обязательной подписки на канал, проверка уникальности устройств и логирование действий.
- 🧠 **Асинхронная архитектура** — aiogram 3, PostgreSQL/SQLite, Redis и очередь задач для стабильной работы под нагрузкой.
- 🌐 **Мультиязычность** — локализации в `locales/`, быстрый выбор языка пользователем, готовность к расширению.
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
### 🧭 Навигация по проекту
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
- `app/handlers/` — сценарии общения (пользовательские и административные).
- `app/services/` — бизнес-логика: подписки, платежи, мониторинг, бекапы, кампании, версия.
- `app/database/` — модели SQLAlchemy, CRUD и миграции Alembic.
- `app/localization/` и `locales/` — тексты интерфейса и переводов.
- `migrations/` — текущие миграции БД для PostgreSQL.
## Локализации
- Каталог `locales/` содержит файлы локализаций в формате `.yml` или `.json` (например, `ru.yml`, `en.yml`). Эти файлы можно редактировать напрямую на рабочем сервере.
- Структура YAML/JSON повторяет иерархию ключей, объявленных в `app/localization/texts.py`. Например, блок `MENU:` и пара `BALANCE: "Баланс"` соответствуют ключу `MENU_BALANCE`. При добавлении новых ключей сохраняйте верхний регистр и единый стиль именования.
- Порядок внесения правок:
1. Измените нужные значения в файле `locales/<язык>.yml` (или `.json`).
2. Перезапустите бота/контейнер (`docker compose restart bot` в Docker) — при старте приложение перечитает локали.
- Путь до пользовательских локалей задаётся переменной `LOCALES_PATH` (`./locales` по умолчанию; в Docker каталог монтируется в `/app/locales`, см. `docker-compose.yml`).
- При первом запуске, если каталог пуст, бот копирует шаблоны `ru.yml` и `en.yml` — благодаря этому у владельца сразу есть заготовки для редактирования.
---
## 💻 Локальная разработка
### 🚀 Быстрый запуск для разработки
Для локальной разработки используйте специальный Docker Compose файл:
```bash
# 1. Клонируйте репозиторий
git clone https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git
cd remnawave-bedolaga-telegram-bot
# 2. Настройте окружение
cp env.example .env
# Отредактируйте .env файл с вашими настройками
# 3. Запустите локально (Windows PowerShell)
.\start-local.ps1
# Или вручную
docker-compose -f docker-compose.local.yml up --build
```
### 📁 Файлы для локальной разработки
- `docker-compose.local.yml` - Docker Compose для локальной разработки
- `env.example` - Пример конфигурации
- `start-local.ps1` - Скрипт быстрого запуска (Windows)
- `start-local.sh` - Скрипт быстрого запуска (Linux/macOS)
- `stop-local.ps1` - Скрипт остановки (Windows)
- `README_LOCAL.md` - Подробная документация по локальной разработке
### 🔧 Особенности локальной разработки
- **Локальная сборка** - образ собирается из исходного кода
- **Горячая перезагрузка** - изменения в коде автоматически применяются
- **Доступ к БД** - PostgreSQL доступен на localhost:5432
- **Доступ к Redis** - Redis доступен на localhost:6379
- **Логи в реальном времени** - все логи выводятся в консоль
- **Отладка** - полный доступ к контейнерам для отладки
### 📊 Мониторинг разработки
```bash
# Просмотр логов
docker-compose -f docker-compose.local.yml logs -f bot
# Проверка статуса
docker-compose -f docker-compose.local.yml ps
# Health check
curl http://localhost:8081/health
# Остановка
docker-compose -f docker-compose.local.yml down
```
---
## 🐳 Docker развертывание
@@ -753,6 +982,7 @@ docker compose down -v --remove-orphans
- **Telegram Stars**: Работает автоматически
- **Tribute**: Настрой webhook на `https://your-domain.com/tribute-webhook`
- **YooKassa**: Настрой webhook на `https://your-domain.com/yookassa-webhook`
- **PayPalych**: Укажи Result URL `https://your-domain.com/pal24-webhook` в кабинете Pal24
### 🛠️ Настройка Уведомлений в топик группы
@@ -868,7 +1098,7 @@ bedolaga_bot/
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Рефералы
│ │ └── 👥 referral.py # Партнерка
│ │
│ ├── 🔧 services/ # Бизнес-логика
│ │ ├── 👤 user_service.py # Сервис пользователей
@@ -877,7 +1107,7 @@ bedolaga_bot/
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_service.py # Промокоды
│ │ ├── 👥 referral_service.py # Рефералы
│ │ ├── 👥 referral_service.py # Партнерка
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
│ │ ├── 🔍 monitoring_service.py # Мониторинг
│ │ ├── ♻️ version_service.py # Проверка версий бота
@@ -892,6 +1122,8 @@ bedolaga_bot/
│ │ ├── ✅ subscription_utils.py # Проверка подписок
│ │ ├── 📄 pagination.py # Пагинация
│ │ ├── 📄 pricing_utils.py # Цены
│ │ ├── 📄 message_patch.py
│ │ ├── 📄 photo_message.py
│ │ ├── ‼️ global_error.py # Обработка ошибок
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
@@ -902,6 +1134,7 @@ bedolaga_bot/
│ │ ├── 📋 logging.py # Логирование
│ │ ├── 🚧 maintenance.py # тех работы
│ │ ├── 🔐 subscription_checker.py # тех работы
│ │ ├── 🔐 channel_checker.py
│ │ └── ⏱️ throttling.py # Ограничение запросов
│ │
│ ├── 🌐 localization/ # Локализация
@@ -962,7 +1195,7 @@ docker system prune
|----------|-------------|---------|
| **Бот не отвечает** | `docker logs remnawave_bot` | Проверь `BOT_TOKEN` и интернет |
| **Ошибки БД** | `docker compose ps postgres` | Проверь статус PostgreSQL |
| **Webhook не работает** | Проверь порты 8081/8082 | Настрой прокси-сервер правильно |
| **Webhook не работает** | Проверь порты 8081/8082/8084 | Настрой прокси-сервер правильно |
| **API недоступен** | Проверь логи бота | Проверь `REMNAWAVE_API_URL` и ключ |
| **Мониторинг не работает** | Админ панель → Мониторинг | Проверь `MAINTENANCE_AUTO_ENABLE` |
| **Платежи не проходят** | Проверь webhook'и | Настрой URL в платежных системах |
@@ -983,20 +1216,41 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# CryptoBot webhook endpoint
handle /cryptobot-webhook* {
reverse_proxy localhost:8081 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
location /cryptobot-webhook {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# MulenPay webhook endpoint
location /mulenpay-webhook {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# PayPalych webhook endpoint
location /pal24-webhook {
proxy_pass http://127.0.0.1:8084;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Для YooKassa
location /yookassa-webhook {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check
@@ -1012,21 +1266,48 @@ your-domain.com {
handle /tribute-webhook* {
reverse_proxy localhost:8081
}
handle /cryptobot-webhook* {
reverse_proxy localhost:8081
}
handle /mulenpay-webhook* {
reverse_proxy localhost:8081
}
handle /pal24-webhook* {
reverse_proxy localhost:8084
}
handle /yookassa-webhook* {
reverse_proxy localhost:8082
}
handle /health {
reverse_proxy localhost:8081/health
}
}
```
#### 🧪 Проверка PayPalych postback
```bash
# Генерируем подпись: md5("100.00:test-order-1:${PAL24_SIGNATURE_TOKEN}")
SIGNATURE=$(python - <<'PY'
import hashlib, os
token = os.environ.get('PAL24_SIGNATURE_TOKEN', 'test_token')
payload = f"100.00:test-order-1:{token}".encode()
print(hashlib.md5(payload).hexdigest().upper())
PY
)
curl -X POST https://your-domain.com/pal24-webhook \
-H "Content-Type: application/json" \
-d '{"InvId": "test-order-1", "OutSum": "100.00", "Status": "SUCCESS", "SignatureValue": "'$SIGNATURE'"}'
```
Ответ `{"status": "ok"}` подтверждает корректную обработку вебхука.
---
## 💡 Использование
@@ -1038,7 +1319,7 @@ your-domain.com {
3. **💰 Баланс** → "💰 Баланс" → пополни через Stars/Tribute/YooKassa
4. **🛒 Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
5. **📱 Управление** → "📋 Мои подписки" → конфигурация → получение ссылки
6. **👥 Рефералы** → "👥 Рефералы" → поделись ссылкой
6. **👥 Партнерка** → "👥 Партнерка" → поделись ссылкой
### ⚙️ **Для администраторов**
+216 -146
View File
@@ -1,163 +1,233 @@
{
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"ru": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"ru": "App Store [RU]"
"config": {},
"platforms": {
"ios": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"ru": "Откройте страницу в App Store и установите приложение. Вернись на страницу с подпиской"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов."
}
}
}
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"ru": "Скачать APK"
],
"android": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
"buttonText": {
"en": "Google Play",
"fa": "Google Play",
"ru": "Google Play",
"zh": "Google Play"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
"buttonText": {
"en": "Download APK",
"fa": "دانلود APK",
"ru": "Скачать APK",
"zh": "下载 APK"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
"zh": "点击下方按钮添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"fa": "برنامه را باز کنید و به سرور متصل شوید",
"ru": "Откройте приложение и подключитесь к серверу",
"zh": "打开应用并连接到服务器"
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает."
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Откройте приложение и подключитесь к серверу"
}
}
}
],
"pc": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215?l=ru",
"buttonText": {
"en": "Mac Os",
"ru": "Mac Os"
}
},
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Windows",
"ru": "Windows"
],
"macos": [
{
"id": "happ",
"name": "Happ",
"isFeatured": true,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
"buttonText": {
"en": "App Store [EU]",
"fa": "App Store [EU]",
"ru": "App Store [EU]",
"zh": "App Store [EU]"
}
},
{
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
"buttonText": {
"en": "App Store [RU]",
"fa": "App Store [RU]",
"ru": "App Store [RU]",
"zh": "App Store [RU]"
}
}
],
"description": {
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below — the app will open and the subscription will be added automatically",
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
}
},
"connectAndUseStep": {
"description": {
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
}
],
"description": {
"en": "Choose the version for your device, click the button below and install the app.",
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
}
}
],
"tv": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
],
"windows": [
{
"id": "happ",
"name": "Happ",
"isFeatured": false,
"urlScheme": "happ://add/",
"installationStep": {
"buttons": [
{
"buttonLink": "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe",
"buttonText": {
"en": "Download",
"ru": "Скачать"
}
}
],
"description": {
"en": "Download and install Happ.",
"ru": "Скачайте и установите Happ"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Подключить подписку) приложение откроется, и подписка добавится автоматически."
}
},
"connectAndUseStep": {
"description": {
"en": "You can select a server",
"ru": "Выберете локацию, включить VPN"
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
]
}
],
"androidTV": [
{
"id": "vpn4tv",
"name": "VPN4TV",
"isFeatured": true,
"urlScheme": "",
"installationStep": {
"buttons": [
{
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
"buttonText": {
"en": "Google Play",
"ru": "Google Play"
}
}
],
"description": {
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
"ru": "Откройте страницу в Google Play и установите приложение"
}
},
"addSubscriptionStep": {
"description": {
"en": "Click the button below to add subscription",
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал"
}
},
"connectAndUseStep": {
"description": {
"en": "Open the app and connect to the server",
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN"
}
}
}
],
"linux": [],
"appleTV": []
}
}
+45 -13
View File
@@ -15,19 +15,32 @@ from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, common
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
)
from app.handlers.admin import (
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
promocodes as admin_promocodes, messages as admin_messages,
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
main as admin_main,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
messages as admin_messages,
monitoring as admin_monitoring,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
user_messages as admin_user_messages,
updates as admin_updates, backup as admin_backup,
welcome_text as admin_welcome_text
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -74,20 +87,31 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info("Используется MemoryStorage для FSM")
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp = Dispatcher(storage=storage)
dp.message.middleware(GlobalErrorMiddleware())
dp.callback_query.middleware(GlobalErrorMiddleware())
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
else:
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
@@ -97,6 +121,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
server_status.register_handlers(dp)
tickets.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
@@ -108,11 +134,17 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
admin_backup.register_handlers(dp)
admin_system_logs.register_handlers(dp)
admin_welcome_text.register_welcome_text_handlers(dp)
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+500 -41
View File
@@ -1,4 +1,9 @@
import logging
import os
import re
import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
@@ -10,10 +15,28 @@ class Settings(BaseSettings):
BOT_TOKEN: str
ADMIN_IDS: str = ""
SUPPORT_USERNAME: str = "@support"
SUPPORT_MENU_ENABLED: bool = True
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
SUPPORT_MENU_ENABLED: bool = True
# SLA for support tickets
SUPPORT_TICKET_SLA_ENABLED: bool = True
SUPPORT_TICKET_SLA_MINUTES: int = 5
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
ADMIN_NOTIFICATIONS_ENABLED: bool = False
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
ADMIN_REPORTS_TOPIC_ID: Optional[int] = None
ADMIN_REPORTS_SEND_TIME: Optional[str] = None
CHANNEL_SUB_ID: Optional[str] = None
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
DATABASE_URL: str = ""
@@ -24,37 +47,50 @@ class Settings(BaseSettings):
POSTGRES_PASSWORD: str = "secure_password_123"
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
DATABASE_MODE: str = "auto"
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
REMNAWAVE_API_KEY: str
REMNAWAVE_API_URL: str = ""
REMNAWAVE_API_KEY: str = ""
REMNAWAVE_SECRET_KEY: Optional[str] = None
REMNAWAVE_USERNAME: Optional[str] = None
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str
TRIAL_SQUAD_UUID: str = ""
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
ENABLE_NOTIFICATIONS: bool = True
NOTIFICATION_RETRY_ATTEMPTS: int = 3
MONITORING_LOGS_RETENTION_DAYS: int = 30
NOTIFICATION_CACHE_HOURS: int = 24
MONITORING_LOGS_RETENTION_DAYS: int = 30
NOTIFICATION_CACHE_HOURS: int = 24
SERVER_STATUS_MODE: str = "disabled"
SERVER_STATUS_EXTERNAL_URL: Optional[str] = None
SERVER_STATUS_METRICS_URL: Optional[str] = None
SERVER_STATUS_METRICS_USERNAME: Optional[str] = None
SERVER_STATUS_METRICS_PASSWORD: Optional[str] = None
SERVER_STATUS_METRICS_VERIFY_SSL: bool = True
SERVER_STATUS_REQUEST_TIMEOUT: int = 10
SERVER_STATUS_ITEMS_PER_PAGE: int = 10
BASE_SUBSCRIPTION_PRICE: int = 50000
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
PRICE_14_DAYS: int = 50000
@@ -63,7 +99,7 @@ class Settings(BaseSettings):
PRICE_90_DAYS: int = 269000
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
PRICE_TRAFFIC_25GB: int = 7000
@@ -75,9 +111,12 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
@@ -117,12 +156,16 @@ class Settings(BaseSettings):
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_SBP_ENABLED: bool = False
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -140,8 +183,48 @@ class Settings(BaseSettings):
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
MULENPAY_ENABLED: bool = False
MULENPAY_API_KEY: Optional[str] = None
MULENPAY_SECRET_KEY: Optional[str] = None
MULENPAY_SHOP_ID: Optional[int] = None
MULENPAY_BASE_URL: str = "https://mulenpay.ru/api"
MULENPAY_WEBHOOK_PATH: str = "/mulenpay-webhook"
MULENPAY_DESCRIPTION: str = "Пополнение баланса"
MULENPAY_LANGUAGE: str = "ru"
MULENPAY_VAT_CODE: int = 0
MULENPAY_PAYMENT_SUBJECT: int = 4
MULENPAY_PAYMENT_MODE: int = 4
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
PAL24_ENABLED: bool = False
PAL24_API_TOKEN: Optional[str] = None
PAL24_SHOP_ID: Optional[str] = None
PAL24_SIGNATURE_TOKEN: Optional[str] = None
PAL24_BASE_URL: str = "https://pal24.pro/api/v1/"
PAL24_WEBHOOK_PATH: str = "/pal24-webhook"
PAL24_WEBHOOK_PORT: int = 8084
PAL24_PAYMENT_DESCRIPTION: str = "Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
PAL24_REQUEST_TIMEOUT: int = 30
PAL24_SBP_BUTTON_TEXT: Optional[str] = None
PAL24_CARD_BUTTON_TEXT: Optional[str] = None
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection"
MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение"
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None
HAPP_DOWNLOAD_LINK_PC: Optional[str] = None
HIDE_SUBSCRIPTION_LINK: bool = False
ENABLE_LOGO_MODE: bool = True
LOGO_FILE: str = "vpn_logo.png"
@@ -150,6 +233,7 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
@@ -157,6 +241,19 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
WEB_API_PORT: int = 8080
WEB_API_WORKERS: int = 1
WEB_API_ALLOWED_ORIGINS: str = "*"
WEB_API_DOCS_ENABLED: bool = False
WEB_API_TITLE: str = "Remnawave Bot Admin API"
WEB_API_VERSION: str = "1.0.0"
WEB_API_DEFAULT_TOKEN: Optional[str] = None
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
@@ -176,6 +273,63 @@ class Settings(BaseSettings):
BACKUP_SEND_ENABLED: bool = False
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
@field_validator('SERVER_STATUS_MODE', mode='before')
@classmethod
def normalize_server_status_mode(cls, value: Optional[str]) -> str:
if not value:
return "disabled"
normalized = str(value).strip().lower()
aliases = {
"off": "disabled",
"none": "disabled",
"disabled": "disabled",
"external": "external_link",
"link": "external_link",
"url": "external_link",
"external_link": "external_link",
"miniapp": "external_link_miniapp",
"mini_app": "external_link_miniapp",
"mini-app": "external_link_miniapp",
"webapp": "external_link_miniapp",
"web_app": "external_link_miniapp",
"web-app": "external_link_miniapp",
"external_link_miniapp": "external_link_miniapp",
"xray": "xray",
"xraychecker": "xray",
"xray_metrics": "xray",
"metrics": "xray",
}
mode = aliases.get(normalized, normalized)
if mode not in {"disabled", "external_link", "external_link_miniapp", "xray"}:
raise ValueError(
"SERVER_STATUS_MODE must be one of: disabled, external_link, external_link_miniapp, xray"
)
return mode
@field_validator('SERVER_STATUS_ITEMS_PER_PAGE', mode='before')
@classmethod
def ensure_positive_server_status_page_size(cls, value: Optional[int]) -> int:
try:
if value is None:
return 10
value_int = int(value)
return max(1, value_int)
except (TypeError, ValueError):
return 10
@field_validator('SERVER_STATUS_REQUEST_TIMEOUT', mode='before')
@classmethod
def ensure_positive_server_status_timeout(cls, value: Optional[int]) -> int:
try:
if value is None:
return 10
value_int = int(value)
return max(1, value_int)
except (TypeError, ValueError):
return 10
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -252,6 +406,46 @@ class Settings(BaseSettings):
"password": self.REMNAWAVE_PASSWORD,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
def get_pal24_sbp_button_text(self, fallback: str) -> str:
value = (self.PAL24_SBP_BUTTON_TEXT or "").strip()
return value or fallback
def get_pal24_card_button_text(self, fallback: str) -> str:
value = (self.PAL24_CARD_BUTTON_TEXT or "").strip()
return value or fallback
def get_remnawave_user_delete_mode(self) -> str:
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
return mode if mode in ["delete", "disable"] else "delete"
def format_remnawave_user_description(
self,
*,
full_name: str,
username: Optional[str],
telegram_id: int
) -> str:
template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}"
template_for_formatting = template.replace("@{username}", "{username}")
username_clean = (username or "").lstrip("@")
values = defaultdict(str, {
"full_name": full_name,
"username": f"@{username_clean}" if username_clean else "",
"username_clean": username_clean,
"telegram_id": str(telegram_id)
})
description = template_for_formatting.format_map(values)
if not username_clean:
description = re.sub(r'@(?=\W|$)', '', description)
description = re.sub(r'\(\s*\)', '', description)
description = re.sub(r'\s+', ' ', description).strip()
return description
def get_autopay_warning_days(self) -> List[int]:
try:
@@ -274,10 +468,39 @@ class Settings(BaseSettings):
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks // 100
return f"{rubles}"
def get_reports_chat_id(self) -> Optional[str]:
if self.ADMIN_REPORTS_CHAT_ID:
return self.ADMIN_REPORTS_CHAT_ID
return self.ADMIN_NOTIFICATIONS_CHAT_ID
def get_reports_topic_id(self) -> Optional[int]:
return self.ADMIN_REPORTS_TOPIC_ID or None
def get_reports_send_time(self) -> Optional[time]:
value = self.ADMIN_REPORTS_SEND_TIME
if not value:
return None
try:
hours_str, minutes_str = value.strip().split(":", 1)
hours = int(hours_str)
minutes = int(minutes_str)
if not (0 <= hours <= 23 and 0 <= minutes <= 59):
raise ValueError
return time(hour=hours, minute=minutes)
except (ValueError, AttributeError):
logging.getLogger(__name__).warning(
"Некорректное значение ADMIN_REPORTS_SEND_TIME: %s", value
)
return None
def kopeks_to_rubles(self, kopeks: int) -> float:
return kopeks / 100
@@ -300,6 +523,34 @@ class Settings(BaseSettings):
def is_deep_links_enabled(self) -> bool:
return self.ENABLE_DEEP_LINKS
def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]:
def _clean(value: Optional[str]) -> Optional[str]:
if value is None:
return None
value_str = str(value).strip()
return value_str or None
name_en = _clean(self.MINIAPP_SERVICE_NAME_EN)
name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU)
desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN)
desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU)
default_name = name_en or name_ru or "RemnaWave VPN"
default_description = desc_en or desc_ru or "Secure & Fast Connection"
return {
"service_name": {
"default": default_name,
"en": name_en,
"ru": name_ru,
},
"service_description": {
"default": default_description,
"en": desc_en,
"ru": desc_ru,
},
}
def get_app_config_cache_ttl(self) -> int:
return self.APP_CONFIG_CACHE_TTL
@@ -326,9 +577,24 @@ class Settings(BaseSettings):
return "https://t.me/"
def is_cryptobot_enabled(self) -> bool:
return (self.CRYPTOBOT_ENABLED and
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
and self.MULENPAY_API_KEY is not None
and self.MULENPAY_SECRET_KEY is not None
and self.MULENPAY_SHOP_ID is not None
)
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
and self.PAL24_API_TOKEN is not None
and self.PAL24_SHOP_ID is not None
)
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
@@ -346,6 +612,41 @@ class Settings(BaseSettings):
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def is_happ_cryptolink_mode(self) -> bool:
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
def is_happ_download_button_enabled(self) -> bool:
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
def should_hide_subscription_link(self) -> bool:
"""Returns True when subscription links must be hidden from the interface."""
if self.is_happ_cryptolink_mode():
return False
return self.HIDE_SUBSCRIPTION_LINK
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
return template or None
def get_happ_download_link(self, platform: str) -> Optional[str]:
platform_key = platform.lower()
if platform_key == "pc":
platform_key = "windows"
links = {
"ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(),
"android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(),
"macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(),
"windows": (
(self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip()
or (self.HAPP_DOWNLOAD_LINK_PC or "").strip()
),
}
link = links.get(platform_key)
return link if link else None
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
@@ -354,7 +655,46 @@ class Settings(BaseSettings):
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def is_base_promo_group_period_discount_enabled(self) -> bool:
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
def get_base_promo_group_period_discounts(self) -> Dict[int, int]:
try:
config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip()
if not config_str:
return {}
discounts: Dict[int, int] = {}
for part in config_str.split(','):
part = part.strip()
if not part:
continue
period_and_discount = part.split(':')
if len(period_and_discount) != 2:
continue
period_str, discount_str = period_and_discount
try:
period_days = int(period_str.strip())
discount_percent = int(discount_str.strip())
except ValueError:
continue
discounts[period_days] = max(0, min(100, discount_percent))
return discounts
except Exception:
return {}
def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int:
if not period_days or not self.is_base_promo_group_period_discount_enabled():
return 0
discounts = self.get_base_promo_group_period_discounts()
return discounts.get(period_days, 0)
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
@@ -559,39 +899,158 @@ class Settings(BaseSettings):
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
]
def get_traffic_price(self, gb: int) -> int:
def get_traffic_price(self, gb: Optional[int]) -> int:
packages = self.get_traffic_packages()
for package in packages:
if package["gb"] == gb and package["enabled"]:
return package["price"]
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
if gb is None:
gb = 0
for package in enabled_packages:
if package["gb"] == gb:
return package["price"]
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
if gb <= 0:
return unlimited_package["price"] if unlimited_package else 0
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
if finite_packages:
max_package = max(finite_packages, key=lambda x: x["gb"])
if gb > max_package["gb"]:
if unlimited_package:
return unlimited_package["price"]
else:
return max_package["price"]
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
if suitable_packages:
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
return nearest_package["price"]
if unlimited_package:
return unlimited_package["price"]
return 0
if not finite_packages:
return unlimited_package["price"] if unlimited_package else 0
max_package = max(finite_packages, key=lambda x: x["gb"])
if gb >= max_package["gb"]:
return unlimited_package["price"] if unlimited_package else max_package["price"]
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
if suitable_packages:
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
return nearest_package["price"]
return unlimited_package["price"] if unlimited_package else 0
def _clean_support_contact(self) -> str:
return (self.SUPPORT_USERNAME or "").strip()
def get_support_contact_url(self) -> Optional[str]:
contact = self._clean_support_contact()
if not contact:
return None
if contact.startswith(("http://", "https://", "tg://")):
return contact
contact_without_prefix = contact.lstrip("@")
if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
return f"https://{contact_without_prefix}"
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
return f"https://{contact}"
if "." in contact_without_prefix:
return f"https://{contact_without_prefix}"
if contact_without_prefix:
return f"https://t.me/{contact_without_prefix}"
return None
def get_support_contact_display(self) -> str:
contact = self._clean_support_contact()
if not contact:
return ""
if contact.startswith("@"):
return contact
if contact.startswith(("http://", "https://", "tg://")):
return contact
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
url = self.get_support_contact_url()
return url if url else contact
contact_without_prefix = contact.lstrip("@")
if "." in contact_without_prefix:
url = self.get_support_contact_url()
return url if url else contact
if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix):
return f"@{contact_without_prefix}"
return contact
def get_support_contact_display_html(self) -> str:
return html.escape(self.get_support_contact_display())
def get_server_status_mode(self) -> str:
return self.SERVER_STATUS_MODE
def is_server_status_enabled(self) -> bool:
return self.get_server_status_mode() != "disabled"
def get_server_status_external_url(self) -> Optional[str]:
url = (self.SERVER_STATUS_EXTERNAL_URL or "").strip()
return url or None
def get_server_status_metrics_url(self) -> Optional[str]:
url = (self.SERVER_STATUS_METRICS_URL or "").strip()
return url or None
def get_server_status_metrics_auth(self) -> Optional[tuple[str, str]]:
username = (self.SERVER_STATUS_METRICS_USERNAME or "").strip()
password_raw = self.SERVER_STATUS_METRICS_PASSWORD
if not username:
return None
password = "" if password_raw is None else str(password_raw)
return username, password
def get_server_status_items_per_page(self) -> int:
return max(1, self.SERVER_STATUS_ITEMS_PER_PAGE)
def get_server_status_request_timeout(self) -> int:
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
def is_web_api_enabled(self) -> bool:
return bool(self.WEB_API_ENABLED)
def get_web_api_allowed_origins(self) -> list[str]:
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
origins = [origin.strip() for origin in raw if origin.strip()]
return origins or ["*"]
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
if self.WEB_API_DOCS_ENABLED:
return {
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json",
}
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
def get_support_system_mode(self) -> str:
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
return mode if mode in {"tickets", "contact", "both"} else "both"
def is_support_tickets_enabled(self) -> bool:
return self.get_support_system_mode() in {"tickets", "both"}
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
model_config = {
"env_file": ".env",
+491
View File
@@ -0,0 +1,491 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import and_, func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
Transaction,
TransactionType,
User,
)
logger = logging.getLogger(__name__)
async def create_campaign(
db: AsyncSession,
*,
name: str,
start_parameter: str,
bonus_type: str,
created_by: Optional[int] = None,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
start_parameter=start_parameter,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
subscription_traffic_gb=subscription_traffic_gb,
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=is_active,
)
db.add(campaign)
await db.commit()
await db.refresh(campaign)
logger.info(
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
)
return campaign
async def get_campaign_by_id(
db: AsyncSession, campaign_id: int
) -> Optional[AdvertisingCampaign]:
result = await db.execute(
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.where(AdvertisingCampaign.id == campaign_id)
)
return result.scalar_one_or_none()
async def get_campaign_by_start_parameter(
db: AsyncSession,
start_parameter: str,
*,
only_active: bool = False,
) -> Optional[AdvertisingCampaign]:
stmt = select(AdvertisingCampaign).where(
AdvertisingCampaign.start_parameter == start_parameter
)
if only_active:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_campaigns_list(
db: AsyncSession,
*,
offset: int = 0,
limit: int = 20,
include_inactive: bool = True,
) -> List[AdvertisingCampaign]:
stmt = (
select(AdvertisingCampaign)
.options(selectinload(AdvertisingCampaign.registrations))
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
.limit(limit)
)
if not include_inactive:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
result = await db.execute(stmt)
return result.scalars().all()
async def get_campaigns_count(
db: AsyncSession, *, is_active: Optional[bool] = None
) -> int:
stmt = select(func.count(AdvertisingCampaign.id))
if is_active is not None:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
result = await db.execute(stmt)
return result.scalar_one() or 0
async def update_campaign(
db: AsyncSession,
campaign: AdvertisingCampaign,
**kwargs,
) -> AdvertisingCampaign:
allowed_fields = {
"name",
"start_parameter",
"bonus_type",
"balance_bonus_kopeks",
"subscription_duration_days",
"subscription_traffic_gb",
"subscription_device_limit",
"subscription_squads",
"is_active",
}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if not update_data:
return campaign
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.commit()
await db.refresh(campaign)
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
)
await db.commit()
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
return True
async def get_campaign_registration_by_user(
db: AsyncSession,
user_id: int,
) -> Optional[AdvertisingCampaignRegistration]:
result = await db.execute(
select(AdvertisingCampaignRegistration)
.options(selectinload(AdvertisingCampaignRegistration.campaign))
.where(AdvertisingCampaignRegistration.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none()
async def record_campaign_registration(
db: AsyncSession,
*,
campaign_id: int,
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: Optional[int] = None,
) -> AdvertisingCampaignRegistration:
existing = await db.execute(
select(AdvertisingCampaignRegistration).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.user_id == user_id,
)
)
)
registration = existing.scalar_one_or_none()
if registration:
return registration
registration = AdvertisingCampaignRegistration(
campaign_id=campaign_id,
user_id=user_id,
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
)
db.add(registration)
await db.commit()
await db.refresh(registration)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
return registration
async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> Dict[str, Optional[int]]:
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
registrations_subquery = registrations_query.subquery()
result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
),
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
count, total_balance, last_registration = result.one()
count = count or 0
total_balance = total_balance or 0
subscription_count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.bonus_type == "subscription",
)
)
)
subscription_bonuses_issued = subscription_count_result.scalar() or 0
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
deposits_total = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_from_flag = paid_users_result.scalar() or 0
conversions_rows = await db.execute(
select(
SubscriptionConversion.user_id,
SubscriptionConversion.first_payment_amount_kopeks,
SubscriptionConversion.converted_at,
)
.where(
SubscriptionConversion.user_id.in_(
select(registrations_subquery.c.user_id)
)
)
.order_by(SubscriptionConversion.converted_at)
)
conversion_entries = conversions_rows.all()
subscription_payments_rows = await db.execute(
select(
Transaction.user_id,
Transaction.amount_kopeks,
Transaction.created_at,
)
.where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed.is_(True),
)
.order_by(Transaction.user_id, Transaction.created_at)
)
subscription_payments = subscription_payments_rows.all()
subscription_payments_total = 0
paid_users_from_transactions = set()
conversion_user_ids = set()
first_payment_amount_by_user: Dict[int, int] = {}
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
for user_id, amount_kopeks, converted_at in conversion_entries:
conversion_user_ids.add(user_id)
amount_value = int(amount_kopeks or 0)
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = converted_at
for user_id, amount_kopeks, created_at in subscription_payments:
amount_value = int(amount_kopeks or 0)
subscription_payments_total += amount_value
paid_users_from_transactions.add(user_id)
if user_id not in first_payment_amount_by_user:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
else:
existing_time = first_payment_time_by_user.get(user_id)
if existing_time is None and created_at is not None:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
elif (
existing_time is not None
and created_at is not None
and created_at < existing_time
):
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
total_revenue = deposits_total + subscription_payments_total
paid_user_ids = set(paid_users_from_transactions)
paid_user_ids.update(conversion_user_ids)
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
conversion_count = conversion_count or len(paid_user_ids)
if conversion_count < len(paid_user_ids):
conversion_count = len(paid_user_ids)
avg_first_payment = 0
if first_payment_amount_by_user:
avg_first_payment = int(
sum(first_payment_amount_by_user.values())
/ len(first_payment_amount_by_user)
)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
deposits_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True),
)
)
total_revenue = deposits_result.scalar() or 0
trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
)
)
trial_users_count = trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(func.distinct(Subscription.user_id))).where(
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
Subscription.is_trial.is_(True),
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
active_trials_count = active_trials_result.scalar() or 0
conversions_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
conversion_count = conversions_result.scalar() or 0
paid_users_result = await db.execute(
select(func.count(User.id)).where(
User.id.in_(select(registrations_subquery.c.user_id)),
User.has_had_paid_subscription.is_(True),
)
)
paid_users_count = paid_users_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
)
)
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
conversion_rate = 0.0
if count:
conversion_rate = round((paid_users_count / count) * 100, 1)
trial_conversion_rate = 0.0
if trial_users_count:
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
avg_revenue_per_user = 0
if count:
avg_revenue_per_user = int(total_revenue / count)
return {
"registrations": count,
"balance_issued": total_balance,
"subscription_issued": subscription_bonuses_issued,
"last_registration": last_registration,
"total_revenue_kopeks": total_revenue,
"trial_users_count": trial_users_count,
"active_trials_count": active_trials_count,
"conversion_count": conversion_count,
"paid_users_count": paid_users_count,
"conversion_rate": conversion_rate,
"trial_conversion_rate": trial_conversion_rate,
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
"avg_first_payment_kopeks": avg_first_payment,
}
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
inactive = await get_campaigns_count(db, is_active=False)
registrations_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
)
balance_result = await db.execute(
select(
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
)
)
)
subscription_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == "subscription"
)
)
return {
"total": total,
"active": active,
"inactive": inactive,
"registrations": registrations_result.scalar() or 0,
"balance_total": balance_result.scalar() or 0,
"subscription_total": subscription_result.scalar() or 0,
}
+90
View File
@@ -0,0 +1,90 @@
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import DiscountOffer
async def upsert_discount_offer(
db: AsyncSession,
*,
user_id: int,
subscription_id: Optional[int],
notification_type: str,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
result = await db.execute(
select(DiscountOffer)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.notification_type == notification_type,
DiscountOffer.is_active == True, # noqa: E712
)
.order_by(DiscountOffer.created_at.desc())
)
offer = result.scalars().first()
if offer and offer.claimed_at is None:
offer.discount_percent = discount_percent
offer.bonus_amount_kopeks = bonus_amount_kopeks
offer.expires_at = expires_at
offer.subscription_id = subscription_id
else:
offer = DiscountOffer(
user_id=user_id,
subscription_id=subscription_id,
notification_type=notification_type,
discount_percent=discount_percent,
bonus_amount_kopeks=bonus_amount_kopeks,
expires_at=expires_at,
is_active=True,
)
db.add(offer)
await db.commit()
await db.refresh(offer)
return offer
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer).where(DiscountOffer.id == offer_id)
)
return result.scalar_one_or_none()
async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
await db.commit()
await db.refresh(offer)
return offer
async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at < now,
)
)
offers = result.scalars().all()
if not offers:
return 0
count = 0
for offer in offers:
offer.is_active = False
count += 1
await db.commit()
return count
+118
View File
@@ -0,0 +1,118 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
async def create_mulenpay_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
uuid: str,
description: str,
payment_url: Optional[str],
mulen_payment_id: Optional[int],
currency: str,
status: str,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment = MulenPayPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
uuid=uuid,
description=description,
payment_url=payment_url,
mulen_payment_id=mulen_payment_id,
currency=currency,
status=status,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан MulenPay платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
payment.mulen_payment_id,
uuid,
amount_kopeks,
user_id,
)
return payment
async def get_mulenpay_payment_by_local_id(
db: AsyncSession, payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_uuid(
db: AsyncSession, uuid: str
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_mulen_id(
db: AsyncSession, mulen_payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(
MulenPayPayment.mulen_payment_id == mulen_payment_id
)
)
return result.scalar_one_or_none()
async def update_mulenpay_payment_status(
db: AsyncSession,
*,
payment: MulenPayPayment,
status: str,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at:
payment.paid_at = paid_at
if callback_payload is not None:
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
payment: MulenPayPayment,
transaction_id: int,
) -> MulenPayPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+14
View File
@@ -50,3 +50,17 @@ async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
)
)
await db.commit()
async def clear_notification_by_type(
db: AsyncSession,
subscription_id: int,
notification_type: str,
) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
)
)
await db.commit()
+160
View File
@@ -0,0 +1,160 @@
"""CRUD helpers for PayPalych (Pal24) payments."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
async def create_pal24_payment(
db: AsyncSession,
*,
user_id: int,
bill_id: str,
amount_kopeks: int,
description: Optional[str],
status: str,
type_: str,
currency: str,
link_url: Optional[str],
link_page_url: Optional[str],
order_id: Optional[str] = None,
ttl: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
payment = Pal24Payment(
user_id=user_id,
bill_id=bill_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
type=type_,
link_url=link_url,
link_page_url=link_page_url,
metadata_json=metadata or {},
ttl=ttl,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
status,
)
return payment
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
return result.scalar_one_or_none()
async def update_pal24_payment_status(
db: AsyncSession,
payment: Pal24Payment,
*,
status: str,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
balance_amount: Optional[str] = None,
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
}
if is_active is not None:
update_values["is_active"] = is_active
if is_paid is not None:
update_values["is_paid"] = is_paid
if payment_id is not None:
update_values["payment_id"] = payment_id
if payment_status is not None:
update_values["payment_status"] = payment_status
if payment_method is not None:
update_values["payment_method"] = payment_method
if balance_amount is not None:
update_values["balance_amount"] = balance_amount
if balance_currency is not None:
update_values["balance_currency"] = balance_currency
if payer_account is not None:
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
update_values["last_status"] = status
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
payment.bill_id,
payment.status,
payment.is_paid,
)
return payment
async def link_pal24_payment_to_transaction(
db: AsyncSession,
payment: Pal24Payment,
transaction_id: int,
) -> Pal24Payment:
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
"Pal24 платеж %s привязан к транзакции %s",
payment.bill_id,
transaction_id,
)
return payment
+251
View File
@@ -0,0 +1,251 @@
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
if not period_discounts:
return {}
normalized: Dict[int, int] = {}
for key, value in period_discounts.items():
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
async def count_promo_groups(db: AsyncSession) -> int:
result = await db.execute(select(func.count(PromoGroup.id)))
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
)
return result.scalars().first()
async def create_promo_group(
db: AsyncSession,
name: str,
*,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
auto_assign_total_spent_kopeks = (
max(0, auto_assign_total_spent_kopeks)
if auto_assign_total_spent_kopeks is not None
else None
)
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=should_be_default,
)
db.add(promo_group)
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s₽, скидки на доп. услуги: %s",
promo_group.name,
promo_group.is_default,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
(auto_assign_total_spent_kopeks or 0) / 100,
"on" if promo_group.apply_discounts_to_addons else "off",
)
return promo_group
async def update_promo_group(
db: AsyncSession,
group: PromoGroup,
*,
name: Optional[str] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
if device_discount_percent is not None:
group.device_discount_percent = max(0, min(100, device_discount_percent))
if period_discounts is not None:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
if is_default is not None:
if is_default:
group.is_default = True
await db.flush()
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
logger.info(
"Обновлена промогруппа '%s' (id=%s)",
group.name,
group.id,
)
return group
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
if group.is_default:
logger.warning("Попытка удалить базовую промогруппу запрещена")
return False
default_group = await get_default_promo_group(db)
if not default_group:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
await db.delete(group)
await db.commit()
logger.info(
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
group.name,
group.id,
default_group.name,
)
return True
async def get_promo_group_members(
db: AsyncSession,
group_id: int,
*,
offset: int = 0,
limit: int = 20,
) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.where(User.promo_group_id == group_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
result = await db.execute(
select(func.count(User.id)).where(User.promo_group_id == group_id)
)
return result.scalar_one()
+222 -26
View File
@@ -1,14 +1,33 @@
import logging
from typing import List, Optional, Tuple
from sqlalchemy import select, and_, func, update, delete, text
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import (
select,
and_,
func,
update,
delete,
text,
or_,
cast,
String,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ServerSquad, SubscriptionServer, Subscription
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
async def _get_default_promo_group_id(db: AsyncSession) -> Optional[int]:
result = await db.execute(
select(PromoGroup.id).where(PromoGroup.is_default.is_(True)).limit(1)
)
return result.scalar_one_or_none()
async def create_server_squad(
db: AsyncSession,
squad_uuid: str,
@@ -18,9 +37,30 @@ async def create_server_squad(
price_kopeks: int = 0,
description: str = None,
max_users: int = None,
is_available: bool = True
is_available: bool = True,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
normalized_group_ids: Sequence[int]
if promo_group_ids is None:
default_id = await _get_default_promo_group_id(db)
normalized_group_ids = [default_id] if default_id is not None else []
else:
normalized_group_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
if not normalized_group_ids:
raise ValueError("Server squad must be linked to at least one promo group")
promo_groups_result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(normalized_group_ids))
)
promo_groups = promo_groups_result.scalars().all()
if len(promo_groups) != len(normalized_group_ids):
logger.warning(
"Не все промогруппы найдены при создании сервера %s", display_name
)
server_squad = ServerSquad(
squad_uuid=squad_uuid,
display_name=display_name,
@@ -29,9 +69,10 @@ async def create_server_squad(
price_kopeks=price_kopeks,
description=description,
max_users=max_users,
is_available=is_available
is_available=is_available,
allowed_promo_groups=promo_groups,
)
db.add(server_squad)
await db.commit()
await db.refresh(server_squad)
@@ -46,9 +87,11 @@ async def get_server_squad_by_uuid(
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad).where(ServerSquad.squad_uuid == squad_uuid)
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.squad_uuid == squad_uuid)
)
return result.scalar_one_or_none()
return result.scalars().unique().one_or_none()
async def get_server_squad_by_id(
@@ -57,9 +100,11 @@ async def get_server_squad_by_id(
) -> Optional[ServerSquad]:
result = await db.execute(
select(ServerSquad).where(ServerSquad.id == server_id)
select(ServerSquad)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.id == server_id)
)
return result.scalar_one_or_none()
return result.scalars().unique().one_or_none()
async def get_all_server_squads(
@@ -91,14 +136,59 @@ async def get_all_server_squads(
return servers, total_count
async def get_available_server_squads(db: AsyncSession) -> List[ServerSquad]:
result = await db.execute(
async def get_available_server_squads(
db: AsyncSession,
promo_group_id: Optional[int] = None,
) -> List[ServerSquad]:
query = (
select(ServerSquad)
.where(ServerSquad.is_available == True)
.options(selectinload(ServerSquad.allowed_promo_groups))
.where(ServerSquad.is_available.is_(True))
.order_by(ServerSquad.sort_order, ServerSquad.display_name)
)
return result.scalars().all()
if promo_group_id is not None:
query = query.join(ServerSquad.allowed_promo_groups).where(
PromoGroup.id == promo_group_id
)
result = await db.execute(query)
return result.scalars().unique().all()
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
unique_ids = [int(pg_id) for pg_id in set(promo_group_ids)]
if not unique_ids:
raise ValueError("Нужно выбрать хотя бы одну промогруппу")
server = await get_server_squad_by_id(db, server_id)
if not server:
return None
result = await db.execute(
select(PromoGroup).where(PromoGroup.id.in_(unique_ids))
)
promo_groups = result.scalars().all()
if not promo_groups:
raise ValueError("Не найдены промогруппы для обновления сервера")
server.allowed_promo_groups = promo_groups
await db.commit()
await db.refresh(server)
logger.info(
"Обновлены промогруппы сервера %s (ID: %s): %s",
server.display_name,
server.id,
", ".join(pg.name for pg in promo_groups),
)
return server
async def update_server_squad(
@@ -153,10 +243,10 @@ async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
disabled = 0
removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
@@ -186,15 +276,121 @@ async def sync_with_remnawave(
)
created += 1
for uuid, server in existing_servers.items():
if uuid not in remnawave_uuids and server.is_available:
server.is_available = False
disabled += 1
removed_servers = [
server for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
removed_uuids = {server.squad_uuid for server in removed_servers}
subscription_ids_result = await db.execute(
select(SubscriptionServer.subscription_id)
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
"🗑️ Удаляется сервер %s (UUID: %s)",
server.display_name,
server.squad_uuid,
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscriptions_to_update: dict[int, Subscription] = {}
if subscription_ids:
subscriptions_result = await db.execute(
select(Subscription).where(Subscription.id.in_(subscription_ids))
)
for subscription in subscriptions_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
for squad_uuid in removed_uuids:
if not squad_uuid:
continue
extra_result = await db.execute(
select(Subscription).where(
text("connected_squads::text LIKE :uuid_pattern")
),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
for subscription in extra_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
cleaned_subscriptions = 0
for subscription in subscriptions_to_update.values():
current_squads = list(subscription.connected_squads or [])
if not current_squads:
continue
filtered_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
]
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
"🧹 Обновлены подписки после удаления серверов: %s",
cleaned_subscriptions,
)
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
return created, updated, disabled
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
return created, updated, removed
async def get_server_connected_users(
db: AsyncSession,
server_id: int
) -> List[User]:
server_uuid_result = await db.execute(
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
)
server_uuid = server_uuid_result.scalar_one_or_none()
connection_filters = [SubscriptionServer.id.isnot(None)]
if server_uuid:
connection_filters.append(
cast(Subscription.connected_squads, String).like(
f'%"{server_uuid}"%'
)
)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(
SubscriptionServer,
and_(
SubscriptionServer.subscription_id == Subscription.id,
SubscriptionServer.server_squad_id == server_id,
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.order_by(User.id)
)
return result.scalars().unique().all()
def _generate_display_name(original_name: str) -> str:
+292 -52
View File
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Subscription, SubscriptionStatus, User,
SubscriptionServer
Subscription,
SubscriptionStatus,
User,
SubscriptionServer,
PromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
@@ -113,13 +116,33 @@ async def extend_subscription(
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
@@ -442,6 +465,8 @@ async def add_subscription_servers(
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
await db.refresh(subscription)
if paid_prices is None:
months_remaining = get_remaining_months(subscription.end_date)
paid_prices = []
@@ -458,7 +483,7 @@ async def add_subscription_servers(
for i, server_id in enumerate(server_squad_ids):
subscription_server = SubscriptionServer(
subscription_id=subscription.id,
subscription_id=subscription.id,
server_squad_id=server_id,
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
)
@@ -467,7 +492,7 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
return subscription
async def get_server_monthly_price(
@@ -493,49 +518,144 @@ async def get_servers_monthly_prices(
prices.append(price)
return prices
def _get_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
async def calculate_subscription_total_cost(
db: AsyncSession,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int
devices: int,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, dict]:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _get_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_price_per_month = sum(servers_prices)
total_servers_price = servers_price_per_month * months_in_period
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
details = {
'base_price': base_price,
'base_price_original': base_price_original,
'base_discount_percent': period_discount_percent,
'base_discount_total': base_discount_total,
'traffic_price_per_month': traffic_price_per_month,
'traffic_discount_percent': traffic_discount_percent,
'traffic_discount_total': total_traffic_discount,
'total_traffic_price': total_traffic_price,
'servers_price_per_month': servers_price_per_month,
'servers_discount_percent': servers_discount_percent,
'servers_discount_total': total_servers_discount,
'total_servers_price': total_servers_price,
'devices_price_per_month': devices_price_per_month,
'devices_discount_percent': devices_discount_percent,
'devices_discount_total': total_devices_discount,
'total_devices_price': total_devices_price,
'months_in_period': months_in_period,
'servers_individual_prices': [price * months_in_period for price in servers_prices]
'servers_individual_prices': [
(price - (price * servers_discount_percent // 100)) * months_in_period
for price in servers_prices
]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}"
)
if total_traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = (
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}"
)
if total_devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_cost/100}")
return total_cost, details
@@ -609,19 +729,33 @@ async def remove_subscription_servers(
async def get_subscription_renewal_cost(
db: AsyncSession,
subscription_id: int,
period_days: int
period_days: int,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
subscription = await db.get(Subscription, subscription_id)
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
)
.where(Subscription.id == subscription_id)
)
subscription = result.scalar_one_or_none()
if not subscription:
return base_price
if user is None:
user = subscription.user
promo_group = promo_group or (user.promo_group if user else None)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
@@ -632,23 +766,74 @@ async def get_subscription_renewal_cost(
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
total_servers_cost = servers_price_per_month * months_in_period
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_cost = traffic_price_per_month * months_in_period
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_cost = discounted_traffic_per_month * months_in_period
total_traffic_discount = traffic_discount_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_cost = devices_price_per_month * months_in_period
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_cost = discounted_devices_per_month * months_in_period
total_devices_discount = devices_discount_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
if total_servers_cost > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}"
)
if total_servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
if total_traffic_cost > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}"
)
if total_traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
if total_devices_cost > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}"
)
if total_devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
@@ -663,27 +848,65 @@ async def calculate_addon_cost_for_remaining_period(
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None
additional_server_ids: List[int] = None,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
total_cost = 0
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_total_cost = traffic_price_per_month * months_to_pay
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
"traffic",
period_days=period_hint_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_cost = discounted_traffic_per_month * months_to_pay
total_cost += traffic_total_cost
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}")
message = (
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_cost = devices_price_per_month * months_to_pay
devices_discount_percent = _get_discount_percent(
user,
promo_group,
"devices",
period_days=period_hint_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_cost = discounted_devices_per_month * months_to_pay
total_cost += devices_total_cost
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}")
message = (
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
@@ -694,9 +917,24 @@ async def calculate_addon_cost_for_remaining_period(
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
server_total_cost = server_price_per_month * months_to_pay
servers_discount_percent = _get_discount_percent(
user,
promo_group,
"servers",
period_days=period_hint_days,
)
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_total_cost = discounted_server_per_month * months_to_pay
total_cost += server_total_cost
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}")
message = (
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}"
)
if server_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}")
return total_cost
@@ -747,7 +985,8 @@ async def create_subscription(
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = ""
subscription_url: str = "",
subscription_crypto_link: str = ""
) -> Subscription:
if end_date is None:
@@ -766,7 +1005,8 @@ async def create_subscription(
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link
)
db.add(subscription)
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting
async def upsert_system_setting(
db: AsyncSession,
key: str,
value: Optional[str],
description: Optional[str] = None,
) -> SystemSetting:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is None:
setting = SystemSetting(key=key, value=value, description=description)
db.add(setting)
else:
setting.value = value
if description is not None:
setting.description = description
await db.flush()
return setting
async def delete_system_setting(db: AsyncSession, key: str) -> None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is not None:
await db.delete(setting)
await db.flush()
+424
View File
@@ -0,0 +1,424 @@
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_, or_, update, func
from sqlalchemy.orm import selectinload
from datetime import datetime
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
class TicketCRUD:
"""CRUD операции для работы с тикетами"""
@staticmethod
async def create_ticket(
db: AsyncSession,
user_id: int,
title: str,
message_text: str,
priority: str = "normal",
*,
media_type: Optional[str] = None,
media_file_id: Optional[str] = None,
media_caption: Optional[str] = None,
) -> Ticket:
"""Создать новый тикет с первым сообщением"""
ticket = Ticket(
user_id=user_id,
title=title,
status=TicketStatus.OPEN.value,
priority=priority
)
db.add(ticket)
await db.flush() # Получаем ID тикета
# Создаем первое сообщение
message = TicketMessage(
ticket_id=ticket.id,
user_id=user_id,
message_text=message_text,
is_from_admin=False,
has_media=bool(media_type and media_file_id),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
)
db.add(message)
await db.commit()
await db.refresh(ticket)
return ticket
@staticmethod
async def get_ticket_by_id(
db: AsyncSession,
ticket_id: int,
load_messages: bool = True,
load_user: bool = False
) -> Optional[Ticket]:
"""Получить тикет по ID"""
query = select(Ticket).where(Ticket.id == ticket_id)
if load_user:
query = query.options(selectinload(Ticket.user))
if load_messages:
query = query.options(selectinload(Ticket.messages))
result = await db.execute(query)
return result.scalar_one_or_none()
@staticmethod
async def get_user_tickets(
db: AsyncSession,
user_id: int,
status: Optional[str] = None,
limit: int = 20,
offset: int = 0
) -> List[Ticket]:
"""Получить тикеты пользователя"""
query = select(Ticket).where(Ticket.user_id == user_id)
if status:
query = query.where(Ticket.status == status)
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def count_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str]
) -> int:
"""Подсчитать количество тикетов пользователя по списку статусов"""
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def get_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str],
limit: int = 20,
offset: int = 0
) -> List[Ticket]:
"""Получить тикеты пользователя по списку статусов с пагинацией"""
query = (
select(Ticket)
.where(Ticket.user_id == user_id)
.order_by(desc(Ticket.updated_at))
.offset(offset)
.limit(limit)
)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def user_has_active_ticket(
db: AsyncSession,
user_id: int
) -> bool:
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
query = (
select(Ticket.id)
.where(
Ticket.user_id == user_id,
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
)
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none() is not None
@staticmethod
async def is_user_globally_blocked(
db: AsyncSession,
user_id: int
) -> Optional[datetime]:
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
Возвращает дату окончания блокировки, если активна, или None.
"""
query = select(Ticket).where(
Ticket.user_id == user_id,
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
).order_by(desc(Ticket.updated_at)).limit(10)
result = await db.execute(query)
tickets = result.scalars().all()
if not tickets:
return None
from datetime import datetime
# Если есть вечная блокировка в любом тикете — блок активен без срока
for t in tickets:
if t.user_reply_block_permanent:
return datetime.max
# Иначе ищем максимальный срок блокировки, если он в будущем
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
if not future_until:
return None
max_until = max(future_until)
return max_until if max_until > datetime.utcnow() else None
@staticmethod
async def get_all_tickets(
db: AsyncSession,
status: Optional[str] = None,
priority: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Ticket]:
"""Получить все тикеты (для админов)"""
query = select(Ticket).options(selectinload(Ticket.user))
conditions = []
if status:
conditions.append(Ticket.status == status)
if priority:
conditions.append(Ticket.priority == priority)
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_tickets_by_statuses(
db: AsyncSession,
statuses: List[str],
limit: int = 50,
offset: int = 0
) -> List[Ticket]:
query = select(Ticket).options(selectinload(Ticket.user))
if statuses:
query = query.where(Ticket.status.in_(statuses))
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def count_tickets(
db: AsyncSession,
status: Optional[str] = None
) -> int:
query = select(func.count()).select_from(Ticket)
if status:
query = query.where(Ticket.status == status)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def count_tickets_by_statuses(
db: AsyncSession,
statuses: List[str]
) -> int:
query = select(func.count()).select_from(Ticket)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def update_ticket_status(
db: AsyncSession,
ticket_id: int,
status: str,
closed_at: Optional[datetime] = None
) -> bool:
"""Обновить статус тикета"""
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if not ticket:
return False
ticket.status = status
ticket.updated_at = datetime.utcnow()
if status == TicketStatus.CLOSED.value and closed_at:
ticket.closed_at = closed_at
await db.commit()
return True
@staticmethod
async def set_user_reply_block(
db: AsyncSession,
ticket_id: int,
permanent: bool,
until: Optional[datetime]
) -> bool:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if not ticket:
return False
ticket.user_reply_block_permanent = bool(permanent)
ticket.user_reply_block_until = until
ticket.updated_at = datetime.utcnow()
await db.commit()
return True
@staticmethod
async def close_ticket(
db: AsyncSession,
ticket_id: int
) -> bool:
"""Закрыть тикет"""
return await TicketCRUD.update_ticket_status(
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def add_support_audit(
db: AsyncSession,
*,
actor_user_id: Optional[int],
actor_telegram_id: int,
is_moderator: bool,
action: str,
ticket_id: Optional[int] = None,
target_user_id: Optional[int] = None,
details: Optional[dict] = None,
) -> None:
try:
log = SupportAuditLog(
actor_user_id=actor_user_id,
actor_telegram_id=actor_telegram_id,
is_moderator=bool(is_moderator),
action=action,
ticket_id=ticket_id,
target_user_id=target_user_id,
details=details or {},
)
db.add(log)
await db.commit()
except Exception:
await db.rollback()
# не мешаем основной логике
pass
@staticmethod
async def list_support_audit(
db: AsyncSession,
*,
limit: int = 50,
offset: int = 0,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
return int(result.scalar() or 0)
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
"""Получить количество открытых тикетов"""
query = select(Ticket).where(Ticket.status.in_([
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value
]))
result = await db.execute(query)
return len(result.scalars().all())
class TicketMessageCRUD:
"""CRUD операции для работы с сообщениями тикетов"""
@staticmethod
async def add_message(
db: AsyncSession,
ticket_id: int,
user_id: int,
message_text: str,
is_from_admin: bool = False,
media_type: Optional[str] = None,
media_file_id: Optional[str] = None,
media_caption: Optional[str] = None
) -> TicketMessage:
"""Добавить сообщение в тикет"""
message = TicketMessage(
ticket_id=ticket_id,
user_id=user_id,
message_text=message_text,
is_from_admin=is_from_admin,
has_media=bool(media_type and media_file_id),
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption
)
db.add(message)
# Обновляем статус тикета
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
if ticket:
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
return message
if is_from_admin:
# Админ ответил - тикет отвечен
ticket.status = TicketStatus.ANSWERED.value
else:
# Пользователь ответил - тикет открыт
ticket.status = TicketStatus.OPEN.value
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
try:
from sqlalchemy import inspect as sa_inspect
# если колонка существует в модели
if hasattr(ticket, 'last_sla_reminder_at'):
ticket.last_sla_reminder_at = None
except Exception:
pass
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
return message
@staticmethod
async def get_ticket_messages(
db: AsyncSession,
ticket_id: int,
limit: int = 50,
offset: int = 0
) -> List[TicketMessage]:
"""Получить сообщения тикета"""
query = (
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(TicketMessage.created_at)
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def get_last_message(
db: AsyncSession,
ticket_id: int
) -> Optional[TicketMessage]:
"""Получить последнее сообщение в тикете"""
query = (
select(TicketMessage)
.where(TicketMessage.ticket_id == ticket_id)
.order_by(desc(TicketMessage.created_at))
.limit(1)
)
result = await db.execute(query)
return result.scalar_one_or_none()
+44 -3
View File
@@ -37,6 +37,20 @@ async def create_transaction(
await db.refresh(transaction)
logger.info(f"💳 Создана транзакция: {type.value} на {amount_kopeks/100}₽ для пользователя {user_id}")
try:
from app.services.promo_group_assignment import (
maybe_assign_promo_group_by_total_spent,
)
await maybe_assign_promo_group_by_total_spent(db, user_id)
except Exception as exc:
logger.debug(
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
user_id,
exc,
)
return transaction
@@ -98,15 +112,42 @@ async def get_user_transactions_count(
return result.scalar()
async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
return int(result.scalar_one())
async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Transaction:
transaction.is_completed = True
transaction.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(transaction)
logger.info(f"✅ Транзакция {transaction.id} завершена")
try:
from app.services.promo_group_assignment import (
maybe_assign_promo_group_by_total_spent,
)
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
except Exception as exc:
logger.debug(
"Не удалось проверить автовыдачу промогруппы для пользователя %s: %s",
transaction.user_id,
exc,
)
return transaction
+211 -21
View File
@@ -2,13 +2,23 @@ import logging
import secrets
import string
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, and_, or_, func
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, UserStatus, Subscription, Transaction
from app.database.models import (
User,
UserStatus,
Subscription,
Transaction,
PromoGroup,
PaymentMethod,
TransactionType,
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -22,7 +32,10 @@ def generate_referral_code() -> str:
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
@@ -36,7 +49,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
@@ -49,7 +65,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User).where(User.referral_code == referral_code)
select(User)
.options(selectinload(User.promo_group))
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
@@ -79,28 +97,47 @@ async def create_user(
) -> User:
if not referral_code:
from app.utils.user_utils import generate_unique_referral_code
referral_code = await generate_unique_referral_code(db, telegram_id)
referral_code = await create_unique_referral_code(db)
default_group = await get_default_promo_group(db)
if not default_group:
default_group = PromoGroup(
name="Базовый юзер",
server_discount_percent=0,
traffic_discount_percent=0,
device_discount_percent=0,
is_default=True,
)
db.add(default_group)
await db.flush()
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
await db.commit()
await db.refresh(user)
if default_group:
user.promo_group = default_group
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
return user
@@ -110,7 +147,10 @@ async def update_user(
**kwargs
) -> User:
from app.utils.validators import sanitize_telegram_name
for field, value in kwargs.items():
if field in ("first_name", "last_name"):
value = sanitize_telegram_name(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -179,10 +219,12 @@ async def add_user_balance_by_id(
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
@@ -201,7 +243,21 @@ async def subtract_user_balance(
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
@@ -216,7 +272,12 @@ async def get_users_list(
offset: int = 0,
limit: int = 50,
search: Optional[str] = None,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
@@ -236,8 +297,75 @@ async def get_users_list(
conditions.append(User.telegram_id == int(search))
query = query.where(or_(*conditions))
sort_flags = [
order_by_balance,
order_by_traffic,
order_by_last_activity,
order_by_total_spent,
order_by_purchase_count,
]
if sum(int(flag) for flag in sort_flags) > 1:
logger.debug(
"Выбрано несколько сортировок пользователей — применяется приоритет: трафик > траты > покупки > баланс > активность"
)
transactions_stats = None
if order_by_total_spent or order_by_purchase_count:
from app.database.models import Transaction
transactions_stats = (
select(
Transaction.user_id.label("user_id"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(Transaction.is_completed.is_(True))
.group_by(Transaction.user_id)
.subquery()
)
query = query.outerjoin(transactions_stats, transactions_stats.c.user_id == User.id)
if order_by_traffic:
traffic_sort = func.coalesce(Subscription.traffic_used_gb, 0.0)
query = query.outerjoin(Subscription, Subscription.user_id == User.id)
query = query.order_by(traffic_sort.desc(), User.created_at.desc())
elif order_by_total_spent:
order_column = func.coalesce(transactions_stats.c.total_spent, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_purchase_count:
order_column = func.coalesce(transactions_stats.c.purchase_count, 0)
query = query.order_by(order_column.desc(), User.created_at.desc())
elif order_by_balance:
query = query.order_by(User.balance_kopeks.desc(), User.created_at.desc())
elif order_by_last_activity:
query = query.order_by(nullslast(User.last_activity.desc()), User.created_at.desc())
else:
query = query.order_by(User.created_at.desc())
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@@ -271,10 +399,69 @@ async def get_users_count(
return result.scalar()
async def get_users_spending_stats(
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
if not user_ids:
return {}
from app.database.models import Transaction
stats_query = (
select(
Transaction.user_id,
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.amount_kopeks,
),
else_=0,
)
),
0,
).label("total_spent"),
func.coalesce(
func.sum(
case(
(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
1,
),
else_=0,
)
),
0,
).label("purchase_count"),
)
.where(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
)
.group_by(Transaction.user_id)
)
result = await db.execute(stats_query)
rows = result.all()
return {
row.user_id: {
"total_spent": int(row.total_spent or 0),
"purchase_count": int(row.purchase_count or 0),
}
for row in rows
}
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
@@ -286,7 +473,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
.where(
and_(
User.last_activity < threshold_date,
+106
View File
@@ -0,0 +1,106 @@
"""CRUD операции для токенов административного веб-API."""
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WebApiToken
async def list_tokens(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[WebApiToken]:
query = select(WebApiToken)
if not include_inactive:
query = query.where(WebApiToken.is_active.is_(True))
query = query.order_by(WebApiToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
return await db.get(WebApiToken, token_id)
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
query = select(WebApiToken).where(
WebApiToken.token_hash == token_hash
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
name: str,
token_hash: str,
token_prefix: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
) -> WebApiToken:
token = WebApiToken(
name=name,
token_hash=token_hash,
token_prefix=token_prefix,
description=description,
expires_at=expires_at,
created_by=created_by,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: WebApiToken,
**kwargs,
) -> WebApiToken:
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def set_tokens_active_status(
db: AsyncSession,
token_ids: Iterable[int],
*,
is_active: bool,
) -> None:
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
await db.delete(token)
__all__ = [
"list_tokens",
"get_token_by_id",
"get_token_by_hash",
"create_token",
"update_token",
"set_tokens_active_status",
"delete_token",
]
+2 -2
View File
@@ -10,7 +10,7 @@ from app.database.models import Base
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.DATABASE_URL,
settings.get_database_url(),
poolclass=NullPool,
echo=settings.DEBUG,
future=True
@@ -48,4 +48,4 @@ async def init_db():
async def close_db():
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
logger.info("✅ Подключение к базе данных закрыто")
+482 -17
View File
@@ -1,10 +1,21 @@
from datetime import datetime, timedelta
from typing import Optional, List
from typing import Optional, List, Dict
from enum import Enum
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Text,
ForeignKey, Float, JSON, BigInteger
Column,
Integer,
String,
DateTime,
Boolean,
Text,
ForeignKey,
Float,
JSON,
BigInteger,
UniqueConstraint,
Index,
Table,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -14,6 +25,24 @@ from sqlalchemy.sql import func
Base = declarative_base()
server_squad_promo_groups = Table(
"server_squad_promo_groups",
Base.metadata,
Column(
"server_squad_id",
Integer,
ForeignKey("server_squads.id", ondelete="CASCADE"),
primary_key=True,
),
Column(
"promo_group_id",
Integer,
ForeignKey("promo_groups.id", ondelete="CASCADE"),
primary_key=True,
),
)
class UserStatus(Enum):
ACTIVE = "active"
BLOCKED = "blocked"
@@ -44,9 +73,11 @@ class PromoCodeType(Enum):
class PaymentMethod(Enum):
TELEGRAM_STARS = "telegram_stars"
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
MANUAL = "manual"
MULENPAY = "mulenpay"
PAL24 = "pal24"
MANUAL = "manual"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
@@ -98,7 +129,7 @@ class YooKassaPayment(Base):
class CryptoBotPayment(Base):
__tablename__ = "cryptobot_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
@@ -146,9 +177,193 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
mulen_payment_id = Column(Integer, nullable=True, index=True)
uuid = Column(String(255), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
status = Column(String(50), nullable=False, default="created")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="mulenpay_payments")
transaction = relationship("Transaction", backref="mulenpay_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<MulenPayPayment(id={0}, mulen_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.mulen_payment_id,
self.amount_rubles,
self.status,
)
)
class Pal24Payment(Base):
__tablename__ = "pal24_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
bill_id = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(255), nullable=True, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
type = Column(String(20), nullable=False, default="normal")
status = Column(String(50), nullable=False, default="NEW")
is_active = Column(Boolean, default=True)
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
last_status = Column(String(50), nullable=True)
last_status_checked_at = Column(DateTime, nullable=True)
link_url = Column(Text, nullable=True)
link_page_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
payment_id = Column(String(255), nullable=True, index=True)
payment_status = Column(String(50), nullable=True)
payment_method = Column(String(50), nullable=True)
balance_amount = Column(String(50), nullable=True)
balance_currency = Column(String(10), nullable=True)
payer_account = Column(String(255), nullable=True)
ttl = Column(Integer, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="pal24_payments")
transaction = relationship("Transaction", backref="pal24_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status in {"NEW", "PROCESS"}
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<Pal24Payment(id={0}, bill_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.bill_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
period_discounts = Column(JSON, nullable=True, default=dict)
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
back_populates="allowed_promo_groups",
lazy="selectin",
)
def _get_period_discounts_map(self) -> Dict[int, int]:
raw_discounts = self.period_discounts or {}
if isinstance(raw_discounts, dict):
items = raw_discounts.items()
else:
items = []
normalized: Dict[int, int] = {}
for key, value in items:
try:
period = int(key)
percent = int(value)
except (TypeError, ValueError):
continue
normalized[period] = max(0, min(100, percent))
return normalized
def _get_period_discount(self, period_days: Optional[int]) -> int:
if not period_days:
return 0
discounts = self._get_period_discounts_map()
if period_days in discounts:
return discounts[period_days]
if self.is_default:
try:
from app.config import settings
if settings.is_base_promo_group_period_discount_enabled():
config_discounts = settings.get_base_promo_group_period_discounts()
return config_discounts.get(period_days, 0)
except Exception:
return 0
return 0
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
if category == "period":
return max(0, min(100, self._get_period_discount(period_days)))
mapping = {
"servers": self.server_discount_percent,
"traffic": self.traffic_discount_percent,
"devices": self.device_discount_percent,
}
percent = mapping.get(category, 0)
return max(0, min(100, percent))
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
telegram_id = Column(BigInteger, unique=True, index=True, nullable=False)
username = Column(String(255), nullable=True)
@@ -170,12 +385,17 @@ class User(Base):
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
discount_offers = relationship("DiscountOffer", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
@property
def balance_rubles(self) -> float:
@@ -185,6 +405,11 @@ class User(Base):
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -212,7 +437,8 @@ class Subscription(Base):
traffic_used_gb = Column(Float, default=0.0)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
connected_squads = Column(JSON, default=list)
@@ -224,8 +450,9 @@ class Subscription(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
remnawave_short_uuid = Column(String(255), nullable=True)
user = relationship("User", back_populates="subscription")
discount_offers = relationship("DiscountOffer", back_populates="subscription")
@property
def is_active(self) -> bool:
@@ -344,8 +571,7 @@ class Subscription(Base):
return 0.0
def extend_subscription(self, days: int):
from datetime import timedelta, datetime
if self.end_date > datetime.utcnow():
self.end_date = self.end_date + timedelta(days=days)
else:
@@ -569,6 +795,28 @@ class SentNotification(Base):
user = relationship("User", backref="sent_notifications")
subscription = relationship("Subscription", backref="sent_notifications")
class DiscountOffer(Base):
__tablename__ = "discount_offers"
__table_args__ = (
Index("ix_discount_offers_user_type", "user_id", "notification_type"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True)
notification_type = Column(String(50), nullable=False)
discount_percent = Column(Integer, nullable=False, default=0)
bonus_amount_kopeks = Column(Integer, nullable=False, default=0)
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", back_populates="discount_offers")
subscription = relationship("Subscription", back_populates="discount_offers")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
@@ -591,9 +839,9 @@ class BroadcastHistory(Base):
class ServerSquad(Base):
__tablename__ = "server_squads"
id = Column(Integer, primary_key=True, index=True)
squad_uuid = Column(String(255), unique=True, nullable=False, index=True)
display_name = Column(String(255), nullable=False)
@@ -611,10 +859,17 @@ class ServerSquad(Base):
sort_order = Column(Integer, default=0)
max_users = Column(Integer, nullable=True)
current_users = Column(Integer, default=0)
current_users = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
allowed_promo_groups = relationship(
"PromoGroup",
secondary=server_squad_promo_groups,
back_populates="server_squads",
lazy="selectin",
)
@property
def price_rubles(self) -> float:
@@ -650,6 +905,23 @@ class SubscriptionServer(Base):
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
class SupportAuditLog(Base):
__tablename__ = "support_audit_logs"
id = Column(Integer, primary_key=True, index=True)
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
actor_telegram_id = Column(BigInteger, nullable=False)
is_moderator = Column(Boolean, default=False)
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
actor = relationship("User", foreign_keys=[actor_user_id])
ticket = relationship("Ticket", foreign_keys=[ticket_id])
class UserMessage(Base):
__tablename__ = "user_messages"
id = Column(Integer, primary_key=True, index=True)
@@ -666,7 +938,7 @@ class UserMessage(Base):
class WelcomeText(Base):
__tablename__ = "welcome_texts"
id = Column(Integer, primary_key=True, index=True)
text_content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
@@ -674,5 +946,198 @@ class WelcomeText(Base):
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_welcome_texts")
class AdvertisingCampaign(Base):
__tablename__ = "advertising_campaigns"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
bonus_type = Column(String(20), nullable=False)
balance_bonus_kopeks = Column(Integer, default=0)
subscription_duration_days = Column(Integer, nullable=True)
subscription_traffic_gb = Column(Integer, nullable=True)
subscription_device_limit = Column(Integer, nullable=True)
subscription_squads = Column(JSON, default=list)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
@property
def is_balance_bonus(self) -> bool:
return self.bonus_type == "balance"
@property
def is_subscription_bonus(self) -> bool:
return self.bonus_type == "subscription"
class AdvertisingCampaignRegistration(Base):
__tablename__ = "advertising_campaign_registrations"
__table_args__ = (
UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
)
id = Column(Integer, primary_key=True, index=True)
campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
bonus_type = Column(String(20), nullable=False)
balance_bonus_kopeks = Column(Integer, default=0)
subscription_duration_days = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
user = relationship("User")
@property
def balance_bonus_rubles(self) -> float:
return (self.balance_bonus_kopeks or 0) / 100
class TicketStatus(Enum):
OPEN = "open"
ANSWERED = "answered"
CLOSED = "closed"
PENDING = "pending"
class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String(255), nullable=False)
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
# Блокировка ответов пользователя в этом тикете
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
user_reply_block_until = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
closed_at = Column(DateTime, nullable=True)
# SLA reminders
last_sla_reminder_at = Column(DateTime, nullable=True)
# Связи
user = relationship("User", backref="tickets")
messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan")
@property
def is_open(self) -> bool:
return self.status == TicketStatus.OPEN.value
@property
def is_answered(self) -> bool:
return self.status == TicketStatus.ANSWERED.value
@property
def is_closed(self) -> bool:
return self.status == TicketStatus.CLOSED.value
@property
def is_pending(self) -> bool:
return self.status == TicketStatus.PENDING.value
@property
def is_user_reply_blocked(self) -> bool:
if self.user_reply_block_permanent:
return True
if self.user_reply_block_until:
try:
from datetime import datetime
return self.user_reply_block_until > datetime.utcnow()
except Exception:
return True
return False
@property
def status_emoji(self) -> str:
status_emojis = {
TicketStatus.OPEN.value: "🔴",
TicketStatus.ANSWERED.value: "🟡",
TicketStatus.CLOSED.value: "🟢",
TicketStatus.PENDING.value: ""
}
return status_emojis.get(self.status, "")
@property
def priority_emoji(self) -> str:
priority_emojis = {
"low": "🟢",
"normal": "🟡",
"high": "🟠",
"urgent": "🔴"
}
return priority_emojis.get(self.priority, "🟡")
def __repr__(self):
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
message_text = Column(Text, nullable=False)
is_from_admin = Column(Boolean, default=False, nullable=False)
# Для медиа файлов
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
media_file_id = Column(String(255), nullable=True)
media_caption = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
# Связи
ticket = relationship("Ticket", back_populates="messages")
user = relationship("User")
@property
def is_user_message(self) -> bool:
return not self.is_from_admin
@property
def is_admin_message(self) -> bool:
return self.is_from_admin
def __repr__(self):
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
class WebApiToken(Base):
__tablename__ = "web_api_tokens"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
token_hash = Column(String(128), nullable=False, unique=True, index=True)
token_prefix = Column(String(32), nullable=False, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
last_used_ip = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(String(255), nullable=True)
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
"""Async client for PayPalych (Pal24) API."""
from __future__ import annotations
import asyncio
import hashlib
import logging
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class Pal24APIError(Exception):
"""Base error for Pal24 API operations."""
@dataclass(slots=True)
class Pal24Response:
"""Wrapper for Pal24 API responses."""
success: bool
data: Dict[str, Any]
status: int
@classmethod
def from_payload(cls, payload: Dict[str, Any], status: int) -> "Pal24Response":
success = bool(payload.get("success", status < 400))
return cls(success=success, data=payload, status=status)
def raise_for_status(self, endpoint: str) -> None:
if not self.success:
detail = self.data.get("message") or self.data.get("error")
raise Pal24APIError(
f"Pal24 API error at {endpoint}: status={self.status}, detail={detail or self.data}"
)
class Pal24Client:
"""Async client implementing PayPalych API methods."""
def __init__(
self,
*,
api_token: Optional[str] = None,
base_url: Optional[str] = None,
timeout: Optional[int] = None,
) -> None:
self.api_token = api_token or settings.PAL24_API_TOKEN
self.base_url = (base_url or settings.PAL24_BASE_URL or "").rstrip("/") + "/"
self.timeout = timeout or settings.PAL24_REQUEST_TIMEOUT
if not self.api_token:
logger.warning("Pal24Client initialized without API token")
@property
def is_configured(self) -> bool:
return bool(self.api_token and self.base_url)
async def _request(
self,
method: str,
endpoint: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Pal24Response:
if not self.is_configured:
raise Pal24APIError("Pal24 client is not configured")
url = f"{self.base_url}{endpoint.lstrip('/')}"
headers = {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
timeout = aiohttp.ClientTimeout(total=self.timeout)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_payload,
params=params,
) as response:
status = response.status
try:
payload = await response.json(content_type=None)
except aiohttp.ContentTypeError:
text_body = await response.text()
logger.error(
"Pal24 API returned non-JSON response for %s: %s",
endpoint,
text_body,
)
raise Pal24APIError(
f"Pal24 API returned non-JSON response: {text_body}"
) from None
result = Pal24Response.from_payload(payload, status)
if status >= 400 or not result.success:
logger.error(
"Pal24 API error %s %s: %s",
status,
endpoint,
payload,
)
result.raise_for_status(endpoint)
return result
except asyncio.TimeoutError as error:
logger.error("Pal24 API request timeout for %s: %s", endpoint, error)
raise Pal24APIError(f"Pal24 API request timeout for {endpoint}") from error
except aiohttp.ClientError as error:
logger.error("Pal24 API client error for %s: %s", endpoint, error)
raise Pal24APIError(str(error)) from error
# API methods -----------------------------------------------------------------
async def create_bill(
self,
*,
amount: Decimal,
shop_id: str,
order_id: Optional[str] = None,
description: Optional[str] = None,
currency_in: str = "RUB",
type_: str = "normal",
**kwargs: Any,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"amount": str(amount),
"shop_id": shop_id,
"currency_in": currency_in,
"type": type_,
}
if order_id:
payload["order_id"] = order_id
if description:
payload["description"] = description
payload.update({k: v for k, v in kwargs.items() if v is not None})
response = await self._request("POST", "bill/create", json_payload=payload)
return response.data
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/status", params={"id": bill_id})
return response.data
async def toggle_bill_activity(self, bill_id: str, active: bool) -> Dict[str, Any]:
payload = {"id": bill_id, "active": 1 if active else 0}
response = await self._request("POST", "bill/toggle_activity", json_payload=payload)
return response.data
async def search_payments(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "payment/search", params=params)
return response.data
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
response = await self._request("GET", "payment/status", params={"id": payment_id})
return response.data
async def get_balance(self) -> Dict[str, Any]:
response = await self._request("GET", "merchant/balance")
return response.data
async def search_bills(self, **params: Any) -> Dict[str, Any]:
response = await self._request("GET", "bill/search", params=params)
return response.data
async def get_bill_payments(self, bill_id: str) -> Dict[str, Any]:
response = await self._request("GET", "bill/payments", params={"id": bill_id})
return response.data
# Helpers ---------------------------------------------------------------------
@staticmethod
def calculate_signature(out_sum: str, inv_id: str, api_token: Optional[str] = None) -> str:
token = api_token or settings.PAL24_SIGNATURE_TOKEN or settings.PAL24_API_TOKEN
if not token:
raise Pal24APIError("Pal24 signature token is not configured")
raw = f"{out_sum}:{inv_id}:{token}".encode("utf-8")
return hashlib.md5(raw).hexdigest().upper()
@staticmethod
def verify_signature(
out_sum: str,
inv_id: str,
signature: str,
api_token: Optional[str] = None,
) -> bool:
try:
expected = Pal24Client.calculate_signature(out_sum, inv_id, api_token)
except Pal24APIError:
logger.error("Pal24 signature verification failed: missing token")
return False
return expected == signature.upper()
@staticmethod
def normalize_amount(amount_kopeks: int) -> Decimal:
try:
return (Decimal(amount_kopeks) / Decimal("100")).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError) as error:
raise Pal24APIError(f"Invalid amount: {amount_kopeks}") from error
+155
View File
@@ -0,0 +1,155 @@
"""Flask webhook server for PayPalych postbacks."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from asyncio import AbstractEventLoop
from typing import Any, Dict, Optional
from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import get_db
from app.services.pal24_service import Pal24Service, Pal24APIError
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
def _normalize_payload() -> Dict[str, str]:
if request.is_json:
payload = request.get_json(silent=True) or {}
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
logger.warning("Pal24 webhook JSON payload не является объектом: %s", payload)
return {}
if request.form:
return {k: v for k, v in request.form.items()}
try:
raw_body = request.data.decode("utf-8")
if raw_body:
payload = json.loads(raw_body)
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
except json.JSONDecodeError:
logger.debug("Pal24 webhook body не удалось распарсить как JSON")
return {}
def create_pal24_flask_app(
payment_service: PaymentService,
loop: AbstractEventLoop,
) -> Flask:
pal24_service = Pal24Service()
app = Flask(__name__)
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["POST"])
def pal24_webhook() -> tuple:
if not pal24_service.is_configured:
logger.error("Pal24 webhook получен, но сервис не настроен")
return jsonify({"status": "error", "reason": "service_not_configured"}), 503
payload = _normalize_payload()
if not payload:
logger.warning("Пустой Pal24 webhook")
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
async def process() -> bool:
async for db in get_db():
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
finally:
await db.close()
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result()
except Exception as error: # pragma: no cover - defensive
logger.exception("Критическая ошибка обработки Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": "internal_error"}), 500
if processed:
return jsonify({"status": "ok"}), 200
return jsonify({"status": "error", "reason": "not_processed"}), 400
@app.route(settings.PAL24_WEBHOOK_PATH, methods=["GET"])
def pal24_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"enabled": settings.is_pal24_enabled(),
}), 200
@app.route("/pal24/health", methods=["GET"])
def pal24_additional_health() -> tuple:
return jsonify({
"status": "ok",
"service": "pal24_webhook",
"path": settings.PAL24_WEBHOOK_PATH,
}), 200
return app
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
self._server: Optional[Any] = None
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if self._server:
logger.warning("Pal24 webhook server уже запущен")
return
self._server = make_server(
host="0.0.0.0",
port=settings.PAL24_WEBHOOK_PORT,
app=self.app,
threaded=True,
)
def _serve() -> None:
logger.info(
"Pal24 webhook сервер запущен на %s:%s%s",
"0.0.0.0",
settings.PAL24_WEBHOOK_PORT,
settings.PAL24_WEBHOOK_PATH,
)
self._server.serve_forever()
self._thread = threading.Thread(target=_serve, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._server:
logger.info("Останавливаем Pal24 webhook сервер")
self._server.shutdown()
self._server = None
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
self._thread = None
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
loop = asyncio.get_running_loop()
server = Pal24WebhookServer(payment_service, loop)
await loop.run_in_executor(None, server.start)
return server
+18 -3
View File
@@ -4,7 +4,6 @@ import ssl
import base64
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Any
from urllib.parse import urlparse
import aiohttp
import logging
from dataclasses import dataclass
@@ -58,6 +57,8 @@ class RemnaWaveUser:
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
@dataclass
@@ -92,6 +93,8 @@ class SubscriptionInfo:
ss_conf_links: Dict[str, str]
subscription_url: str
happ: Optional[Dict[str, str]]
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
class RemnaWaveAPIError(Exception):
@@ -584,6 +587,10 @@ class RemnaWaveAPI:
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
@@ -612,7 +619,9 @@ class RemnaWaveAPI:
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
@@ -645,13 +654,19 @@ class RemnaWaveAPI:
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
happ_data = data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return SubscriptionInfo(
is_found=data['isFound'],
user=data.get('user'),
links=data.get('links', []),
ss_conf_links=data.get('ssConfLinks', {}),
subscription_url=data.get('subscriptionUrl', ''),
happ=data.get('happ')
happ=data.get('happ'),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
+4 -2
View File
@@ -33,6 +33,7 @@ class TelegramStarsService:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
invoice_link = await self.bot.create_invoice_link(
title=title,
@@ -46,7 +47,7 @@ class TelegramStarsService:
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
f"для {chat_id}, курс: {stars_rate}₽/⭐"
)
return invoice_link
@@ -66,6 +67,7 @@ class TelegramStarsService:
try:
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
message = await self.bot.send_invoice(
chat_id=chat_id,
@@ -80,7 +82,7 @@ class TelegramStarsService:
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
)
return {
"message_id": message.message_id,
+148 -5
View File
@@ -1,12 +1,17 @@
import base64
import hashlib
import hmac
import logging
import json
from typing import Optional
from typing import Optional, Iterable
from aiohttp import web
from aiogram import Bot
from app.config import settings
from app.services.tribute_service import TributeService
from app.services.payment_service import PaymentService
from app.database.database import get_db
logger = logging.getLogger(__name__)
@@ -25,18 +30,25 @@ class WebhookServer:
self.app = web.Application()
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_post(settings.MULENPAY_WEBHOOK_PATH, self._mulenpay_webhook_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
if settings.is_mulenpay_enabled():
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(f" - Mulen Pay webhook: POST {settings.MULENPAY_WEBHOOK_PATH}")
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
@@ -62,6 +74,10 @@ class WebhookServer:
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
logger.info(
f"Mulen Pay webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.MULENPAY_WEBHOOK_PATH}"
)
if settings.is_cryptobot_enabled():
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
@@ -89,12 +105,139 @@ class WebhookServer:
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature, X-MulenPay-Signature, Authorization',
}
)
async def _mulenpay_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Mulen Pay webhook: {request.method} {request.path}")
raw_body = await request.read()
if not raw_body:
logger.warning("Пустой Mulen Pay webhook")
return web.json_response({"status": "error", "reason": "empty_body"}, status=400)
if not self._verify_mulenpay_signature(request, raw_body):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError as error:
logger.error(f"Ошибка парсинга Mulen Pay webhook: {error}")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
payment_service = PaymentService(self.bot)
async for db in get_db():
try:
success = await payment_service.process_mulenpay_callback(db, payload)
if success:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "processing_failed"}, status=400)
except Exception as error:
logger.error(f"Ошибка обработки Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error"}, status=500)
finally:
break
except Exception as error:
logger.error(f"Критическая ошибка Mulen Pay webhook: {error}", exc_info=True)
return web.json_response({"status": "error", "reason": "internal_error", "message": str(error)}, status=500)
@staticmethod
def _extract_mulenpay_header(request: web.Request, header_names: Iterable[str]) -> Optional[str]:
for header_name in header_names:
value = request.headers.get(header_name)
if value:
return value.strip()
return None
@staticmethod
def _verify_mulenpay_signature(request: web.Request, raw_body: bytes) -> bool:
secret_key = settings.MULENPAY_SECRET_KEY
if not secret_key:
logger.error("Mulen Pay secret key is not configured")
return False
signature = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Signature',
'X-Mulenpay-Signature',
'X-MULENPAY-SIGNATURE',
'X-MulenPay-Webhook-Signature',
'X-Mulenpay-Webhook-Signature',
'X-MULENPAY-WEBHOOK-SIGNATURE',
'X-Signature',
'Signature',
)
)
if signature:
normalized_signature = signature
if normalized_signature.lower().startswith('sha256='):
normalized_signature = normalized_signature.split('=', 1)[1].strip()
hmac_digest = hmac.new(
secret_key.encode('utf-8'),
raw_body,
hashlib.sha256,
).digest()
expected_hex_signature = hmac_digest.hex()
expected_base64_signature = base64.b64encode(hmac_digest).decode('utf-8').strip()
expected_urlsafe_base64_signature = base64.urlsafe_b64encode(hmac_digest).decode('utf-8').strip()
normalized_signature_lower = normalized_signature.lower()
if hmac.compare_digest(normalized_signature_lower, expected_hex_signature.lower()):
return True
normalized_signature_no_padding = normalized_signature.rstrip('=')
if hmac.compare_digest(normalized_signature_no_padding, expected_base64_signature.rstrip('=')):
return True
if hmac.compare_digest(normalized_signature_no_padding, expected_urlsafe_base64_signature.rstrip('=')):
return True
logger.error("Неверная подпись Mulen Pay webhook")
return False
authorization_header = request.headers.get('Authorization')
if authorization_header:
scheme, _, value = authorization_header.partition(' ')
scheme_lower = scheme.lower()
token = value.strip() if value else scheme.strip()
if scheme_lower in ('bearer', 'token'):
if hmac.compare_digest(token, secret_key):
return True
logger.error("Неверный %s токен Mulen Pay webhook", scheme)
return False
if not value and hmac.compare_digest(token, secret_key):
return True
fallback_token = WebhookServer._extract_mulenpay_header(
request,
(
'X-MulenPay-Token',
'X-Mulenpay-Token',
'X-Webhook-Token',
)
)
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.debug(
"Mulen Pay webhook headers received: %s",
{key: value for key, value in request.headers.items() if 'authorization' not in key.lower()}
)
logger.error("Отсутствует подпись Mulen Pay webhook")
return False
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+154 -15
View File
@@ -10,13 +10,18 @@ from app.keyboards.admin import (
get_admin_users_submenu_keyboard,
get_admin_promo_submenu_keyboard,
get_admin_communications_submenu_keyboard,
get_admin_support_submenu_keyboard,
get_admin_settings_submenu_keyboard,
get_admin_system_submenu_keyboard
)
from app.localization.texts import get_texts
from app.handlers.admin import support_settings as support_settings_handlers
from app.utils.decorators import admin_required, error_handler
from app.services.support_settings_service import SupportSettingsService
from app.database.crud.rules import clear_all_rules, get_rules_statistics
from app.localization.texts import clear_rules_cache
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.database.crud.ticket import TicketCRUD
logger = logging.getLogger(__name__)
@@ -66,10 +71,10 @@ async def show_users_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"👥 **Управление пользователями и подписками**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_USERS_SUBMENU_TITLE", "👥 **Управление пользователями и подписками**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -84,10 +89,10 @@ async def show_promo_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"💰 **Промокоды и статистика**\n\n"
"Выберите нужный раздел:",
texts.t("ADMIN_PROMO_SUBMENU_TITLE", "💰 **Промокоды и статистика**\n\n") +
texts.t("ADMIN_SUBMENU_SELECT_SECTION", "Выберите нужный раздел:"),
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -102,16 +107,135 @@ async def show_communications_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"📨 **Коммуникации**\n\n"
"Управление рассылками и текстами интерфейса:",
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_TITLE", "📨 **Коммуникации**\n\n") +
texts.t("ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION", "Управление рассылками и текстами интерфейса:"),
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
await callback.answer()
@admin_required
@error_handler
async def show_support_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# Moderators have access only to tickets and not to settings
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
kb = get_admin_support_submenu_keyboard(db_user.language)
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_SUBMENU_TITLE", "🛟 **Поддержка**\n\n") + (
texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR", "Доступ к тикетам.")
if is_moderator_only
else texts.t("ADMIN_SUPPORT_SUBMENU_DESCRIPTION", "Управление тикетами и настройками поддержки:")
),
reply_markup=kb,
parse_mode="Markdown"
)
await callback.answer()
# Moderator panel entry (from main menu quick button)
async def show_moderator_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=texts.t("ADMIN_SUPPORT_TICKETS", "🎫 Тикеты поддержки"), callback_data="admin_tickets")],
[InlineKeyboardButton(text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"), callback_data="back_to_menu")]
])
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_MODERATION_TITLE", "🧑‍⚖️ <b>Модерация поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_MODERATION_DESCRIPTION", "Доступ к тикетам поддержки."),
parse_mode="HTML",
reply_markup=kb
)
await callback.answer()
@admin_required
@error_handler
async def show_support_audit(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
per_page = 10
total = await TicketCRUD.count_support_audit(db)
total_pages = max(1, (total + per_page - 1) // per_page)
if page < 1:
page = 1
if page > total_pages:
page = total_pages
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
lines = [texts.t("ADMIN_SUPPORT_AUDIT_TITLE", "🧾 <b>Аудит модераторов</b>"), ""]
if not logs:
lines.append(texts.t("ADMIN_SUPPORT_AUDIT_EMPTY", "Пока пусто"))
else:
for log in logs:
role = (
texts.t("ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR", "Модератор")
if getattr(log, 'is_moderator', False)
else texts.t("ADMIN_SUPPORT_AUDIT_ROLE_ADMIN", "Админ")
)
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
'close_ticket': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET", "Закрытие тикета"),
'block_user_timed': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED", "Блокировка (время)"),
'block_user_perm': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM", "Блокировка (навсегда)"),
'unblock_user': texts.t("ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK", "Снятие блока"),
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
details = log.details or {}
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
nav_row = []
if total_pages > 1:
if page > 1:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
if page < total_pages:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
kb_rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
await callback.answer()
@admin_required
@error_handler
async def show_settings_submenu(
@@ -120,10 +244,10 @@ async def show_settings_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"⚙️ **Настройки системы**\n\n"
"Управление Remnawave, мониторингом и другими настройками:",
texts.t("ADMIN_SETTINGS_SUBMENU_TITLE", "⚙️ **Настройки системы**\n\n") +
texts.t("ADMIN_SETTINGS_SUBMENU_DESCRIPTION", "Управление Remnawave, мониторингом и другими настройками:"),
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -138,10 +262,10 @@ async def show_system_submenu(
db: AsyncSession
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
"🛠️ **Системные функции**\n\n"
"Обновления, резервные копии и системные операции:",
texts.t("ADMIN_SYSTEM_SUBMENU_TITLE", "🛠️ **Системные функции**\n\n") +
texts.t("ADMIN_SYSTEM_SUBMENU_DESCRIPTION", "Отчеты, обновления, логи, резервные копии и системные операции:"),
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
parse_mode="Markdown"
)
@@ -284,6 +408,15 @@ def register_handlers(dp: Dispatcher):
F.data == "admin_submenu_communications"
)
dp.callback_query.register(
show_support_submenu,
F.data == "admin_submenu_support"
)
dp.callback_query.register(
show_support_audit,
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
)
dp.callback_query.register(
show_settings_submenu,
F.data == "admin_submenu_settings"
@@ -293,6 +426,12 @@ def register_handlers(dp: Dispatcher):
show_system_submenu,
F.data == "admin_submenu_system"
)
dp.callback_query.register(
show_moderator_panel,
F.data == "moderator_panel"
)
# Support settings module
support_settings_handlers.register_handlers(dp)
dp.message.register(
clear_rules_command,
+256 -170
View File
@@ -9,12 +9,20 @@ from sqlalchemy import select, func, and_, or_
from app.config import settings
from app.states import AdminStates
from app.database.models import User, UserStatus, Subscription, BroadcastHistory
from app.database.models import (
User,
UserStatus,
Subscription,
SubscriptionStatus,
BroadcastHistory,
)
from app.keyboards.admin import (
get_admin_messages_keyboard, get_broadcast_target_keyboard,
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
get_broadcast_button_config, get_broadcast_button_labels
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
@@ -23,69 +31,41 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="btn_balance"),
types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text="🎫 Промокод", callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
balance_text = "✅ Пополнить баланс" if "balance" in selected_buttons else "💰 Пополнить баланс"
referrals_text = "✅ Рефералы" if "referrals" in selected_buttons else "🤝 Рефералы"
promocode_text = "✅ Промокод" if "promocode" in selected_buttons else "🎫 Промокод"
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text=balance_text, callback_data="btn_balance"),
types.InlineKeyboardButton(text=referrals_text, callback_data="btn_referrals")
],
[
types.InlineKeyboardButton(text=promocode_text, callback_data="btn_promocode")
],
[
types.InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
],
[
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
]
])
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
def create_broadcast_keyboard(selected_buttons: list) -> types.InlineKeyboardMarkup:
keyboard = []
button_row = []
if "balance" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="balance_topup"))
if "referrals" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🤝 Рефералы", callback_data="menu_referrals"))
if "promocode" in selected_buttons:
button_row.append(types.InlineKeyboardButton(text="🎫 Промокод", callback_data="menu_promocode"))
if len(button_row) > 2:
keyboard.append(button_row[:2])
if len(button_row) > 2:
keyboard.append(button_row[2:])
elif button_row:
keyboard.append(button_row)
keyboard.append([types.InlineKeyboardButton(text="🏠 На главную", callback_data="back_to_menu")])
def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> Optional[types.InlineKeyboardMarkup]:
selected_buttons = selected_buttons or []
keyboard: list[list[types.InlineKeyboardButton]] = []
button_config_map = get_broadcast_button_config(language)
for row in BUTTON_ROWS:
row_buttons: list[types.InlineKeyboardButton] = []
for button_key in row:
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
row_buttons.append(
types.InlineKeyboardButton(
text=button_config["text"],
callback_data=button_config["callback"]
)
)
if row_buttons:
keyboard.append(row_buttons)
if not keyboard:
return None
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -173,13 +153,16 @@ async def show_messages_history(
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
import html
message_preview = html.escape(message_preview)
text += f"""
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
🎯 Аудитория: {get_target_name(broadcast.target_type)}
👤 Админ: {broadcast.admin_name}
📝 Сообщение: <i>{message_preview}</i>
📝 Сообщение: {message_preview}
"""
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
@@ -187,7 +170,7 @@ async def show_messages_history(
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
parse_mode="HTML"
)
await callback.answer()
@@ -282,14 +265,21 @@ async def select_broadcast_target(
state: FSMContext,
db: AsyncSession
):
target = callback.data.split('_')[-1]
raw_target = callback.data[len("broadcast_"):]
target_aliases = {
"no_sub": "no",
}
target = target_aliases.get(raw_target, raw_target)
target_names = {
"all": "Всем пользователям",
"active": "С активной подпиской",
"trial": "С триальной подпиской",
"trial": "С триальной подпиской",
"no": "Без подписки",
"expiring": "С истекающей подпиской"
"expiring": "С истекающей подпиской",
"expired": "С истекшей подпиской",
"active_zero": "Активная подписка, трафик 0 ГБ",
"trial_zero": "Триальная подписка, трафик 0 ГБ",
}
user_count = await get_target_users_count(db, target)
@@ -344,13 +334,13 @@ async def handle_media_selection(
db_user: User,
state: FSMContext
):
media_type = callback.data.replace('add_media_', '')
if media_type == "skip":
if callback.data == "skip_media":
await state.update_data(has_media=False)
await show_button_selector_callback(callback, db_user, state)
return
media_type = callback.data.replace('add_media_', '')
media_instructions = {
"photo": "📷 Отправьте фотографию для рассылки:",
"video": "🎥 Отправьте видео для рассылки:",
@@ -418,17 +408,31 @@ async def show_media_preview(
):
data = await state.get_data()
media_type = data.get('media_type')
media_file_id = data.get('media_file_id')
preview_text = f"🖼️ <b>Медиафайл добавлен</b>\n\n" \
f"📎 <b>Тип:</b> {media_type}\n" \
f"✅ Файл сохранен и готов к отправке\n\n" \
f"Что делать дальше?"
await message.answer(
preview_text,
reply_markup=get_media_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
# Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа
# чтобы показать именно загруженное фото
from app.utils.message_patch import _original_answer
if media_type == "photo" and media_file_id:
# Показываем предпросмотр с загруженным фото
await message.bot.send_photo(
chat_id=message.chat.id,
photo=media_file_id,
caption=preview_text,
reply_markup=get_media_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
else:
# Для других типов медиа или если нет фото, используем обычное сообщение
await _original_answer(message, preview_text,
reply_markup=get_media_confirm_keyboard(db_user.language),
parse_mode="HTML")
@admin_required
@error_handler
@@ -478,7 +482,11 @@ async def show_button_selector_callback(
):
data = await state.get_data()
has_media = data.get('has_media', False)
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
media_info = ""
if has_media:
@@ -490,11 +498,14 @@ async def show_button_selector_callback(
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> поможет подключить приложение
📱 <b>Подписка</b> покажет состояние подписки
🛠 <b>Техподдержка</b> свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.{media_info}
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{media_info}
Выберите нужные кнопки и нажмите "Продолжить":
"""
@@ -518,28 +529,38 @@ async def show_button_selector(
db_user: User,
state: FSMContext
):
data = await state.get_data()
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
💰 <b>Пополнить баланс</b> откроет методы пополнения
🤝 <b>Партнерка</b> откроет реферальную программу
🎫 <b>Промокод</b> откроет форму ввода промокода
🔗 <b>Подключиться</b> поможет подключить приложение
📱 <b>Подписка</b> покажет состояние подписки
🛠 <b>Техподдержка</b> свяжет с поддержкой
Кнопка "🏠 На главную" добавляется автоматически внизу.
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
if 'selected_buttons' not in data:
data['selected_buttons'] = []
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await message.answer(
text,
reply_markup=get_message_buttons_selector_keyboard(db_user.language),
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -553,18 +574,24 @@ async def toggle_button_selection(
):
button_type = callback.data.replace('btn_', '')
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
else:
selected_buttons = list(selected_buttons)
if button_type in selected_buttons:
selected_buttons.remove(button_type)
else:
selected_buttons.append(button_type)
data['selected_buttons'] = selected_buttons
await state.set_data(data)
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
@@ -580,7 +607,10 @@ async def confirm_button_selection(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
@@ -596,18 +626,13 @@ async def confirm_button_selection(
}
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
buttons_info = ""
if selected_buttons:
buttons_list = []
if "balance" in selected_buttons:
buttons_list.append("💰 Пополнить баланс")
if "referrals" in selected_buttons:
buttons_list.append("🤝 Рефералы")
if "promocode" in selected_buttons:
buttons_list.append("🎫 Промокод")
buttons_info = f"\n📘 <b>Дополнительные кнопки:</b> {', '.join(buttons_list)}"
buttons_info += "\n🏠 <b>Основная кнопка:</b> На главную"
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
button_labels = get_broadcast_button_labels(db_user.language)
selected_names = [button_labels[key] for key in ordered_keys if key in selected_buttons]
if selected_names:
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
else:
buttons_info = "\n📘 <b>Кнопки:</b> отсутствуют"
preview_text = f"""
📨 <b>Предварительный просмотр рассылки</b>
@@ -639,48 +664,35 @@ async def confirm_button_selection(
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
])
await callback.message.edit_text(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
# Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение
if has_media and media_type == "photo":
media_file_id = data.get('media_file_id')
if media_file_id:
# Удаляем текущее сообщение и отправляем новое с фото
await callback.message.delete()
await callback.bot.send_photo(
chat_id=callback.message.chat.id,
photo=media_file_id,
caption=preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
else:
# Если нет file_id, используем обычное редактирование
await callback.message.edit_text(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
else:
# Для текстовых сообщений или других типов медиа используем обычное редактирование
await callback.message.edit_text(
preview_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_button_selector_callback(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
text = """
📘 <b>Выбор дополнительных кнопок</b>
Выберите кнопки, которые будут добавлены к сообщению рассылки:
💰 <b>Пополнить баланс</b> - откроет методы пополнения
🤝 <b>Рефералы</b> - откроет реферальную программу
🎫 <b>Промокод</b> - откроет форму ввода промокода
Кнопка "🏠 На главную" добавляется автоматически внизу.
Выберите нужные кнопки и нажмите "Продолжить":
"""
data = await state.get_data()
selected_buttons = data.get('selected_buttons', [])
keyboard = get_updated_message_buttons_selector_keyboard(selected_buttons, db_user.language)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def confirm_broadcast(
@@ -692,7 +704,9 @@ async def confirm_broadcast(
data = await state.get_data()
target = data.get('broadcast_target')
message_text = data.get('broadcast_message')
selected_buttons = data.get('selected_buttons', [])
selected_buttons = data.get('selected_buttons')
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
has_media = data.get('has_media', False)
media_type = data.get('media_type')
media_file_id = data.get('media_file_id')
@@ -731,7 +745,7 @@ async def confirm_broadcast(
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons) if selected_buttons else None
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
try:
@@ -816,22 +830,88 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
async def get_target_users(db: AsyncSession, target: str) -> list:
if target == "all":
return await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
elif target == "active":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if user.subscription and user.subscription.is_active and not user.subscription.is_trial]
elif target == "trial":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if user.subscription and user.subscription.is_trial]
elif target == "no":
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
return [user for user in users if not user.subscription or not user.subscription.is_active]
elif target == "expiring":
expiring_subs = await get_expiring_subscriptions(db, 3)
return [sub.user for sub in expiring_subs if sub.user]
else:
return []
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
if target == "all":
return users
if target == "active":
return [
user
for user in users
if user.subscription
and user.subscription.is_active
and not user.subscription.is_trial
]
if target == "trial":
return [
user
for user in users
if user.subscription and user.subscription.is_trial
]
if target == "no":
return [
user
for user in users
if not user.subscription or not user.subscription.is_active
]
if target == "expiring":
expiring_subs = await get_expiring_subscriptions(db, 3)
return [sub.user for sub in expiring_subs if sub.user]
if target == "expired":
now = datetime.utcnow()
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
}
expired_users = []
for user in users:
subscription = user.subscription
if subscription:
if subscription.status in expired_statuses:
expired_users.append(user)
continue
if subscription.end_date <= now and not subscription.is_active:
expired_users.append(user)
continue
elif user.has_had_paid_subscription:
expired_users.append(user)
return expired_users
if target == "active_zero":
return [
user
for user in users
if user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
and (user.subscription.traffic_used_gb or 0) <= 0
]
if target == "trial_zero":
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.is_active
and (user.subscription.traffic_used_gb or 0) <= 0
]
if target == "zero":
return [
user
for user in users
if user.subscription
and user.subscription.is_active
and (user.subscription.traffic_used_gb or 0) <= 0
]
return []
async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
@@ -955,7 +1035,12 @@ def get_target_name(target_type: str) -> str:
"active": "С активной подпиской",
"trial": "С триальной подпиской",
"no": "Без подписки",
"sub": "Без подписки",
"expiring": "С истекающей подпиской",
"expired": "С истекшей подпиской",
"active_zero": "Активная подписка, трафик 0 ГБ",
"trial_zero": "Триальная подписка, трафик 0 ГБ",
"zero": "Подписка, трафик 0 ГБ",
"custom_today": "Зарегистрированные сегодня",
"custom_week": "Зарегистрированные за неделю",
"custom_month": "Зарегистрированные за месяц",
@@ -985,7 +1070,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_") | F.data.in_(["skip_media"]))
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
dp.callback_query.register(handle_change_media, F.data == "change_media")
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
+809 -40
View File
@@ -2,24 +2,385 @@ import asyncio
import logging
from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.exceptions import TelegramBadRequest
from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.states import AdminStates
logger = logging.getLogger(__name__)
router = Router()
def _format_toggle(enabled: bool) -> str:
return "🟢 Вкл" if enabled else "🔴 Выкл"
def _build_notification_settings_view(language: str):
texts = get_texts(language)
config = NotificationSettingsService.get_config()
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
trial_channel_status = _format_toggle(
config["trial_channel_unsubscribed"].get("enabled", True)
)
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
summary_text = (
"🔔 <b>Уведомления пользователям</b>\n\n"
f"• 1 час после триала: {trial_1h_status}\n"
f"• 24 часа после триала: {trial_24h_status}\n"
f"• Отписка от канала: {trial_channel_status}\n"
f"• 1 день после истечения: {expired_1d_status}\n"
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
f"{third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
[InlineKeyboardButton(text=f"{third_wave_status}{third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
])
return summary_text, keyboard
def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now()
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
header = "🧪 <b>Тестовое уведомление мониторинга</b>\n\n"
if notification_type == "trial_inactive_1h":
template = texts.get(
"TRIAL_INACTIVE_1H",
(
"⏳ <b>Прошёл час, а подключения нет</b>\n\n"
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "trial_inactive_24h":
template = texts.get(
"TRIAL_INACTIVE_24H",
(
"⏳ <b>Вы ещё не подключились к VPN</b>\n\n"
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
"\n\nНажмите кнопку ниже, чтобы подключиться."
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "trial_channel_unsubscribed":
template = texts.get(
"TRIAL_CHANNEL_UNSUBSCRIBED",
(
"🚫 <b>Доступ приостановлен</b>\n\n"
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
),
)
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data="sub_channel_check",
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
elif notification_type == "expired_1d":
template = texts.get(
"SUBSCRIPTION_EXPIRED_1D",
(
"⛔ <b>Подписка закончилась</b>\n\n"
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
),
)
message = template.format(
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
price=price_30_days,
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🎁 Получить скидку",
callback_data="claim_discount_preview",
)
],
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "expired_nd":
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. "
"Скидка действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🎁 Получить скидку",
callback_data="claim_discount_preview",
)
],
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
else:
raise ValueError(f"Unsupported notification type: {notification_type}")
footer = "\n\n<i>Сообщение отправлено только вам для проверки оформления.</i>"
return header + message + footer, keyboard
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
message, keyboard = _build_notification_preview_message(language, notification_type)
await bot.send_message(
chat_id,
message,
parse_mode="HTML",
reply_markup=keyboard,
)
async def _render_notification_settings(callback: CallbackQuery) -> None:
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
text, keyboard = _build_notification_settings_view(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
async def _render_notification_settings_for_state(
bot,
chat_id: int,
message_id: int,
language: str,
business_connection_id: str | None = None,
) -> None:
text, keyboard = _build_notification_settings_view(language)
edit_kwargs = {
"text": text,
"chat_id": chat_id,
"message_id": message_id,
"parse_mode": "HTML",
"reply_markup": keyboard,
}
if business_connection_id:
edit_kwargs["business_connection_id"] = business_connection_id
try:
await bot.edit_message_text(**edit_kwargs)
except TelegramBadRequest as exc:
if "no text in the message to edit" in (exc.message or "").lower():
caption_kwargs = {
"chat_id": chat_id,
"message_id": message_id,
"caption": text,
"parse_mode": "HTML",
"reply_markup": keyboard,
}
if business_connection_id:
caption_kwargs["business_connection_id"] = business_connection_id
await bot.edit_message_caption(**caption_kwargs)
else:
raise
@router.callback_query(F.data == "admin_monitoring")
@admin_required
async def admin_monitoring_menu(callback: CallbackQuery):
"""Главное меню мониторинга"""
try:
async for db in get_db():
status = await monitoring_service.get_monitoring_status(db)
@@ -43,7 +404,8 @@ async def admin_monitoring_menu(callback: CallbackQuery):
🔧 Выберите действие:
"""
keyboard = get_monitoring_keyboard()
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
keyboard = get_monitoring_keyboard(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -52,6 +414,287 @@ async def admin_monitoring_menu(callback: CallbackQuery):
await callback.answer("❌ Ошибка получения данных", show_alert=True)
@router.callback_query(F.data == "admin_mon_settings")
@admin_required
async def admin_monitoring_settings(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
text = (
"⚙️ <b>Настройки мониторинга</b>\n\n"
f"🔔 <b>Уведомления пользователям:</b> {global_status}\n"
f"• Скидка 2-3 дня: {second_percent}%\n"
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
"Выберите раздел для настройки."
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_settings")
@admin_required
async def admin_notify_settings(callback: CallbackQuery):
try:
await _render_notification_settings(callback)
except Exception as e:
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial 1h preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial 24h preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
@admin_required
async def preview_trial_channel_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial channel preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
@admin_required
async def toggle_expired_1d_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_expired_1d_enabled()
NotificationSettingsService.set_expired_1d_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
@admin_required
async def preview_expired_1d_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send expired 1d preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
@admin_required
async def toggle_second_wave_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_second_wave_enabled()
NotificationSettingsService.set_second_wave_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
@admin_required
async def preview_second_wave_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send second wave preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
@admin_required
async def toggle_third_wave_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_third_wave_enabled()
NotificationSettingsService.set_third_wave_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
@admin_required
async def preview_third_wave_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send third wave preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_preview_all")
@admin_required
async def preview_all_notifications(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
"trial_inactive_1h",
"trial_inactive_24h",
"trial_channel_unsubscribed",
"expired_1d",
"expired_2d",
"expired_nd",
]:
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
await callback.answer("✅ Все тестовые уведомления отправлены")
except Exception as exc:
logger.error("Failed to send all notification previews: %s", exc)
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
async def _start_notification_value_edit(
callback: CallbackQuery,
state: FSMContext,
setting_key: str,
field: str,
prompt_key: str,
default_prompt: str,
):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await state.set_state(AdminStates.editing_notification_value)
await state.update_data(
notification_setting_key=setting_key,
notification_setting_field=field,
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
settings_business_connection_id=(
str(getattr(callback.message, "business_connection_id", None))
if getattr(callback.message, "business_connection_id", None) is not None
else None
),
settings_language=language,
)
texts = get_texts(language)
await callback.answer()
await callback.message.answer(texts.get(prompt_key, default_prompt))
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
@admin_required
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_second_wave",
"percent",
"NOTIFY_PROMPT_SECOND_PERCENT",
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
@admin_required
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_second_wave",
"hours",
"NOTIFY_PROMPT_SECOND_HOURS",
"Введите количество часов действия скидки (1-168):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
@admin_required
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"percent",
"NOTIFY_PROMPT_THIRD_PERCENT",
"Введите новый процент скидки для позднего предложения (0-100):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
@admin_required
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"hours",
"NOTIFY_PROMPT_THIRD_HOURS",
"Введите количество часов действия скидки (1-168):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
@admin_required
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"trigger",
"NOTIFY_PROMPT_THIRD_DAYS",
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
)
@router.callback_query(F.data == "admin_mon_start")
@admin_required
async def start_monitoring_callback(callback: CallbackQuery):
@@ -127,50 +770,57 @@ async def force_check_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_logs")
@router.callback_query(F.data.startswith("admin_mon_logs"))
@admin_required
async def monitoring_logs_callback(callback: CallbackQuery):
try:
page = 1
if "_page_" in callback.data:
page = int(callback.data.split("_page_")[1])
async for db in get_db():
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
if not logs:
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
else:
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
if not all_logs:
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
keyboard = get_monitoring_logs_back_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
return
per_page = 8
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
for log in paginated_logs.items:
icon = "" if log['is_success'] else ""
time_str = log['created_at'].strftime('%m-%d %H:%M')
event_type = log['event_type'].replace('_', ' ').title()
for log in logs:
icon = "" if log['is_success'] else ""
time_str = log['created_at'].strftime('%m-%d %H:%M')
event_type = log['event_type'].replace('_', ' ').title()
text += f"{icon} <code>{time_str}</code> {event_type}\n"
message = log['message']
if len(message) > 60:
message = message[:60] + "..."
text += f" 📄 {message}\n\n"
if len(text) > 3500:
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
break
message = log['message']
if len(message) > 45:
message = message[:45] + "..."
text += f"{icon} <code>{time_str}</code> {event_type}\n"
text += f" 📄 {message}\n\n"
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
total_success = sum(1 for log in all_logs if log['is_success'])
total_failed = len(all_logs) - total_success
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
text += f"📊 <b>Общая статистика:</b>\n"
text += f"• Всего событий: {len(all_logs)}\n"
text += f"• Успешных: {total_success}\n"
text += f"• Ошибок: {total_failed}\n"
text += f"• Успешность: {success_rate}%"
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка получения логов: {e}")
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
await callback.answer("❌ Ошибка получения логов", show_alert=True)
@router.callback_query(F.data == "admin_mon_clear_logs")
@@ -178,12 +828,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
async def clear_logs_callback(callback: CallbackQuery):
try:
async for db in get_db():
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
if deleted_count > 0:
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
else:
await callback.answer("Нет старых логов для удаления")
await callback.answer("Логи уже пусты")
await monitoring_logs_callback(callback)
break
@@ -196,7 +846,6 @@ async def clear_logs_callback(callback: CallbackQuery):
@router.callback_query(F.data == "admin_mon_test_notifications")
@admin_required
async def test_notifications_callback(callback: CallbackQuery):
"""Тест системы уведомлений"""
try:
test_message = f"""
🧪 <b>Тестовое уведомление системы мониторинга</b>
@@ -280,10 +929,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = []
if total_pages > 1:
nav_row = []
if current_page > 1:
nav_row.append(InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_mon_logs_page_{current_page - 1}"
))
nav_row.append(InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="current_page"
))
if current_page < total_pages:
nav_row.append(InlineKeyboardButton(
text="➡️",
callback_data=f"admin_mon_logs_page_{current_page + 1}"
))
keyboard.append(nav_row)
keyboard.extend([
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_monitoring_logs_back_keyboard():
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
],
[
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
@router.message(Command("monitoring"))
@admin_required
async def monitoring_command(message: Message):
"""Команда /monitoring для быстрого доступа"""
try:
async for db in get_db():
status = await monitoring_service.get_monitoring_status(db)
@@ -308,6 +1009,74 @@ async def monitoring_command(message: Message):
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(AdminStates.editing_notification_value)
async def process_notification_value_input(message: Message, state: FSMContext):
data = await state.get_data()
if not data:
await state.clear()
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
return
raw_value = (message.text or "").strip()
try:
value = int(raw_value)
except (TypeError, ValueError):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
return
key = data.get("notification_setting_key")
field = data.get("notification_setting_field")
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
elif key == "expired_second_wave" and field == "hours":
success = NotificationSettingsService.set_second_wave_valid_hours(value)
elif key == "expired_third_wave" and field == "percent":
success = NotificationSettingsService.set_third_wave_discount_percent(value)
elif key == "expired_third_wave" and field == "hours":
success = NotificationSettingsService.set_third_wave_valid_hours(value)
elif key == "expired_third_wave" and field == "trigger":
success = NotificationSettingsService.set_third_wave_trigger_days(value)
if not success:
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
return
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.get("BACK", "⬅️ Назад"),
callback_data="admin_mon_notify_settings",
)
]
]
)
await message.answer(
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
reply_markup=back_keyboard,
)
chat_id = data.get("settings_message_chat")
message_id = data.get("settings_message_id")
business_connection_id = data.get("settings_business_connection_id")
if chat_id and message_id:
await _render_notification_settings_for_state(
message.bot,
chat_id,
message_id,
language,
business_connection_id=business_connection_id,
)
await state.clear()
def register_handlers(dp):
"""Регистрация обработчиков мониторинга"""
dp.include_router(router)
File diff suppressed because it is too large Load Diff
+14 -6
View File
@@ -30,14 +30,22 @@ async def show_remnawave_menu(
):
remnawave_service = RemnaWaveService()
connection_test = await remnawave_service.test_api_connection()
status_emoji = "" if connection_test["status"] == "connected" else ""
status = connection_test.get("status")
if status == "connected":
status_emoji = ""
elif status == "not_configured":
status_emoji = ""
else:
status_emoji = ""
api_url_display = settings.REMNAWAVE_API_URL or ""
text = f"""
🖥 <b>Управление Remnawave</b>
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
📡 <b>Соединение:</b> {status_emoji} {connection_test.get("message", "Нет данных")}
🌐 <b>URL:</b> <code>{api_url_display}</code>
Выберите действие:
"""
@@ -194,7 +202,7 @@ async def show_traffic_stats(
remnawave_service = RemnaWaveService()
try:
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
bandwidth_stats = await api.get_bandwidth_stats()
realtime_usage = await api.get_nodes_realtime_usage()
+117
View File
@@ -0,0 +1,117 @@
import logging
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.keyboards.admin import (
get_admin_report_result_keyboard,
get_admin_reports_keyboard,
)
from app.localization.texts import get_texts
from app.services.reporting_service import (
ReportPeriod,
ReportingServiceError,
reporting_service,
)
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_reports_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await callback.message.edit_text(
"📊 <b>Отчеты</b>\n\n"
"Выберите период, чтобы отправить отчет в админский топик.",
reply_markup=get_admin_reports_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def send_daily_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.DAILY, db_user.language)
@admin_required
@error_handler
async def send_weekly_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.WEEKLY, db_user.language)
@admin_required
@error_handler
async def send_monthly_report(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
await _send_report(callback, ReportPeriod.MONTHLY, db_user.language)
async def _send_report(
callback: types.CallbackQuery,
period: ReportPeriod,
language: str,
) -> None:
try:
report_text = await reporting_service.send_report(period, send_to_topic=True)
except ReportingServiceError as exc:
logger.warning("Не удалось отправить отчет: %s", exc)
await callback.answer(str(exc), show_alert=True)
return
except Exception as exc: # noqa: BLE001
logger.error("Непредвиденная ошибка при отправке отчета: %s", exc)
await callback.answer("Не удалось отправить отчет. Попробуйте позже.", show_alert=True)
return
await callback.message.answer(
report_text,
reply_markup=get_admin_report_result_keyboard(language),
)
await callback.answer("Отчет отправлен в топик")
@admin_required
@error_handler
async def close_report_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
try:
await callback.message.delete()
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.warning("Не удалось закрыть сообщение отчета: %s", exc)
await callback.answer(texts.t("REPORT_CLOSE_ERROR", "Не удалось закрыть отчет."), show_alert=True)
return
await callback.answer(texts.t("REPORT_CLOSED", "Отчет закрыт."))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(show_reports_menu, F.data == "admin_reports")
dp.callback_query.register(send_daily_report, F.data == "admin_reports_daily")
dp.callback_query.register(send_weekly_report, F.data == "admin_reports_weekly")
dp.callback_query.register(send_monthly_report, F.data == "admin_reports_monthly")
dp.callback_query.register(close_report_message, F.data == "admin_close_report")
+437 -114
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -6,10 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.states import AdminStates
from app.database.models import User
from app.database.crud.server_squad import (
get_all_server_squads, get_server_squad_by_id, update_server_squad,
delete_server_squad, sync_with_remnawave, get_server_statistics,
create_server_squad, get_available_server_squads
get_all_server_squads,
get_server_squad_by_id,
update_server_squad,
delete_server_squad,
sync_with_remnawave,
get_server_statistics,
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
get_server_connected_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
from app.utils.decorators import admin_required, error_handler
from app.utils.cache import cache
@@ -17,6 +26,116 @@ from app.utils.cache import cache
logger = logging.getLogger(__name__)
def _build_server_edit_view(server):
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
promo_groups_text = (
", ".join(sorted(pg.name for pg in server.allowed_promo_groups))
if server.allowed_promo_groups
else "Не выбраны"
)
text = f"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
Цена: {price_text}
Код страны: {server.country_code or 'Не указан'}
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
Промогруппы: {promo_groups_text}
<b>Описание:</b>
{server.description or 'Не указано'}
Выберите что изменить:
"""
keyboard = [
[
types.InlineKeyboardButton(
text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"
),
types.InlineKeyboardButton(
text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"
),
types.InlineKeyboardButton(
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
),
types.InlineKeyboardButton(
text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server.id}",
)
],
[
types.InlineKeyboardButton(
text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"
),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list"),
],
]
return text, types.InlineKeyboardMarkup(inline_keyboard=keyboard)
def _build_server_promo_groups_keyboard(server_id: int, promo_groups, selected_ids):
keyboard = []
for group in promo_groups:
emoji = "" if group["id"] in selected_ids else ""
keyboard.append(
[
types.InlineKeyboardButton(
text=f"{emoji} {group['name']}",
callback_data=f"admin_server_promo_toggle_{server_id}_{group['id']}",
)
]
)
keyboard.append(
[
types.InlineKeyboardButton(
text="💾 Сохранить", callback_data=f"admin_server_promo_save_{server_id}"
)
]
)
keyboard.append(
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data=f"admin_server_edit_{server_id}"
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@admin_required
@error_handler
async def show_servers_menu(
@@ -164,9 +283,9 @@ async def sync_servers_with_remnawave(
)
return
created, updated, disabled = await sync_with_remnawave(db, squads)
created, updated, removed = await sync_with_remnawave(db, squads)
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
text = f"""
<b>Синхронизация завершена</b>
@@ -174,7 +293,7 @@ async def sync_servers_with_remnawave(
📊 <b>Результаты:</b>
Создано новых серверов: {created}
Обновлено существующих: {updated}
Отключено неактивных: {disabled}
Удалено отсутствующих: {removed}
Всего обработано: {len(squads)}
Новые серверы созданы как недоступные.
@@ -202,7 +321,7 @@ async def sync_servers_with_remnawave(
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@@ -216,65 +335,161 @@ async def show_server_edit_menu(
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
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Статус: {status_emoji}
text, keyboard = _build_server_edit_view(server)
<b>Настройки:</b>
Цена: {price_text}
Код страны: {server.country_code or 'Не указан'}
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
<b>Описание:</b>
{server.description or 'Не указано'}
Выберите что изменить:
"""
keyboard = [
[
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
],
[
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
],
[
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server.id}"
)
],
[
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
]
@admin_required
@error_handler
async def show_server_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
payload = callback.data.split("admin_server_users_", 1)[-1]
payload_parts = payload.split("_")
server_id = int(payload_parts[0])
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
page = max(page, 1)
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
users = await get_server_connected_users(db, server_id)
total_users = len(users)
page_size = 10
total_pages = max((total_users + page_size - 1) // page_size, 1)
if page > total_pages:
page = total_pages
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_users = users[start_index:end_index]
safe_name = html.escape(server.display_name or "")
safe_uuid = html.escape(server.squad_uuid or "")
header = [
"🌐 <b>Пользователи сервера</b>",
"",
f"• Сервер: {safe_name}",
f"• UUID: <code>{safe_uuid}</code>",
f"• Подключений: {total_users}",
]
if total_pages > 1:
header.append(f"• Страница: {page}/{total_pages}")
header.append("")
text = "\n".join(header)
def _get_status_icon(status_text: str) -> str:
if not status_text:
return ""
parts = status_text.split(" ", 1)
return parts[0] if parts else status_text
if users:
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
lines.append(f"{index}. {safe_user_name}")
text += "\n" + "\n".join(lines)
else:
text += "Пользователи не найдены."
keyboard: list[list[types.InlineKeyboardButton]] = []
for user in page_users:
display_name = user.full_name
if len(display_name) > 30:
display_name = display_name[:27] + "..."
subscription_status = (
user.subscription.status_display
if user.subscription
else "❌ Нет подписки"
)
status_icon = _get_status_icon(subscription_status)
if status_icon:
button_text = f"{status_icon} {display_name}"
else:
button_text = display_name
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if total_pages > 1:
navigation_buttons: list[types.InlineKeyboardButton] = []
if page > 1:
navigation_buttons.append(
types.InlineKeyboardButton(
text="⬅️ Предыдущая",
callback_data=f"admin_server_users_{server_id}_{page - 1}",
)
)
navigation_buttons.append(
types.InlineKeyboardButton(
text=f"Стр. {page}/{total_pages}",
callback_data=f"admin_server_users_{server_id}_{page}",
)
)
if page < total_pages:
navigation_buttons.append(
types.InlineKeyboardButton(
text="Следующая ➡️",
callback_data=f"admin_server_users_{server_id}_{page + 1}",
)
)
keyboard.append(navigation_buttons)
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К списку", callback_data="admin_servers_list"
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
@@ -296,65 +511,18 @@ async def toggle_server_availability(
new_status = not server.is_available
await update_server_squad(db, server_id, is_available=new_status)
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
status_text = "включен" if new_status else "отключен"
await callback.answer(f"✅ Сервер {status_text}!")
server = await get_server_squad_by_id(db, server_id)
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
price_text = f"{int(server.price_rubles)}" if server.price_kopeks > 0 else "Бесплатно"
text = f"""
🌐 <b>Редактирование сервера</b>
text, keyboard = _build_server_edit_view(server)
<b>Информация:</b>
ID: {server.id}
UUID: <code>{server.squad_uuid}</code>
Название: {server.display_name}
Оригинальное: {server.original_name or 'Не указано'}
Статус: {status_emoji}
<b>Настройки:</b>
Цена: {price_text}
Код страны: {server.country_code or 'Не указан'}
Лимит пользователей: {server.max_users or 'Без лимита'}
Текущих пользователей: {server.current_users}
<b>Описание:</b>
{server.description or 'Не указано'}
Выберите что изменить:
"""
keyboard = [
[
types.InlineKeyboardButton(text="✏️ Название", callback_data=f"admin_server_edit_name_{server.id}"),
types.InlineKeyboardButton(text="💰 Цена", callback_data=f"admin_server_edit_price_{server.id}")
],
[
types.InlineKeyboardButton(text="🌍 Страна", callback_data=f"admin_server_edit_country_{server.id}"),
types.InlineKeyboardButton(text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}")
],
[
types.InlineKeyboardButton(text="📝 Описание", callback_data=f"admin_server_edit_desc_{server.id}")
],
[
types.InlineKeyboardButton(
text="❌ Отключить" if server.is_available else "✅ Включить",
callback_data=f"admin_server_toggle_{server.id}"
)
],
[
types.InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_server_delete_{server.id}"),
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers_list")
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
reply_markup=keyboard,
parse_mode="HTML"
)
@@ -422,7 +590,7 @@ async def process_server_price_edit(
if server:
await state.clear()
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
price_text = f"{int(price_rubles)}" if price_kopeks > 0 else "Бесплатно"
await message.answer(
@@ -497,7 +665,7 @@ async def process_server_name_edit(
if server:
await state.clear()
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
await message.answer(
f"✅ Название сервера изменено на: <b>{new_name}</b>",
@@ -570,7 +738,7 @@ async def delete_server_execute(
success = await delete_server_squad(db, server_id)
if success:
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
await callback.message.edit_text(
f"✅ Сервер <b>{server.display_name}</b> успешно удален!",
@@ -701,7 +869,7 @@ async def process_server_country_edit(
if server:
await state.clear()
await cache.delete("available_countries")
await cache.delete_pattern("available_countries*")
country_text = new_country or "Удален"
await message.answer(
@@ -847,11 +1015,12 @@ async def process_server_description_edit(
return
server = await update_server_squad(db, server_id, description=new_description)
if server:
await state.clear()
desc_text = new_description or "Удалено"
await cache.delete_pattern("available_countries*")
await message.answer(
f"✅ Описание сервера изменено:\n\n<i>{desc_text}</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -862,6 +1031,147 @@ async def process_server_description_edit(
else:
await message.answer("❌ Ошибка при обновлении сервера")
@admin_required
@error_handler
async def start_server_edit_promo_groups(
callback: types.CallbackQuery,
state: FSMContext,
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
promo_groups_data = await get_promo_groups_with_counts(db)
promo_groups = [
{"id": group.id, "name": group.name, "is_default": group.is_default}
for group, _ in promo_groups_data
]
if not promo_groups:
await callback.answer("❌ Не найдены промогруппы", show_alert=True)
return
selected_ids = {pg.id for pg in server.allowed_promo_groups}
if not selected_ids:
default_group = next((pg for pg in promo_groups if pg["is_default"]), None)
if default_group:
selected_ids.add(default_group["id"])
await state.set_state(AdminStates.editing_server_promo_groups)
await state.set_data(
{
"server_id": server_id,
"promo_groups": promo_groups,
"selected_promo_groups": list(selected_ids),
"server_name": server.display_name,
}
)
text = (
"🎯 <b>Настройка промогрупп</b>\n\n"
f"Сервер: <b>{server.display_name}</b>\n\n"
"Выберите промогруппы, которым будет доступен этот сервер.\n"
"Должна быть выбрана минимум одна промогруппа."
)
await callback.message.edit_text(
text,
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected_ids),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def toggle_server_promo_group(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
parts = callback.data.split('_')
server_id = int(parts[4])
group_id = int(parts[5])
data = await state.get_data()
if not data or data.get("server_id") != server_id:
await callback.answer("⚠️ Сессия редактирования устарела", show_alert=True)
return
selected = set(int(pg_id) for pg_id in data.get("selected_promo_groups", []))
promo_groups = data.get("promo_groups", [])
if group_id in selected:
if len(selected) == 1:
await callback.answer("⚠️ Нельзя отключить последнюю промогруппу", show_alert=True)
return
selected.remove(group_id)
message = "Промогруппа отключена"
else:
selected.add(group_id)
message = "Промогруппа добавлена"
await state.update_data(selected_promo_groups=list(selected))
await callback.message.edit_reply_markup(
reply_markup=_build_server_promo_groups_keyboard(server_id, promo_groups, selected)
)
await callback.answer(message)
@admin_required
@error_handler
async def save_server_promo_groups(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession,
):
data = await state.get_data()
if not data:
await callback.answer("⚠️ Нет данных для сохранения", show_alert=True)
return
server_id = data.get("server_id")
selected = data.get("selected_promo_groups", [])
if not selected:
await callback.answer("❌ Выберите хотя бы одну промогруппу", show_alert=True)
return
try:
server = await update_server_squad_promo_groups(db, server_id, selected)
except ValueError as exc:
await callback.answer(f"{exc}", show_alert=True)
return
if not server:
await callback.answer("❌ Сервер не найден", show_alert=True)
return
await cache.delete_pattern("available_countries*")
await state.clear()
text, keyboard = _build_server_edit_view(server)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await callback.answer("✅ Промогруппы обновлены!")
@admin_required
@error_handler
async def sync_server_user_counts_handler(
@@ -934,12 +1244,23 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(sync_server_user_counts_handler, F.data == "admin_servers_sync_counts")
dp.callback_query.register(show_server_detailed_stats, F.data == "admin_servers_stats")
dp.callback_query.register(show_server_edit_menu, F.data.startswith("admin_server_edit_") & ~F.data.contains("name") & ~F.data.contains("price") & ~F.data.contains("country") & ~F.data.contains("limit") & ~F.data.contains("desc"))
dp.callback_query.register(
show_server_edit_menu,
F.data.startswith("admin_server_edit_")
& ~F.data.contains("name")
& ~F.data.contains("price")
& ~F.data.contains("country")
& ~F.data.contains("limit")
& ~F.data.contains("desc")
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
dp.callback_query.register(start_server_edit_country, F.data.startswith("admin_server_edit_country_"))
dp.callback_query.register(start_server_edit_promo_groups, F.data.startswith("admin_server_edit_promo_"))
dp.callback_query.register(start_server_edit_limit, F.data.startswith("admin_server_edit_limit_"))
dp.callback_query.register(start_server_edit_description, F.data.startswith("admin_server_edit_desc_"))
@@ -947,7 +1268,9 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_server_price_edit, AdminStates.editing_server_price)
dp.message.register(process_server_country_edit, AdminStates.editing_server_country)
dp.message.register(process_server_limit_edit, AdminStates.editing_server_limit)
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
dp.message.register(process_server_description_edit, AdminStates.editing_server_description)
dp.callback_query.register(toggle_server_promo_group, F.data.startswith("admin_server_promo_toggle_"))
dp.callback_query.register(save_server_promo_groups, F.data.startswith("admin_server_promo_save_"))
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
+468
View File
@@ -0,0 +1,468 @@
import logging
import re
import html
import contextlib
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
from app.services.support_settings_service import SupportSettingsService
from app.states import SupportSettingsStates
logger = logging.getLogger(__name__)
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
texts = get_texts(language)
mode = SupportSettingsService.get_system_mode()
menu_enabled = SupportSettingsService.is_support_menu_enabled()
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
sla_enabled = SupportSettingsService.get_sla_enabled()
sla_minutes = SupportSettingsService.get_sla_minutes()
rows: list[list[types.InlineKeyboardButton]] = []
status_enabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED", "Включены")
status_disabled = texts.t("ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED", "Отключены")
def mode_button(label_key: str, default: str, active: bool) -> str:
prefix = "🔘" if active else ""
return f"{prefix} {texts.t(label_key, default)}"
rows.append([
types.InlineKeyboardButton(
text=(
f"{'' if menu_enabled else '🚫'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_MENU_LABEL', 'Пункт «Техподдержка» в меню')}"
),
callback_data="admin_support_toggle_menu"
)
])
rows.append([
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_TICKETS", "Тикеты", mode == "tickets"),
callback_data="admin_support_mode_tickets"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_CONTACT", "Контакт", mode == "contact"),
callback_data="admin_support_mode_contact"
),
types.InlineKeyboardButton(
text=mode_button("ADMIN_SUPPORT_SETTINGS_MODE_BOTH", "Оба", mode == "both"),
callback_data="admin_support_mode_both"
),
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION", "📝 Изменить описание"),
callback_data="admin_support_edit_desc"
)
])
# Notifications block
rows.append([
types.InlineKeyboardButton(
text=(
f"{'🔔' if admin_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS', 'Админ-уведомления')}: "
f"{status_enabled if admin_notif else status_disabled}"
),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
text=(
f"{'🔔' if user_notif else '🔕'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS', 'Пользовательские уведомления')}: "
f"{status_enabled if user_notif else status_disabled}"
),
callback_data="admin_support_toggle_user_notifications"
)
])
# SLA block
rows.append([
types.InlineKeyboardButton(
text=(
f"{'' if sla_enabled else '⏹️'} "
f"{texts.t('ADMIN_SUPPORT_SETTINGS_SLA_LABEL', 'SLA')}: "
f"{status_enabled if sla_enabled else status_disabled}"
),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_SLA_TIME", "⏳ Время SLA: {minutes} мин").format(minutes=sla_minutes),
callback_data="admin_support_set_sla_minutes"
)
])
# Moderators
moderators = SupportSettingsService.get_moderators()
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT", "🧑‍⚖️ Модераторы: {count}").format(count=mod_count),
callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR", "➕ Назначить модератора"),
callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR", "➖ Удалить модератора"),
callback_data="admin_support_remove_moderator"
)
])
rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
])
return types.InlineKeyboardMarkup(inline_keyboard=rows)
@admin_required
@error_handler
async def show_support_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
desc = SupportSettingsService.get_support_info_text(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_SUPPORT_SETTINGS_TITLE", "🛟 <b>Настройки поддержки</b>") + "\n\n" +
texts.t("ADMIN_SUPPORT_SETTINGS_DESCRIPTION", "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:") + "\n\n" +
desc,
reply_markup=_get_support_settings_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_support_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
current = SupportSettingsService.is_support_menu_enabled()
SupportSettingsService.set_support_menu_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_user_ticket_notifications_enabled()
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_sla_enabled()
SupportSettingsService.set_sla_enabled(not current)
await show_support_settings(callback, db_user, db)
class SupportAdvancedStates(StatesGroup):
waiting_for_sla_minutes = State()
waiting_for_moderator_id = State()
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_SLA_SETUP_PROMPT",
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
await callback.answer()
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
await message.answer(texts.t("ADMIN_SUPPORT_SLA_INVALID", "❌ Введите корректное число минут (1-1440)"))
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(texts.t("ADMIN_SUPPORT_SLA_SAVED", "✅ Значение SLA сохранено"), reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT",
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)"
),
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
# We'll reuse the same state; next message will decide action via flag
await state.update_data(action="remove_moderator")
await callback.answer()
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer(texts.t("ADMIN_SUPPORT_INVALID_TELEGRAM_ID", "❌ Введите корректный Telegram ID (число)"))
return
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS", "✅ Модератор {tid} удалён").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL", "❌ Не удалось удалить модератора")
)
else:
ok = SupportSettingsService.add_moderator(tid)
msg = (
texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS", "✅ Пользователь {tid} назначен модератором").format(tid=tid)
if ok
else texts.t("ADMIN_SUPPORT_MODERATOR_ADDED_FAIL", "❌ Не удалось назначить модератора")
)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
texts = get_texts(db_user.language)
moderators = SupportSettingsService.get_moderators()
if not moderators:
await callback.answer(texts.t("ADMIN_SUPPORT_MODERATORS_EMPTY", "Список пуст"), show_alert=True)
return
text = (
texts.t("ADMIN_SUPPORT_MODERATORS_TITLE", "🧑‍⚖️ <b>Модераторы</b>") +
"\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
)
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@admin_required
@error_handler
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("tickets")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("contact")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
SupportSettingsService.set_system_mode("both")
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
# plain text for display-only code block
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
kb_rows: list[list[types.InlineKeyboardButton]] = []
kb_rows.append([
types.InlineKeyboardButton(
text=texts.t("ADMIN_SUPPORT_SEND_DESCRIPTION", "📨 Прислать текст"),
callback_data="admin_support_send_desc"
)
])
# Подготовим блок контакта (отдельным инлайном)
from app.config import settings
support_contact_display = settings.get_support_contact_display()
kb_rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
])
text_parts = [
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE", "📝 <b>Редактирование описания поддержки</b>"),
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT", "Текущее описание:"),
"",
f"<code>{html.escape(current_desc_plain)}</code>",
]
if support_contact_display:
text_parts += [
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE", "<b>Контакт для режима «Контакт»</b>"),
f"<code>{html.escape(support_contact_display)}</code>",
"",
texts.t("ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT", "Добавьте в описание при необходимости."),
]
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
parse_mode="HTML"
)
await state.set_state(SupportSettingsStates.waiting_for_desc)
await callback.answer()
@admin_required
@error_handler
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
texts = get_texts(db_user.language)
new_text = message.html_text or message.text
SupportSettingsService.set_support_info_text(db_user.language, new_text)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
await message.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_UPDATED", "✅ Описание обновлено."), reply_markup=markup)
@admin_required
@error_handler
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# send plain text for easy copying
texts = get_texts(db_user.language)
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
# attach delete button to the sent message
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data="admin_support_delete_msg")]]
)
if len(current_desc_plain) <= 4000:
await callback.message.answer(current_desc_plain, reply_markup=markup)
else:
# split long messages (attach delete only to the last chunk)
chunk = 0
while chunk < len(current_desc_plain):
next_chunk = current_desc_plain[chunk:chunk+4000]
is_last = (chunk + 4000) >= len(current_desc_plain)
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
chunk += 4000
await callback.answer(texts.t("ADMIN_SUPPORT_DESCRIPTION_SENT", "Текст отправлен ниже"))
@error_handler
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Allow admins and moderators to delete informational notifications
try:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
texts = get_texts(db_user.language if db_user else 'ru')
if not may_delete:
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
with contextlib.suppress(Exception):
await callback.answer(texts.t("ADMIN_SUPPORT_MESSAGE_DELETED", "Сообщение удалено"))
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
dp.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)
+165
View File
@@ -0,0 +1,165 @@
import logging
from datetime import datetime
from html import escape
from pathlib import Path
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
LOG_PREVIEW_LIMIT = 2300
def _resolve_log_path() -> Path:
log_path = Path(settings.LOG_FILE)
if not log_path.is_absolute():
log_path = Path.cwd() / log_path
return log_path
def _format_preview_block(text: str) -> str:
escaped_text = escape(text) if text else ""
return f"<blockquote expandable><pre><code>{escaped_text}</code></pre></blockquote>"
def _build_logs_message(log_path: Path) -> str:
if not log_path.exists():
message = (
"🧾 <b>Системные логи</b>\n\n"
f"Файл <code>{log_path}</code> пока не создан.\n"
"Логи появятся автоматически после первой записи."
)
return message
try:
content = log_path.read_text(encoding="utf-8", errors="ignore")
except Exception as error: # pragma: no cover - защита от проблем чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
message = (
"❌ <b>Ошибка чтения логов</b>\n\n"
f"Не удалось прочитать файл <code>{log_path}</code>."
)
return message
total_length = len(content)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime)
if not content:
preview_text = "Лог-файл пуст."
truncated = False
else:
preview_text = content[-LOG_PREVIEW_LIMIT:]
truncated = total_length > LOG_PREVIEW_LIMIT
details_lines = [
"🧾 <b>Системные логи</b>",
"",
f"📁 <b>Файл:</b> <code>{log_path}</code>",
f"🕒 <b>Обновлен:</b> {updated_at.strftime('%d.%m.%Y %H:%M:%S')}",
f"🧮 <b>Размер:</b> {total_length} символов",
(
f"👇 Показаны последние {LOG_PREVIEW_LIMIT} символов."
if truncated
else "📄 Показано все содержимое файла."
),
"",
_format_preview_block(preview_text),
]
return "\n".join(details_lines)
def _get_logs_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_system_logs_refresh")],
[InlineKeyboardButton(text="⬇️ Скачать лог", callback_data="admin_system_logs_download")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_system")],
]
)
@admin_required
@error_handler
async def show_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
@admin_required
@error_handler
async def refresh_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
message = _build_logs_message(log_path)
reply_markup = _get_logs_keyboard()
await callback.message.edit_text(message, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("🔄 Обновлено")
@admin_required
@error_handler
async def download_system_logs(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
log_path = _resolve_log_path()
if not log_path.exists() or not log_path.is_file():
await callback.answer("❌ Лог-файл не найден", show_alert=True)
return
try:
await callback.answer("⬇️ Отправляю лог...")
document = FSInputFile(log_path)
stats = log_path.stat()
updated_at = datetime.fromtimestamp(stats.st_mtime).strftime("%d.%m.%Y %H:%M:%S")
caption = (
f"🧾 Лог-файл <code>{log_path.name}</code>\n"
f"📁 Путь: <code>{log_path}</code>\n"
f"🕒 Обновлен: {updated_at}"
)
await callback.message.answer_document(document=document, caption=caption, parse_mode="HTML")
except Exception as error: # pragma: no cover - защита от ошибок отправки
logger.error("Ошибка отправки лог-файла %s: %s", log_path, error)
await callback.message.answer(
"❌ <b>Не удалось отправить лог-файл</b>\n\n"
"Проверьте журналы приложения или повторите попытку позже.",
parse_mode="HTML",
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_system_logs,
F.data == "admin_system_logs",
)
dp.callback_query.register(
refresh_system_logs,
F.data == "admin_system_logs_refresh",
)
dp.callback_query.register(
download_system_logs,
F.data == "admin_system_logs_download",
)
File diff suppressed because it is too large Load Diff
+1808 -252
View File
File diff suppressed because it is too large Load Diff
+30 -31
View File
@@ -3,7 +3,6 @@ from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.states import AdminStates
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
@@ -44,16 +43,16 @@ async def show_welcome_text_panel(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"👋 Управление приветственным текстом\n\n"
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
f"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -89,11 +88,11 @@ async def show_current_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
is_enabled = settings['is_enabled']
if not settings['id']:
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
is_enabled = welcome_settings['is_enabled']
if not welcome_settings['id']:
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
@@ -121,7 +120,7 @@ async def show_placeholders_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
@@ -137,7 +136,7 @@ async def show_placeholders_help(
await callback.message.edit_text(
help_text,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -149,12 +148,12 @@ async def show_formatting_help(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
await callback.answer()
@@ -167,8 +166,8 @@ async def start_edit_welcome_text(
db_user: User,
db: AsyncSession
):
settings = await get_current_welcome_text_settings(db)
current_text = settings['text']
welcome_settings = await get_current_welcome_text_settings(db)
current_text = welcome_settings['text']
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
@@ -206,9 +205,9 @@ async def process_welcome_text_edit(
success = await set_welcome_text(db, new_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
@@ -219,14 +218,14 @@ async def process_welcome_text_edit(
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await state.clear()
@@ -242,9 +241,9 @@ async def reset_welcome_text(
success = await set_welcome_text(db, default_text, db_user.id)
if success:
settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
status_text = "включено" if settings['is_enabled'] else "отключено"
welcome_settings = await get_current_welcome_text_settings(db)
status_emoji = "🟢" if welcome_settings['is_enabled'] else "🔴"
status_text = "включено" if welcome_settings['is_enabled'] else "отключено"
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\n\n"
@@ -252,14 +251,14 @@ async def reset_welcome_text(
f"Стандартный текст:\n"
f"<code>{default_text}</code>\n\n"
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled'])
)
await callback.answer()
@@ -281,14 +280,14 @@ async def show_preview_welcome_text(
test_user = TestUser()
preview_text = await get_welcome_text_for_user(db, test_user)
settings = await get_current_welcome_text_settings(db)
welcome_settings = await get_current_welcome_text_settings(db)
if preview_text:
await callback.message.edit_text(
f"👁️ Предварительный просмотр\n\n"
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
f"<code>{preview_text}</code>",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
else:
@@ -296,7 +295,7 @@ async def show_preview_welcome_text(
f"👁️ Предварительный просмотр\n\n"
f"🔴 Приветственные сообщения отключены.\n"
f"Новые пользователи не будут получать приветственный текст после регистрации.",
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
reply_markup=get_welcome_text_keyboard(db_user.language, welcome_settings['is_enabled']),
parse_mode="HTML"
)
+1087 -31
View File
File diff suppressed because it is too large Load Diff
+53 -7
View File
@@ -1,10 +1,11 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.localization.texts import get_texts
from app.localization.texts import get_texts, get_rules
from app.keyboards.inline import get_back_keyboard
logger = logging.getLogger(__name__)
@@ -18,13 +19,36 @@ async def handle_unknown_callback(
texts = get_texts(db_user.language if db_user else "ru")
await callback.answer(
"❓ Неизвестная команда. Попробуйте ещё раз.",
show_alert=True
texts.t(
"UNKNOWN_CALLBACK_ALERT",
"❓ Неизвестная команда. Попробуйте ещё раз.",
),
show_alert=True,
)
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
async def handle_noop(
callback: types.CallbackQuery,
db_user: User
):
try:
await callback.answer()
except Exception:
pass
async def handle_current_page(
callback: types.CallbackQuery,
db_user: User
):
try:
await callback.answer()
except Exception:
pass
async def handle_cancel(
callback: types.CallbackQuery,
state: FSMContext,
@@ -49,8 +73,11 @@ async def handle_unknown_message(
texts = get_texts(db_user.language if db_user else "ru")
await message.answer(
"❓ Не понимаю эту команду. Используйте кнопки меню.",
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
texts.t(
"UNKNOWN_COMMAND_MESSAGE",
"❓ Не понимаю эту команду. Используйте кнопки меню.",
),
reply_markup=get_back_keyboard(db_user.language if db_user else "ru"),
)
@@ -61,8 +88,8 @@ async def show_rules(
):
texts = get_texts(db_user.language)
rules_text = texts.RULES_TEXT
rules_text = await get_rules(db_user.language)
await callback.message.edit_text(
rules_text,
@@ -77,9 +104,28 @@ def register_handlers(dp: Dispatcher):
show_rules,
F.data == "menu_rules"
)
# No-op utility handlers used in many keyboards
dp.callback_query.register(
handle_noop,
F.data == "noop"
)
dp.callback_query.register(
handle_current_page,
F.data == "current_page"
)
dp.callback_query.register(
handle_cancel,
F.data.in_(["cancel", "subscription_cancel"])
)
# Самый последний: ловим любые неизвестные текстовые сообщения
# Исключаем специальные сервисные события (например, успешные платежи),
# чтобы их обработка не прерывалась общим хендлером неизвестных сообщений
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.successful_payment.is_(None)
)
+220 -79
View File
@@ -1,63 +1,72 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.crud.user_message import get_random_active_message
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
should_offer_checkout_resume,
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
logger = logging.getLogger(__name__)
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
*,
skip_callback_answer: bool = False,
):
texts = get_texts(db_user.language)
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
has_active_subscription = bool(db_user.subscription)
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
async def mark_user_as_had_paid_subscription(
db: AsyncSession,
user: User
) -> None:
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
if not skip_callback_answer:
await callback.answer()
async def show_service_rules(
@@ -66,34 +75,106 @@ async def show_service_rules(
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
texts = get_texts(db_user.language)
rules_text = await get_current_rules_content(db, db_user.language)
if not rules_text:
texts = get_texts(db_user.language)
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
📋 <b>Правила использования сервиса</b>
rules_text = await get_rules(db_user.language)
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
await callback.message.edit_text(
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
)
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
await edit_or_answer_photo(
callback=callback,
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
keyboard=get_language_selection_keyboard(
current_language=db_user.language,
include_back=True,
language=db_user.language,
),
parse_mode="HTML",
)
await callback.answer()
async def process_language_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
if db_user.language.lower() == normalized_selected:
await show_main_menu(
callback,
db_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
return
updated_user = await update_user(db, db_user, language=resolved_language)
texts = get_texts(updated_user.language)
await show_main_menu(
callback,
updated_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
async def handle_back_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
@@ -101,82 +182,131 @@ async def handle_back_to_menu(
db: AsyncSession
):
await state.clear()
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
is_admin = settings.is_admin(db_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(
db_user.telegram_id
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
),
parse_mode="HTML"
parse_mode="HTML",
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return "❌ Отсутствует"
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
subscription = user.subscription
current_time = datetime.utcnow()
if subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
days_left = (subscription.end_date - current_time).days
if subscription.is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left == 1:
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_TRIAL_TOMORROW",
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
)
else:
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_TRIAL_TODAY",
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
)
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
).format(
end_date=subscription.end_date.strftime('%d.%m.%Y'),
days=days_left,
)
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
return texts.t(
"SUB_STATUS_ACTIVE_FEW_DAYS",
"💎 Активна\n⚠️ истекает через {days} дн.",
).format(days=days_left)
elif days_left == 1:
return f"💎 Активна\n⚠️ истекает завтра!"
return texts.t(
"SUB_STATUS_ACTIVE_TOMORROW",
"💎 Активна\n⚠️ истекает завтра!",
)
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
return texts.t(
"SUB_STATUS_ACTIVE_TODAY",
"💎 Активна\n⚠️ истекает сегодня!",
)
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
if not random_message:
return base_text
prompt = action_prompt or ""
if prompt and prompt in base_text:
parts = base_text.split(prompt, 1)
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
return f"{base_text}\n\n{random_message}"
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
@@ -195,3 +325,14 @@ def register_handlers(dp: Dispatcher):
show_service_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
)
dp.callback_query.register(
process_language_change,
F.data.startswith("language_select:"),
StateFilter(None)
)
+20 -1
View File
@@ -9,6 +9,7 @@ from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@@ -44,7 +45,10 @@ async def process_promocode(
if not code:
await message.answer(
"❌ Введите корректный промокод",
texts.t(
"PROMOCODE_EMPTY_INPUT",
"❌ Введите корректный промокод",
),
reply_markup=get_back_keyboard(db_user.language)
)
return
@@ -58,6 +62,21 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
+238 -72
View File
@@ -33,64 +33,149 @@ async def show_referral_info(
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\n"
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
referral_text += f"🎁 <b>Как работают награды:</b>\n"
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
referral_text = (
texts.t("REFERRAL_PROGRAM_TITLE", "👥 <b>Реферальная программа</b>")
+ "\n\n"
+ texts.t("REFERRAL_STATS_HEADER", "📊 <b>Ваша статистика:</b>")
+ "\n"
+ texts.t(
"REFERRAL_STATS_INVITED",
"• Приглашено пользователей: <b>{count}</b>",
).format(count=summary['invited_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_FIRST_TOPUPS",
"• Сделали первое пополнение: <b>{count}</b>",
).format(count=summary['paid_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_ACTIVE",
"• Активных рефералов: <b>{count}</b>",
).format(count=summary['active_referrals_count'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_CONVERSION",
"• Конверсия: <b>{rate}%</b>",
).format(rate=summary['conversion_rate'])
+ "\n"
+ texts.t(
"REFERRAL_STATS_TOTAL_EARNED",
"• Заработано всего: <b>{amount}</b>",
).format(amount=texts.format_price(summary['total_earned_kopeks']))
+ "\n"
+ texts.t(
"REFERRAL_STATS_MONTH_EARNED",
"• За последний месяц: <b>{amount}</b>",
).format(amount=texts.format_price(summary['month_earned_kopeks']))
+ "\n\n"
+ texts.t("REFERRAL_REWARDS_HEADER", "🎁 <b>Как работают награды:</b>")
+ "\n"
+ texts.t(
"REFERRAL_REWARD_NEW_USER",
"• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
).format(
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
)
+ "\n"
+ texts.t(
"REFERRAL_REWARD_INVITER",
"• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
+ "\n"
+ texts.t(
"REFERRAL_REWARD_COMMISSION",
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
+ "\n\n"
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
+ f"\n<code>{referral_link}</code>\n\n"
+ texts.t("REFERRAL_CODE_TITLE", "🆔 <b>Ваш код:</b> <code>{code}</code>").format(code=db_user.referral_code)
+ "\n\n"
)
if summary['recent_earnings']:
meaningful_earnings = [
earning for earning in summary['recent_earnings'][:5]
earning for earning in summary['recent_earnings'][:5]
if earning['amount_kopeks'] > 0
]
if meaningful_earnings:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in meaningful_earnings[:3]:
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_HEADER",
"💰 <b>Последние начисления:</b>",
) + "\n"
for earning in meaningful_earnings[:3]:
reason_text = {
"referral_first_topup": "🎉 Первое пополнение",
"referral_commission_topup": "💰 Комиссия с пополнения",
"referral_commission": "💰 Комиссия с покупки"
"referral_first_topup": texts.t(
"REFERRAL_EARNING_REASON_FIRST_TOPUP",
"🎉 Первое пополнение",
),
"referral_commission_topup": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP",
"💰 Комиссия с пополнения",
),
"referral_commission": texts.t(
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE",
"💰 Комиссия с покупки",
),
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
referral_text += texts.t(
"REFERRAL_RECENT_EARNINGS_ITEM",
"{reason}: <b>{amount}</b> от {referral_name}",
).format(
reason=reason_text,
amount=texts.format_price(earning['amount_kopeks']),
referral_name=earning['referral_name'],
) + "\n"
referral_text += "\n"
if summary['earnings_by_type']:
referral_text += f"📈 <b>Доходы по типам:</b>\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_BY_TYPE_HEADER",
"📈 <b>Доходы по типам:</b>",
) + "\n"
if 'referral_first_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_first_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_FIRST_TOPUPS",
"• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_TOPUPS",
"• Комиссии с пополнений: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
if 'referral_commission' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += texts.t(
"REFERRAL_EARNINGS_PURCHASES",
"• Комиссии с покупок: <b>{count}</b> ({amount})",
).format(
count=data['count'],
amount=texts.format_price(data['total_amount_kopeks']),
) + "\n"
referral_text += "\n"
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
referral_text += texts.t(
"REFERRAL_INVITE_FOOTER",
"📢 Приглашайте друзей и зарабатывайте!",
)
await edit_or_answer_photo(
callback,
@@ -128,7 +213,10 @@ async def show_referral_qr(
await callback.message.edit_media(
types.InputMediaPhoto(
media=photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
),
reply_markup=keyboard,
)
@@ -136,7 +224,10 @@ async def show_referral_qr(
await callback.message.delete()
await callback.message.answer_photo(
photo,
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
caption=texts.t(
"REFERRAL_LINK_CAPTION",
"🔗 Ваша реферальная ссылка:\n{link}",
).format(link=referral_link),
reply_markup=keyboard,
)
@@ -154,7 +245,10 @@ async def show_detailed_referral_list(
if not referrals_data['referrals']:
await edit_or_answer_photo(
callback,
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
texts.t(
"REFERRAL_LIST_EMPTY",
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
),
types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
@@ -162,23 +256,47 @@ async def show_detailed_referral_list(
)
await callback.answer()
return
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
text = texts.t(
"REFERRAL_LIST_HEADER",
"👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
).format(
current=referrals_data['current_page'],
total=referrals_data['total_pages'],
) + "\n\n"
for i, referral in enumerate(referrals_data['referrals'], 1):
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
topup_emoji = "💰" if referral['has_made_first_topup'] else ""
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
text += texts.t(
"REFERRAL_LIST_ITEM_HEADER",
"{index}. {status} <b>{name}</b>",
).format(index=i, status=status_emoji, name=referral['full_name']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_TOPUPS",
" {emoji} Пополнений: {count}",
).format(emoji=topup_emoji, count=referral['topups_count']) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_EARNED",
" 💎 Заработано с него: {amount}",
).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n"
text += texts.t(
"REFERRAL_LIST_ITEM_REGISTERED",
" 📅 Регистрация: {days} дн. назад",
).format(days=referral['days_since_registration']) + "\n"
if referral['days_since_activity'] is not None:
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY",
" 🕐 Активность: {days} дн. назад",
).format(days=referral['days_since_activity']) + "\n"
else:
text += f" 🕐 Активность: давно\n"
text += texts.t(
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
" 🕐 Активность: давно",
) + "\n"
text += "\n"
@@ -187,13 +305,13 @@ async def show_detailed_referral_list(
if referrals_data['has_prev']:
nav_buttons.append(types.InlineKeyboardButton(
text="⬅️ Назад",
text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"),
callback_data=f"referral_list_page_{page - 1}"
))
if referrals_data['has_next']:
nav_buttons.append(types.InlineKeyboardButton(
text="Вперед ➡️",
text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"),
callback_data=f"referral_list_page_{page + 1}"
))
@@ -222,21 +340,50 @@ async def show_referral_analytics(
analytics = await get_referral_analytics(db, db_user.id)
text = f"📊 <b>Аналитика рефералов</b>\n\n"
text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 <b>Аналитика рефералов</b>") + "\n\n"
text += f"💰 <b>Доходы по периодам:</b>\n"
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_HEADER",
"💰 <b>Доходы по периодам:</b>",
) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_TODAY",
"• Сегодня: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_WEEK",
"• За неделю: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_MONTH",
"• За месяц: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n"
text += texts.t(
"REFERRAL_ANALYTICS_EARNINGS_QUARTER",
"• За квартал: {amount}",
).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n"
if analytics['top_referrals']:
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
text += texts.t(
"REFERRAL_ANALYTICS_TOP_TITLE",
"🏆 <b>Топ-{count} рефералов:</b>",
).format(count=len(analytics['top_referrals'])) + "\n"
for i, ref in enumerate(analytics['top_referrals'], 1):
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
text += texts.t(
"REFERRAL_ANALYTICS_TOP_ITEM",
"{index}. {name}: {amount} ({count} начислений)",
).format(
index=i,
name=ref['referral_name'],
amount=texts.format_price(ref['total_earned_kopeks']),
count=ref['earnings_count'],
) + "\n"
text += "\n"
text += "📈 Продолжайте развивать свою реферальную сеть!"
text += texts.t(
"REFERRAL_ANALYTICS_FOOTER",
"📈 Продолжайте развивать свою реферальную сеть!",
)
await edit_or_answer_photo(
callback,
@@ -257,16 +404,30 @@ async def create_invite_message(
bot_username = (await callback.bot.get_me()).username
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
invite_text += f"🚀 Быстрое подключение\n"
invite_text += f"🌍 Серверы по всему миру\n"
invite_text += f"🔒 Надежная защита\n\n"
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
invite_text = (
texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_BONUS",
"💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
).format(
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
)
+ "\n\n"
+ texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру")
+ "\n"
+ texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита")
+ "\n\n"
+ texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:")
+ f"\n{referral_link}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text="📤 Поделиться",
text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"),
switch_inline_query=invite_text
)],
[types.InlineKeyboardButton(
@@ -278,8 +439,13 @@ async def create_invite_message(
await edit_or_answer_photo(
callback,
(
f"📝 <b>Приглашение создано!</b>\n\n"
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 <b>Приглашение создано!</b>")
+ "\n\n"
+ texts.t(
"REFERRAL_INVITE_CREATED_INSTRUCTION",
"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
)
+ "\n\n"
f"<code>{invite_text}</code>"
),
keyboard,
+212
View File
@@ -0,0 +1,212 @@
import logging
from datetime import datetime
from typing import List, Tuple
from aiogram import Dispatcher, F, types
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_server_status_keyboard
from app.localization.texts import get_texts
from app.services.server_status_service import (
ServerStatusEntry,
ServerStatusError,
ServerStatusService,
)
logger = logging.getLogger(__name__)
_status_service = ServerStatusService()
async def show_server_status(callback: types.CallbackQuery, db_user: User) -> None:
await _render_server_status(callback, db_user, page=1)
async def change_server_status_page(callback: types.CallbackQuery, db_user: User) -> None:
try:
_, page_str = callback.data.split(":", 1)
page = int(page_str)
except (ValueError, AttributeError, IndexError):
page = 1
await _render_server_status(callback, db_user, page=page)
async def _render_server_status(
callback: types.CallbackQuery,
db_user: User,
page: int = 1,
) -> None:
texts = get_texts(db_user.language)
if settings.get_server_status_mode() != "xray":
await callback.answer(texts.t("SERVER_STATUS_NOT_CONFIGURED", "Функция недоступна."), show_alert=True)
return
try:
servers = await _status_service.get_servers()
except ServerStatusError as error:
logger.warning("Server status error: %s", error)
await callback.answer(
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
show_alert=True,
)
return
except Exception as error: # pragma: no cover - defensive logging
logger.error("Unexpected server status error: %s", error)
await callback.answer(
texts.t("SERVER_STATUS_ERROR_SHORT", "Не удалось получить данные"),
show_alert=True,
)
return
message, total_pages, current_page = _build_status_message(servers, texts, page)
keyboard = get_server_status_keyboard(db_user.language, current_page, total_pages)
await callback.message.edit_text(
message,
reply_markup=keyboard,
disable_web_page_preview=True,
)
await callback.answer()
def _build_status_message(
servers: List[ServerStatusEntry],
texts,
page: int,
) -> Tuple[str, int, int]:
total_servers = len(servers)
online_servers = [server for server in servers if server.is_online]
offline_servers = [server for server in servers if not server.is_online]
items_per_page = settings.get_server_status_items_per_page()
pages = _split_into_pages(online_servers, offline_servers, items_per_page)
total_pages = max(1, len(pages))
current_index = min(max(page - 1, 0), total_pages - 1)
current_online, current_offline = pages[current_index] if pages else ([], [])
lines: List[str] = [texts.t("SERVER_STATUS_TITLE", "📊 <b>Статус серверов</b>")]
if total_servers == 0:
lines.append("")
lines.append(texts.t("SERVER_STATUS_NO_SERVERS", "Нет данных о серверах."))
message = "\n".join(lines).strip()
return message, 1, 1
summary = texts.t(
"SERVER_STATUS_SUMMARY",
"Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
).format(
total=total_servers,
online=len(online_servers),
offline=len(offline_servers),
)
updated_at = datetime.now().strftime("%H:%M:%S")
lines.extend(
[
"",
summary,
texts.t("SERVER_STATUS_UPDATED_AT", "⏱ Обновлено: {time}").format(time=updated_at),
"",
]
)
if current_online:
lines.append(texts.t("SERVER_STATUS_AVAILABLE", "✅ <b>Доступны</b>"))
lines.extend(_format_server_lines(current_online, texts, online=True))
lines.append("")
if current_offline:
lines.append(texts.t("SERVER_STATUS_UNAVAILABLE", "❌ <b>Недоступны</b>"))
lines.extend(_format_server_lines(current_offline, texts, online=False))
lines.append("")
if total_pages > 1:
lines.append(
texts.t("SERVER_STATUS_PAGINATION", "Страница {current} из {total}").format(
current=current_index + 1,
total=total_pages,
)
)
message = "\n".join(line for line in lines if line is not None)
message = message.strip()
return message, total_pages, current_index + 1
def _split_into_pages(
online: List[ServerStatusEntry],
offline: List[ServerStatusEntry],
items_per_page: int,
) -> List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]]:
if not online and not offline:
return [([], [])]
pages: List[Tuple[List[ServerStatusEntry], List[ServerStatusEntry]]] = []
online_index = 0
offline_index = 0
while online_index < len(online) or offline_index < len(offline):
current_online: List[ServerStatusEntry] = []
current_offline: List[ServerStatusEntry] = []
remaining = max(1, items_per_page)
while remaining > 0 and online_index < len(online):
current_online.append(online[online_index])
online_index += 1
remaining -= 1
while remaining > 0 and offline_index < len(offline):
current_offline.append(offline[offline_index])
offline_index += 1
remaining -= 1
pages.append((current_online, current_offline))
return pages if pages else [([], [])]
def _format_server_lines(
servers: List[ServerStatusEntry],
texts,
*,
online: bool,
) -> List[str]:
lines: List[str] = []
for server in servers:
latency_text: str
if online:
if server.latency_ms and server.latency_ms > 0:
latency_text = texts.t("SERVER_STATUS_LATENCY", "{latency} мс").format(
latency=server.latency_ms
)
else:
latency_text = texts.t("SERVER_STATUS_LATENCY_UNKNOWN", "нет данных")
else:
latency_text = texts.t("SERVER_STATUS_OFFLINE", "нет ответа")
name = server.display_name or server.name
flag_prefix = f"{server.flag} " if server.flag else ""
server_line = f"{flag_prefix}{name}{latency_text}"
lines.append(f"<blockquote>{server_line}</blockquote>")
return lines
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_server_status,
F.data == "menu_server_status",
)
dp.callback_query.register(
change_server_status_page,
F.data.startswith("server_status_page:"),
)
+64 -51
View File
@@ -1,29 +1,36 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
allowed_prefixes = ("balance_", "admin_stars_test_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message="Ошибка валидации платежа. Попробуйте еще раз."
error_message=texts.t(
"STARS_PRECHECK_INVALID_PAYLOAD",
"Ошибка валидации платежа. Попробуйте еще раз.",
),
)
return
try:
from app.database.database import get_db
async for db in get_db():
@@ -32,26 +39,36 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message="Пользователь не найден. Обратитесь в поддержку."
error_message=texts.t(
"STARS_PRECHECK_USER_NOT_FOUND",
"Пользователь не найден. Обратитесь в поддержку.",
),
)
return
break
texts = get_texts(user.language or DEFAULT_LANGUAGE)
break
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
error_message=texts.t(
"STARS_PRECHECK_TECHNICAL_ERROR",
"Техническая ошибка. Попробуйте позже.",
),
)
@@ -60,25 +77,32 @@ async def handle_successful_payment(
db: AsyncSession,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
texts.t(
"STARS_PAYMENT_USER_NOT_FOUND",
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
)
)
return
payment_service = PaymentService(message.bot)
success = await payment_service.process_stars_payment(
db=db,
@@ -91,40 +115,23 @@ async def handle_successful_payment(
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
keyboard = await payment_service.build_topup_success_keyboard(user)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
]
)
transaction_id_short = payment.telegram_payment_charge_id[:8]
await message.answer(
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
f"⭐ Потрачено звезд: {payment.total_amount}\n"
f"💰 Зачислено на баланс: {int(rubles_amount)}\n"
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
f"Спасибо за пополнение! 🚀",
texts.t(
"STARS_PAYMENT_SUCCESS",
"🎉 <b>Платеж успешно обработан!</b>\n\n"
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
amount=int(rubles_amount),
transaction_id=transaction_id_short,
),
parse_mode="HTML",
reply_markup=keyboard,
)
@@ -136,15 +143,21 @@ async def handle_successful_payment(
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную."
texts.t(
"STARS_PAYMENT_ENROLLMENT_ERROR",
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную.",
)
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы."
texts.t(
"STARS_PAYMENT_PROCESSING_ERROR",
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы.",
)
)
+841 -192
View File
File diff suppressed because it is too large Load Diff
+3817 -1443
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_support_keyboard
from app.services.support_settings_service import SupportSettingsService
from app.localization.texts import get_texts
from app.utils.photo_message import edit_or_answer_photo
logger = logging.getLogger(__name__)
@@ -16,10 +18,12 @@ async def show_support_info(
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.SUPPORT_INFO,
reply_markup=get_support_keyboard(db_user.language)
support_info = SupportSettingsService.get_support_info_text(db_user.language)
await edit_or_answer_photo(
callback=callback,
caption=support_info,
keyboard=get_support_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
File diff suppressed because it is too large Load Diff
+1347 -271
View File
File diff suppressed because it is too large Load Diff
+1007 -202
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -33,19 +33,19 @@ def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
return ReplyKeyboardMarkup(
keyboard=[
[
KeyboardButton(text="👥 Пользователи"),
KeyboardButton(text="📱 Подписки")
KeyboardButton(text=texts.ADMIN_USERS),
KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)
],
[
KeyboardButton(text="🎫 Промокоды"),
KeyboardButton(text="📨 Рассылки")
KeyboardButton(text=texts.ADMIN_PROMOCODES),
KeyboardButton(text=texts.ADMIN_MESSAGES)
],
[
KeyboardButton(text="📊 Статистика"),
KeyboardButton(text="🔧 Мониторинг")
KeyboardButton(text=texts.ADMIN_STATISTICS),
KeyboardButton(text=texts.ADMIN_MONITORING)
],
[
KeyboardButton(text="🏠 Главное меню")
KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню"))
]
],
resize_keyboard=True,
@@ -81,9 +81,10 @@ def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="⏭️ Пропустить")]
[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -95,10 +96,11 @@ def remove_keyboard() -> ReplyKeyboardRemove:
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
@@ -106,11 +108,12 @@ def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
texts = get_texts(language)
return ReplyKeyboardMarkup(
keyboard=[
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
[KeyboardButton(text="❌ Отмена")]
[KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)],
[KeyboardButton(text=texts.CANCEL)]
],
resize_keyboard=True,
one_time_keyboard=True
)
)
+34
View File
@@ -0,0 +1,34 @@
# Sample localization for English language.
# Keys correspond to constants defined in app/localization/texts.py.
WELCOME: |
Welcome to Remnawave Bedolaga Bot!
Update this text to greet users in your own style.
MENU:
BALANCE: "Balance"
SERVER_STATUS: "Server status"
SUBSCRIPTION: "Subscription"
SERVER_STATUS:
TITLE: "📊 <b>Server status</b>"
SUMMARY: "Total servers: {total} (online: {online}, offline: {offline})"
AVAILABLE: "✅ <b>Online</b>"
UNAVAILABLE: "❌ <b>Offline</b>"
LATENCY: "{latency} ms"
LATENCY_UNKNOWN: "no data"
OFFLINE: "no response"
NO_SERVERS: "No server data available."
PAGINATION: "Page {current} of {total}"
PREV_PAGE: "⬅️ Back"
NEXT_PAGE: "Next ➡️"
REFRESH: "🔄 Refresh"
ERROR_SHORT: "Failed to fetch data"
NOT_CONFIGURED: "Feature is not available."
UPDATED_AT: "⏱ Updated at: {time}"
RULES_TEXT: |
Remnawave service rules:
1. Follow the law of your jurisdiction.
2. Do not distribute spam or malicious content.
3. Respect other community members.
+34
View File
@@ -0,0 +1,34 @@
# Пример локализации на русском языке.
# Ключи соответствуют именам констант из app/localization/texts.py.
WELCOME: |
Добро пожаловать в Remnawave Bedolaga Bot!
Эти строки заменят стандартное приветствие бота.
MENU:
BALANCE: "Баланс"
SERVER_STATUS: "Статус серверов"
SUBSCRIPTION: "Подписка"
SERVER_STATUS:
TITLE: "📊 <b>Статус серверов</b>"
SUMMARY: "Всего серверов: {total} (в сети: {online}, вне сети: {offline})"
AVAILABLE: "✅ <b>Доступны</b>"
UNAVAILABLE: "❌ <b>Недоступны</b>"
LATENCY: "{latency} мс"
LATENCY_UNKNOWN: "нет данных"
OFFLINE: "нет ответа"
NO_SERVERS: "Нет данных о серверах."
PAGINATION: "Страница {current} из {total}"
PREV_PAGE: "⬅️ Назад"
NEXT_PAGE: "Вперед ➡️"
REFRESH: "🔄 Обновить"
ERROR_SHORT: "Не удалось получить данные"
NOT_CONFIGURED: "Функция недоступна."
UPDATED_AT: "⏱ Обновлено: {time}"
RULES_TEXT: |
Правила сервиса Remnawave:
1. Следуйте законам своей страны.
2. Не распространяйте спам и вредоносный контент.
3. Уважайте других пользователей.
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import json
import logging
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Any, Dict
from app.config import settings
_logger = logging.getLogger(__name__)
_FALLBACK_LANGUAGE = "ru"
_BASE_DIR = Path(__file__).resolve().parent
_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales"
def _normalize_language_code(value: Any) -> str:
if isinstance(value, str):
return value.strip().lower()
if value is None:
return ""
return str(value).strip().lower()
def _resolve_user_locales_dir() -> Path:
path = Path(settings.LOCALES_PATH).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path
def _locale_file_exists(language: str) -> bool:
code = _normalize_language_code(language)
if not code:
return False
default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json"
if default_candidate.exists():
return True
user_dir = _resolve_user_locales_dir()
for extension in (".json", ".yml", ".yaml"):
if (user_dir / f"{code}{extension}").exists():
return True
return False
def _select_fallback_language(available_map: Dict[str, str]) -> str:
candidates = []
if _FALLBACK_LANGUAGE:
candidates.append(_FALLBACK_LANGUAGE)
candidates.extend(available_map.values())
seen = set()
for candidate in candidates:
normalized = _normalize_language_code(candidate)
if not normalized or normalized in seen:
continue
seen.add(normalized)
if normalized in available_map:
return available_map[normalized]
if _locale_file_exists(normalized):
return normalized
if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE):
return _FALLBACK_LANGUAGE
return _FALLBACK_LANGUAGE or "ru"
def _determine_default_language() -> str:
try:
raw_default = settings.DEFAULT_LANGUAGE
except AttributeError:
raw_default = None
configured = raw_default.strip() if isinstance(raw_default, str) else ""
try:
available_languages = settings.get_available_languages()
except Exception as error: # pragma: no cover - defensive logging
_logger.warning("Failed to load available languages from settings: %s", error)
available_languages = []
available_map = {
_normalize_language_code(lang): lang.strip()
for lang in available_languages
if isinstance(lang, str) and lang.strip()
}
if configured:
normalized_configured = _normalize_language_code(configured)
if normalized_configured in available_map:
return available_map[normalized_configured]
if not available_map and _locale_file_exists(normalized_configured):
return normalized_configured
if _locale_file_exists(normalized_configured):
_logger.warning(
"Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.",
configured,
_FALLBACK_LANGUAGE,
)
else:
_logger.warning(
"Configured default language '%s' is not available. Falling back to '%s'.",
configured,
_FALLBACK_LANGUAGE,
)
else:
_logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE)
fallback_language = _select_fallback_language(available_map)
if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE):
_logger.warning(
"Fallback language '%s' is not available. Using '%s' instead.",
_FALLBACK_LANGUAGE,
fallback_language,
)
return fallback_language or _FALLBACK_LANGUAGE
DEFAULT_LANGUAGE = _determine_default_language()
def _normalize_key(raw_key: Any) -> str:
key = str(raw_key).strip().replace(" ", "_")
return key.upper()
def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]:
flattened: Dict[str, Any] = {}
for key, value in (data or {}).items():
composite_key = _normalize_key(key)
if parent_key:
composite_key = f"{parent_key}_{composite_key}"
if isinstance(value, dict):
flattened.update(_flatten_locale_dict(value, composite_key))
else:
flattened[composite_key] = value
return flattened
def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
normalized: Dict[str, Any] = {}
for key, value in (data or {}).items():
if isinstance(value, dict):
normalized.update(_flatten_locale_dict(value, _normalize_key(key)))
else:
normalized[_normalize_key(key)] = value
return normalized
def ensure_locale_templates() -> None:
destination = _resolve_user_locales_dir()
try:
destination.mkdir(parents=True, exist_ok=True)
except Exception as error:
_logger.warning("Unable to create locales directory %s: %s", destination, error)
return
if any(destination.glob("*")):
return
if not _DEFAULT_LOCALES_DIR.exists():
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
return
for template in _DEFAULT_LOCALES_DIR.iterdir():
if not template.is_file():
continue
target_path = destination / template.name
try:
shutil.copyfile(template, target_path)
except Exception as error:
_logger.warning(
"Failed to copy default locale %s to %s: %s",
template,
target_path,
error,
)
def _load_default_locale(language: str) -> Dict[str, Any]:
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
if not default_path.exists():
return {}
return _normalize_locale_dict(_load_locale_file(default_path))
def _load_user_locale(language: str) -> Dict[str, Any]:
user_dir = _resolve_user_locales_dir()
for extension in (".json", ".yml", ".yaml"):
candidate = user_dir / f"{language}{extension}"
if candidate.exists():
return _normalize_locale_dict(_load_locale_file(candidate))
return {}
def _load_locale_file(path: Path) -> Dict[str, Any]:
suffix = path.suffix.lower()
try:
if suffix == ".json":
return json.loads(path.read_text(encoding="utf-8"))
if suffix in {".yml", ".yaml"}:
try:
import yaml # type: ignore
except ModuleNotFoundError as import_error:
raise RuntimeError(
"PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files."
) from import_error
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as error:
_logger.warning("Failed to parse locale file %s: %s", path, error)
return {}
_logger.warning("Unsupported locale format for %s", path)
return {}
def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
result = dict(base)
for key, value in overrides.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = _merge_dicts(result[key], value)
else:
result[key] = value
return result
@lru_cache(maxsize=None)
def load_locale(language: str) -> Dict[str, Any]:
language = language or DEFAULT_LANGUAGE
defaults = _load_default_locale(language)
overrides = _load_user_locale(language)
merged = _merge_dicts(defaults, overrides)
if not merged and language != DEFAULT_LANGUAGE:
_logger.warning(
"Locale %s not found. Falling back to default language %s.",
language,
DEFAULT_LANGUAGE,
)
return load_locale(DEFAULT_LANGUAGE)
return merged
def clear_locale_cache() -> None:
load_locale.cache_clear()
+702
View File
@@ -0,0 +1,702 @@
{
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "️ Server management is unavailable — only one server is accessible",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n — will be added (paid)\n — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
"PAID_FEATURE_ONLY": "⚠ This feature is available only for paid subscriptions",
"PAID_FEATURE_ONLY_SHORT": "⚠ Paid subscriptions only",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ This server is not available for your promo group",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ No changes detected",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
"COUNTRY_CHANGES_ADDED_HEADER": " <b>Added countries:</b>\n",
"COUNTRY_CHANGES_CHARGED": "💰 Charged: {amount} (for {months} mo)",
"COUNTRY_CHANGES_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Removed countries:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "️ Reconnecting later will be charged",
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Active countries:</b> {count}",
"ADMIN_MAIN_MENU": "🏠 Main menu",
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
"AUTOPAY_BUTTON": "💳 Auto payment",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
"AUTOPAY_STATUS_ENABLED": "enabled",
"AUTOPAY_STATUS_DISABLED": "disabled",
"AUTOPAY_MENU_TEXT": "💳 <b>Auto payment</b>\n\n📊 <b>Status:</b> {status}\n⏰ <b>Charge:</b> {days} days before expiry\n\nChoose an action:",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Autopay {status}!",
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Choose how many days before expiry to charge the payment:",
"AUTOPAY_DAYS_SET": "✅ Set to {days} days!",
"BACK": "⬅️ Back",
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
"CANCEL": "❌ Cancel",
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
"CONFIRM": "✅ Confirm",
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
"CONNECT_BUTTON": "🔗 Connect",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
"HAPP_PLATFORM_IOS": "🍎 iOS",
"HAPP_PLATFORM_ANDROID": "🤖 Android",
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
"HAPP_PLATFORM_PC": "💻 PC",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
"CONTINUE": "➡️ Continue",
"CONTINUE_BUTTON": "➡️ Continue",
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
"CREATE_INVITE_BUTTON": "📝 Create invite",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
"DISABLE_BUTTON": "❌ Disable",
"ENABLE_BUTTON": "✅ Enable",
"ERROR": "❌ An error occurred",
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
"MAIN_MENU_BUTTON": "🏠 Main menu",
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
"DEVICE_NONE_CONNECTED": "️ You have no connected devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Device management</b>\n\n📊 Total connected: {total} devices\n📄 Page {page} of {pages}\n\n",
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Connected devices:</b>\n",
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Actions:</b>\n• Select a device to reset\n• Or reset all devices at once",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_PAGE_LOAD_ERROR": "❌ Failed to open the page",
"DEVICE_RESET_INVALID_REQUEST": "❌ Error: invalid request",
"DEVICE_RESET_PARSE_ERROR": "❌ Failed to process the request",
"DEVICE_RESET_SUCCESS": "✅ Device {device} has been reset!",
"DEVICE_RESET_ALL_DONE": "️ All devices have been reset",
"DEVICE_RESET_ID_FAILED": "❌ Unable to get device ID",
"DEVICE_RESET_NOT_FOUND": "❌ Device not found",
"DEVICE_RESET_ERROR": "❌ Failed to reset the device",
"DEVICE_LIST_FETCH_ERROR": "❌ Failed to load device list",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>All devices have been reset!</b>\n\n🔄 Reset: {count} devices\n📱 You can now reconnect your devices\n\n💡 Use the link from the 'My subscription' section to reconnect",
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Devices reset partially</b>\n\n✅ Removed: {success} devices\n❌ Failed to remove: {failed} devices\n\nTry again or contact support.",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Couldn't reset devices</b>\n\nPlease try again later or contact support.\n\nTotal devices: {total}",
"MENU_BALANCE": "💰 Balance",
"MENU_SUBSCRIPTION": "📱 Subscription",
"MENU_TRIAL": "🎁 Trial subscription",
"MY_BALANCE_BUTTON": "💰 My balance",
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
"NO": "❌ No",
"NO_SERVERS_AVAILABLE": "❌ No servers available",
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
"OTHER_APPS_BUTTON": "📋 Other apps",
"PAGINATION_NEXT": "➡️",
"PAGINATION_PREV": "⬅️",
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"ALREADY_REGISTERED_REFERRAL": "️ You are already registered. A referral link cannot be applied.",
"REFERRAL_LIST_BUTTON": "👥 Referral list",
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
"RULES_HEADER": "📋 <b>Service Rules</b>",
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
"SEND_CONTACT_BUTTON": "📱 Share contact",
"SEND_LOCATION_BUTTON": "📍 Share location",
"SHOW_QR_BUTTON": "📱 Show QR code",
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
"SKIP_BUTTON": "Skip ➡️",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ You don't have an active subscription!",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
"SUBSCRIPTION_ACTIVE": "✅ Active",
"SUCCESS": "✅ Success",
"REGISTRATION_COMPLETING": "✅ Completing registration...",
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
"WELCOME_FALLBACK": "Welcome, {user_name}!",
"YES": "✅ Yes",
"ACCESS_DENIED": "❌ Access denied",
"ADMIN_MESSAGES": "📨 Broadcasts",
"ADMIN_MONITORING": "🔍 Monitoring",
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
"ADMIN_PROMOCODES": "🎫 Promo codes",
"ADMIN_REFERRALS": "🤝 Referral program",
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
"ADMIN_RULES": "📋 Rules",
"ADMIN_STATISTICS": "📊 Statistics",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
"ADMIN_USER_PROMO_GROUP_ALREADY": "️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>User management</b>\n\n<b>Main information:</b>\n• Name: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Status: {status}\n• Language: {language}\n\n<b>Finances:</b>\n• Balance: {balance}\n• Transactions: {transactions}\n\n<b>Activity:</b>\n• Registration: {registration}\n• Last activity: {last_activity}\n• Days since registration: {registration_days}",
"ADMIN_USER_USERNAME_NOT_SET": "not set",
"ADMIN_USER_STATUS_ACTIVE": "✅ Active",
"ADMIN_USER_STATUS_BLOCKED": "🚫 Blocked",
"ADMIN_USER_STATUS_DELETED": "🗑️ Deleted",
"ADMIN_USER_STATUS_UNKNOWN": "❓ Unknown",
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Unknown",
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Trial",
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Paid",
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Active",
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Inactive",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} GB",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Subscription:</b>\n• Type: {type}\n• Status: {status}\n• Until: {end_date}\n• Traffic: {traffic}\n• Devices: {devices}\n• Countries: {countries}",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Subscription:</b> None",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Promo group:</b>\n• Name: {name}\n• Server discount: {server_discount}%\n• Traffic discount: {traffic_discount}%\n• Device discount: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Promo group:</b> Not assigned",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
"ADMIN_USERS": "👥 Users",
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
"BALANCE_BUTTON": "💰 Balance: {balance}",
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
"BALANCE_HISTORY": "📊 Transaction history",
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOP_UP": "💳 Top up",
"CAMPAIGN_EXISTING_USER": "️ This promo link is available only to new users.",
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Youve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\n️ Payments are not refunded\n",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CONTACT_SUPPORT": "💬 Contact support",
"CREATE_INVITE": "📝 Create invite",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
"DEVICES_NO_CHANGE": "️ Device limit was not changed",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"DEVICE_CHANGE_ACTION_INCREASE": "increase to {count}",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"DEVICE_CHANGE_FREE": "Free",
"DEVICE_CHANGE_ACTION_DECREASE": "decrease to {count}",
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICE_CHANGE_CHARGED": "💰 Charged: {amount}",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
"DEVICE_CHANGE_NO_REFUND_INFO": "️ Payments are not refunded",
"INVALID_AMOUNT": "❌ Invalid amount",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
"MENU_ADMIN": "⚙️ Admin panel",
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
"MENU_PROMOCODE": "🎫 Promo code",
"MENU_REFERRALS": "🤝 Referral program",
"MENU_RULES": "📋 Service rules",
"MENU_SERVER_STATUS": "📊 Server status",
"MENU_SUPPORT": "🛠️ Support",
"OPERATION_CANCELLED": "❌ Operation cancelled",
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"PROMOCODE_ENTER": "🎫 Enter promo code",
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
"PROMOCODE_INVALID": "❌ Invalid promo code",
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
"PROMOCODE_USED": "️ Promo code has already been used",
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
"RULES_ACCEPT": "✅ I accept the rules",
"RULES_DECLINE": "❌ I do not accept",
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
"SELECT_COUNTRIES": "Select countries:",
"SELECT_DEVICES": "Number of devices:",
"SELECT_PERIOD": "Choose period:",
"SELECT_TRAFFIC": "Choose traffic package:",
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
"SUBSCRIPTION_NONE": "❌ No active subscription",
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
"SERVER_STATUS_AVAILABLE": "✅ <b>Online</b>",
"SERVER_STATUS_ERROR_SHORT": "Failed to fetch data",
"SERVER_STATUS_LATENCY": "{latency} ms",
"SERVER_STATUS_LATENCY_UNKNOWN": "no data",
"SERVER_STATUS_NEXT_PAGE": "Next ➡️",
"SERVER_STATUS_NO_SERVERS": "No server data available.",
"SERVER_STATUS_NOT_CONFIGURED": "Feature is not available.",
"SERVER_STATUS_OFFLINE": "no response",
"SERVER_STATUS_PAGINATION": "Page {current} of {total}",
"SERVER_STATUS_PREV_PAGE": "⬅️ Back",
"SERVER_STATUS_REFRESH": "🔄 Refresh",
"SERVER_STATUS_SUMMARY": "Total servers: {total} (online: {online}, offline: {offline})",
"SERVER_STATUS_TITLE": "📊 <b>Server status</b>",
"SERVER_STATUS_UPDATED_AT": "⏱ Updated at: {time}",
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Offline</b>",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n️ Payments are not refunded\n",
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
"TOP_UP_TRIBUTE": "💎 Bank card",
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"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_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"USER_NOT_FOUND": "❌ User not found",
"MENU_LANGUAGE": "🌐 Language",
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
"SUBSCRIPTION_TYPE_PAID": "Paid",
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "️ The subscription link is available via the buttons below or in the “My subscription” section.",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\n️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
"REFERRAL_SHARE_BUTTON": "📤 Share",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
"ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Auto assignment by total spending: disabled",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Auto assignment by total spending from {amount} ₽",
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) required for automatic assignment. Send 0 to disable.",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Enter subscription period discounts (e.g. 30:10, 90:15). Send 0 if none.",
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Enter total spending (in ₽) for auto assignment. Current value: {current}.",
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Auto assignment by spending",
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Device discount",
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Rename",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Period discounts",
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Server discount",
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Traffic discount",
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Select a parameter to change:",
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Promo group settings “{name}”",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Enter new period discounts (current: {current}). Send 0 if none.",
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Enter a non-negative amount in rubles or 0 to disable.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Enter period:discount pairs separated by commas, e.g. 30:10, 90:15, or 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_REPORTS": "📊 Reports",
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Closed support tickets:",
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Open support tickets:",
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
"ATTACHMENTS_SENT": "✅ Attachments sent.",
"BACK_TO_MENU": "🏠 Back to menu",
"BACK_TO_OPEN_TICKETS": "🔴 Open tickets",
"BACK_TO_SUPPORT": "⬅️ Back to support",
"BACK_TO_TICKETS": "⬅️ Back to tickets",
"BALANCE_TOPUP": "💳 Top up balance",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
"CAMPAIGN_EXISTING_USERL": "️ This promotional link is available to new users only.",
"CANCEL_REPLY": "❌ Cancel reply",
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
"CLOSED_TICKETS": "🟢 Closed",
"CLOSED_TICKETS_HEADER": "🟢 Closed tickets",
"CLOSED_TICKETS_TITLE": "🟢 Closed tickets:",
"CLOSE_NOTIFICATION": "❌ Close notification",
"CLOSE_TICKET": "🔒 Close ticket",
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
"DELETE_MESSAGE": "🗑 Delete",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"DISCOUNT_CLAIM_ALREADY": "️ This discount has already been activated.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
"MARK_AS_ANSWERED": "✅ Mark as answered",
"MULENPAY_PAYMENT_ERROR": "❌ Failed to create Mulen Pay payment. Please try again later or contact support.",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Mulen Pay payment</b>\n\n💰 Amount: {amount}\n🆔 Payment ID: {payment_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with Mulen Pay\n2. Follow the instructions on the payment page\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"MULENPAY_PAY_BUTTON": "💳 Pay with Mulen Pay",
"MULENPAY_TOPUP_PROMPT": "💳 <b>Mulen Pay payment</b>\n\nEnter an amount between 100 and 100,000 ₽.\nThe payment is processed by the secure Mulen Pay platform.",
"MY_TICKETS_BUTTON": "📋 My tickets",
"MY_TICKETS_TITLE": "📋 Your tickets:",
"NOTIFICATION_CLOSED": "Notification closed.",
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
"NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):",
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
"NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):",
"NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):",
"NO_ATTACHMENTS": "No attachments.",
"NO_CLOSED_TICKETS": "There are no closed tickets yet.",
"NO_TICKETS": "You don't have any tickets yet.",
"NO_TICKETS_ADMIN": "No tickets to display.",
"OPEN_TICKETS": "🔴 Open",
"OPEN_TICKETS_HEADER": "🔴 Open tickets",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with PayPalych (SBP)\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
"PAYMENT_CARD_MULENPAY": "💳 Bank card (Mulen Pay)",
"PAYMENT_CARD_PAL24": "🏦 SBP (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card (Mulen Pay)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"REPLY_TO_TICKET": "💬 Reply",
"REPORT_CLOSE": "❌ Close",
"REPORT_CLOSED": "✅ Report closed.",
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Tap the \"Connect\" button below to open Happ and add the subscription automatically.",
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 If the link doesn't open automatically, copy it manually:",
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Open link in Happ</a>",
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Connect via Happ</b>",
"SUPPORT_BUTTON": "🆘 Support",
"TICKET_ALREADY_OPEN": "You already have an open ticket. Please close it first.",
"TICKET_ATTACHMENTS": "📎 Attachments",
"TICKET_CLOSED": "✅ Ticket closed.",
"TICKET_CLOSED_NO_REPLY": "❌ The ticket is closed; replying is not possible.",
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
"TICKET_CREATE_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
"TICKET_NOT_FOUND": "Ticket not found.",
"TICKET_PRIORITY_HIGH": "🟠 High",
"TICKET_PRIORITY_LOW": "🟢 Low",
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
"TICKET_REPLY_INPUT": "Enter your reply:",
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
"TICKET_STATUS_ANSWERED": "Answered",
"TICKET_STATUS_CLOSED": "Closed",
"TICKET_STATUS_OPEN": "Open",
"TICKET_STATUS_PENDING": "Pending",
"TICKET_TITLE_INPUT": "Enter ticket title:",
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!",
"UNBLOCK": "✅ Unblock",
"USER_BLOCKED_FOREVER": "You are blocked from contacting support.",
"USER_BLOCKED_UNTIL": "You are blocked until {time}",
"VIEW_CLOSED_TICKETS": "🟢 Closed tickets",
"VIEW_TICKET": "👁️ View ticket",
"ADMIN_USERS_SUBMENU_TITLE": "👥 **User and subscription management**\n\n",
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Promo codes and statistics**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Communications**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Manage broadcasts and interface texts:",
"ADMIN_SUBMENU_SELECT_SECTION": "Choose a section:",
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Support**\n\n",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Manage tickets and support settings:",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Ticket access only.",
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ <b>Support moderation</b>",
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Access to support tickets.",
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Moderator audit</b>",
"ADMIN_SUPPORT_AUDIT_EMPTY": "Nothing here yet",
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Moderator",
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Admin",
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Ticket closed",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Timed block",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Permanent block",
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Unblock",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **System tools**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Reports, updates, logs, backups and system operations:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Enabled",
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Disabled",
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "\"Support\" menu item",
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Tickets",
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Contact",
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Both",
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Edit description",
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Admin notifications",
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "User notifications",
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ SLA time: {minutes} min",
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Moderators: {count}",
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": " Assign moderator",
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": " Remove moderator",
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Support settings</b>",
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Working hours and menu visibility. Current support menu description:",
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>SLA configuration</b>\n\nEnter the response wait time in minutes (integer > 0):",
"ADMIN_SUPPORT_SLA_INVALID": "❌ Enter a valid number of minutes (1-1440)",
"ADMIN_SUPPORT_SLA_SAVED": "✅ SLA value saved",
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ <b>Assign moderator</b>\n\nSend the user's Telegram ID (number)",
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ <b>Remove moderator</b>\n\nSend the user's Telegram ID (number)",
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Enter a valid Telegram ID (number)",
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Moderator {tid} removed",
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Failed to remove moderator",
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ User {tid} assigned as moderator",
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Failed to assign moderator",
"ADMIN_SUPPORT_MODERATORS_EMPTY": "List is empty",
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ <b>Moderators</b>",
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Send description",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Editing support description</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Current description:",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Contact for \"Contact\" mode</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Add to the description if needed.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Description updated.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Description sent below",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Message deleted"
}
+702
View File
@@ -0,0 +1,702 @@
{
"ACCESS_DENIED": "❌ Доступ запрещен",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
"PAID_FEATURE_ONLY": "⚠ Эта функция доступна только для платных подписок",
"PAID_FEATURE_ONLY_SHORT": "⚠ Только для платных подписок",
"COUNTRY_NOT_AVAILABLE_PROMOGROUP": "❌ Сервер недоступен для вашей промогруппы",
"COUNTRY_CHANGES_NOT_FOUND": "⚠️ Изменения не обнаружены",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
"COUNTRY_CHANGES_ADDED_HEADER": " <b>Добавлены страны:</b>\n",
"COUNTRY_CHANGES_CHARGED": "💰 Списано: {amount} (за {months} мес)",
"COUNTRY_CHANGES_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Отключены страны:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
"COUNTRY_CHANGES_ACTIVE_COUNT": "🌐 <b>Активных стран:</b> {count}",
"ADMIN_MAIN_MENU": "🏠 Главное меню",
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MONITORING": "🔍 Мониторинг",
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
"ADMIN_PROMOCODES": "🎫 Промокоды",
"ADMIN_REFERRALS": "🤝 Партнерка",
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
"ADMIN_RULES": "📋 Правила",
"ADMIN_STATISTICS": "📊 Статистика",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_MANAGEMENT_PROFILE": "👤 <b>Управление пользователем</b>\n\n<b>Основная информация:</b>\n• Имя: {name}\n• ID: <code>{telegram_id}</code>\n• Username: {username}\n• Статус: {status}\n• Язык: {language}\n\n<b>Финансы:</b>\n• Баланс: {balance}\n• Транзакций: {transactions}\n\n<b>Активность:</b>\n• Регистрация: {registration}\n• Последняя активность: {last_activity}\n• Дней с регистрации: {registration_days}",
"ADMIN_USER_USERNAME_NOT_SET": "не указан",
"ADMIN_USER_STATUS_ACTIVE": "✅ Активен",
"ADMIN_USER_STATUS_BLOCKED": "🚫 Заблокирован",
"ADMIN_USER_STATUS_DELETED": "🗑️ Удален",
"ADMIN_USER_STATUS_UNKNOWN": "❓ Неизвестно",
"ADMIN_USER_LAST_ACTIVITY_UNKNOWN": "Неизвестно",
"ADMIN_USER_SUBSCRIPTION_TYPE_TRIAL": "🎁 Триал",
"ADMIN_USER_SUBSCRIPTION_TYPE_PAID": "💎 Платная",
"ADMIN_USER_SUBSCRIPTION_STATUS_ACTIVE": "✅ Активна",
"ADMIN_USER_SUBSCRIPTION_STATUS_INACTIVE": "❌ Неактивна",
"ADMIN_USER_TRAFFIC_USAGE": "{used}/{limit} ГБ",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION": "<b>Подписка:</b>\n• Тип: {type}\n• Статус: {status}\n• До: {end_date}\n• Трафик: {traffic}\n• Устройства: {devices}\n• Стран: {countries}",
"ADMIN_USER_MANAGEMENT_SUBSCRIPTION_NONE": "<b>Подписка:</b> Отсутствует",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP": "<b>Промогруппа:</b>\n• Название: {name}\n• Скидка на сервера: {server_discount}%\n• Скидка на трафик: {traffic_discount}%\n• Скидка на устройства: {device_discount}%",
"ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE": "<b>Промогруппа:</b> Не назначена",
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_USERS": "👥 Пользователи",
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:",
"AUTOPAY_BUTTON": "💳 Автоплатёж",
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
"AUTOPAY_STATUS_ENABLED": "включен",
"AUTOPAY_STATUS_DISABLED": "выключен",
"AUTOPAY_MENU_TEXT": "💳 <b>Автоплатеж</b>\n\n📊 <b>Статус:</b> {status}\n⏰ <b>Списание за:</b> {days} дн. до окончания\n\nВыберите действие:",
"AUTOPAY_TOGGLE_SUCCESS": "✅ Автоплатеж {status}!",
"AUTOPAY_SELECT_DAYS_PROMPT": "⏰ Выберите за сколько дней до окончания списывать средства:",
"AUTOPAY_DAYS_SET": "✅ Установлено {days} дней!",
"BACK": "⬅️ Назад",
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
"BALANCE_BUTTON": "💰 Баланс: {balance}",
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
"BALANCE_HISTORY": "📊 История операций",
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOP_UP": "💳 Пополнить",
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
"CANCEL": "❌ Отмена",
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
"CONFIRM": "✅ Подтвердить",
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
"CONNECT_BUTTON": "🔗 Подключиться",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
"HAPP_PLATFORM_IOS": "🍎 iOS",
"HAPP_PLATFORM_ANDROID": "🤖 Android",
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
"HAPP_PLATFORM_PC": "💻 ПК",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"CONTINUE_BUTTON": "✅ Продолжить",
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
"CREATE_INVITE": "📝 Создать приглашение",
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"DEVICE_CHANGE_ACTION_INCREASE": "увеличить до {count}",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"DEVICE_CHANGE_FREE": "Бесплатно",
"DEVICE_CHANGE_ACTION_DECREASE": "уменьшить до {count}",
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICE_CHANGE_CHARGED": "💰 Списано: {amount}",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_GUIDE_ANDROID": "🤖 Android",
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
"DEVICE_GUIDE_MAC": "🎯 macOS",
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
"DISABLE_BUTTON": "❌ Выключить",
"ENABLE_BUTTON": "✅ Включить",
"ERROR": "❌ Произошла ошибка",
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
"INVALID_AMOUNT": "❌ Неверная сумма",
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
"LOADING": "⏳ Загрузка...",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
"MAIN_MENU_BUTTON": "🏠 Главное меню",
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
"DEVICE_NONE_CONNECTED": "ℹ️ У вас нет подключенных устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
"DEVICE_MANAGEMENT_OVERVIEW": "🔄 <b>Управление устройствами</b>\n\n📊 Всего подключено: {total} устройств\n📄 Страница {page} из {pages}\n\n",
"DEVICE_MANAGEMENT_CONNECTED_HEADER": "<b>Подключенные устройства:</b>\n",
"DEVICE_MANAGEMENT_LIST_ITEM": "• {device}\n",
"DEVICE_MANAGEMENT_ACTIONS": "\n💡 <b>Действия:</b>\n• Выберите устройство для сброса\n• Или сбросьте все устройства сразу",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_PAGE_LOAD_ERROR": "❌ Ошибка загрузки страницы",
"DEVICE_RESET_INVALID_REQUEST": "❌ Ошибка: некорректный запрос",
"DEVICE_RESET_PARSE_ERROR": "❌ Ошибка обработки запроса",
"DEVICE_RESET_SUCCESS": "✅ Устройство {device} успешно сброшено!",
"DEVICE_RESET_ALL_DONE": "ℹ️ Все устройства сброшены",
"DEVICE_RESET_ID_FAILED": "❌ Не удалось получить ID устройства",
"DEVICE_RESET_NOT_FOUND": "❌ Устройство не найдено",
"DEVICE_RESET_ERROR": "❌ Ошибка сброса устройства",
"DEVICE_LIST_FETCH_ERROR": "❌ Ошибка получения списка устройств",
"DEVICE_RESET_ALL_SUCCESS_MESSAGE": "✅ <b>Все устройства успешно сброшены!</b>\n\n🔄 Сброшено: {count} устройств\n📱 Теперь вы можете заново подключить свои устройства\n\n💡 Используйте ссылку из раздела 'Моя подписка' для повторного подключения",
"DEVICE_RESET_PARTIAL_MESSAGE": "⚠️ <b>Частичный сброс устройств</b>\n\n✅ Удалено: {success} устройств\n❌ Не удалось удалить: {failed} устройств\n\nПопробуйте еще раз или обратитесь в поддержку.",
"DEVICE_RESET_ALL_FAILED_MESSAGE": "❌ <b>Не удалось сбросить устройства</b>\n\nПопробуйте еще раз позже или обратитесь в техподдержку.\n\nВсего устройств: {total}",
"MENU_ADMIN": "⚙️ Админ-панель",
"MENU_BALANCE": "💰 Баланс",
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
"MENU_LANGUAGE": "🌐 Язык",
"MENU_PROMOCODE": "🎫 Промокод",
"MENU_REFERRALS": "🤝 Партнерка",
"MENU_RULES": "📋 Правила сервиса",
"MENU_SERVER_STATUS": "📊 Статус серверов",
"MENU_SUBSCRIPTION": "📱 Подписка",
"MENU_SUPPORT": "🛠️ Техподдержка",
"MENU_TRIAL": "🧪 Тестовая подписка",
"MY_BALANCE_BUTTON": "💰 Мой баланс",
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
"NO": "❌ Нет",
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
"OPERATION_CANCELLED": "❌ Операция отменена",
"OTHER_APPS_BUTTON": "📋 Другие приложения",
"PAGINATION_NEXT": "➡️",
"PAGINATION_PREV": "⬅️",
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
"PROMOCODE_ENTER": "🎫 Введите промокод:",
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
"PROMOCODE_EXPIRED": "❌ Промокод истек",
"PROMOCODE_INVALID": "❌ Неверный промокод",
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
"PROMOCODE_USED": "❌ Промокод уже использован",
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
"RULES_ACCEPT": "✅ Принимаю правила",
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
"RULES_DECLINE": "❌ Не принимаю",
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
"SELECT_COUNTRIES": "Выберите страны:",
"SELECT_DEVICES": "Количество устройств:",
"SELECT_PERIOD": "Выберите период:",
"SELECT_TRAFFIC": "Выберите пакет трафика:",
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
"SHOW_QR_BUTTON": "📱 Показать QR код",
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
"SKIP_BUTTON": "⏭️ Пропустить",
"SUBSCRIPTION_ACTIVE": "✅ Активна",
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_ACTIVE_REQUIRED": "⚠️ У вас нет активной подписки!",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
"SUCCESS": "✅ Успешно",
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
"SERVER_STATUS_AVAILABLE": "✅ <b>Доступны</b>",
"SERVER_STATUS_ERROR_SHORT": "Не удалось получить данные",
"SERVER_STATUS_LATENCY": "{latency} мс",
"SERVER_STATUS_LATENCY_UNKNOWN": "нет данных",
"SERVER_STATUS_NEXT_PAGE": "Вперед ➡️",
"SERVER_STATUS_NO_SERVERS": "Нет данных о серверах.",
"SERVER_STATUS_NOT_CONFIGURED": "Функция недоступна.",
"SERVER_STATUS_OFFLINE": "нет ответа",
"SERVER_STATUS_PAGINATION": "Страница {current} из {total}",
"SERVER_STATUS_PREV_PAGE": "⬅️ Назад",
"SERVER_STATUS_REFRESH": "🔄 Обновить",
"SERVER_STATUS_SUMMARY": "Всего серверов: {total} (в сети: {online}, вне сети: {offline})",
"SERVER_STATUS_TITLE": "📊 <b>Статус серверов</b>",
"SERVER_STATUS_UPDATED_AT": "⏱ Обновлено: {time}",
"SERVER_STATUS_UNAVAILABLE": "❌ <b>Недоступны</b>",
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
"TOP_UP_TRIBUTE": "💎 Банковская карта",
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"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_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
"USER_NOT_FOUND": "❌ Пользователь не найден",
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
"YES": "✅ Да",
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
"SUBSCRIPTION_TYPE_PAID": "Платная",
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_DISABLED": "Автовыдача по суммарным тратам: отключена",
"ADMIN_PROMO_GROUP_AUTO_ASSIGN_LINE": "Автовыдача по суммарным тратам: от {amount} ₽",
"ADMIN_PROMO_GROUP_CREATE_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автоматической выдачи этой группы. Отправьте 0, чтобы отключить.",
"ADMIN_PROMO_GROUP_CREATE_PERIOD_PROMPT": "Введите скидки на периоды подписки (например, 30:10, 90:15). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_EDIT_AUTO_ASSIGN_PROMPT": "Введите сумму общих трат (в ₽) для автовыдачи. Текущее значение: {current}.",
"ADMIN_PROMO_GROUP_EDIT_FIELD_AUTO_ASSIGN": "🤖 Автовыдача по тратам",
"ADMIN_PROMO_GROUP_EDIT_FIELD_DEVICES": "📱 Скидка на устройства",
"ADMIN_PROMO_GROUP_EDIT_FIELD_NAME": "✏️ Изменить название",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PERIODS": "⏳ Скидки по периодам",
"ADMIN_PROMO_GROUP_EDIT_FIELD_SERVERS": "🖥 Скидка на серверы",
"ADMIN_PROMO_GROUP_EDIT_FIELD_TRAFFIC": "🌐 Скидка на трафик",
"ADMIN_PROMO_GROUP_EDIT_MENU_HINT": "Выберите параметр для изменения:",
"ADMIN_PROMO_GROUP_EDIT_MENU_TITLE": "✏️ Настройки промогруппы «{name}»",
"ADMIN_PROMO_GROUP_EDIT_PERIOD_PROMPT": "Введите новые скидки на периоды (текущие: {current}). Отправьте 0, если без скидок.",
"ADMIN_PROMO_GROUP_INVALID_AUTO_ASSIGN": "Введите неотрицательное число в рублях или 0 для отключения.",
"ADMIN_PROMO_GROUP_INVALID_PERIOD_DISCOUNTS": "Введите пары период:скидка через запятую, например 30:10, 90:15, или 0.",
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки по периодам:",
"ADMIN_REPORTS": "📊 Отчеты",
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
"BACK_TO_MENU": "🏠 В главное меню",
"BACK_TO_OPEN_TICKETS": "🔴 Открытые тикеты",
"BACK_TO_SUPPORT": "⬅️ К поддержке",
"BACK_TO_TICKETS": "⬅️ К тикетам",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
"CAMPAIGN_EXISTING_USERL": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
"CANCEL_REPLY": "❌ Отменить ответ",
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
"CLOSED_TICKETS": "🟢 Закрытые",
"CLOSED_TICKETS_HEADER": "🟢 Закрытые тикеты",
"CLOSED_TICKETS_TITLE": "🟢 Закрытые тикеты:",
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
"CLOSE_TICKET": "🔒 Закрыть тикет",
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
"DELETE_MESSAGE": "🗑 Удалить",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
"MULENPAY_PAYMENT_ERROR": "❌ Ошибка создания платежа Mulen Pay. Попробуйте позже или обратитесь в поддержку.",
"MULENPAY_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Mulen Pay</b>\n\n💰 Сумма: {amount}\n🆔 ID платежа: {payment_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через Mulen Pay’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"MULENPAY_PAY_BUTTON": "💳 Оплатить через Mulen Pay",
"MULENPAY_TOPUP_PROMPT": "💳 <b>Оплата через Mulen Pay</b>\n\nВведите сумму для пополнения от 100 до 100 000 ₽.\nОплата происходит через защищенную платформу Mulen Pay.",
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
"NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):",
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
"NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):",
"NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):",
"NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):",
"NO_ATTACHMENTS": "Вложений нет.",
"NO_CLOSED_TICKETS": "Закрытых тикетов пока нет.",
"NO_TICKETS": "У вас пока нет тикетов.",
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
"OPEN_TICKETS": "🔴 Открытые",
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
"PAYMENT_CARD_MULENPAY": "💳 Банковская карта (Mulen Pay)",
"PAYMENT_CARD_PAL24": "🏦 СБП (PayPalych)",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через Mulen Pay",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта (Mulen Pay)</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"REPLY_TO_TICKET": "💬 Ответить",
"REPORT_CLOSE": "❌ Закрыть",
"REPORT_CLOSED": "✅ Отчет закрыт.",
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
"SUBSCRIPTION_HAPP_OPEN_BUTTON_HINT": "▶️ Нажмите кнопку \"Подключиться\" ниже, чтобы открыть Happ и добавить подписку автоматически.",
"SUBSCRIPTION_HAPP_OPEN_HINT": "💡 Если ссылка не открывается автоматически, скопируйте её вручную:",
"SUBSCRIPTION_HAPP_OPEN_LINK": "<a href=\"{subscription_link}\">🔓 Открыть ссылку в Happ</a>",
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>Подключение через Happ</b>",
"SUPPORT_BUTTON": "🆘 Поддержка",
"TICKET_ALREADY_OPEN": "У вас уже есть незакрытый тикет. Сначала закройте его.",
"TICKET_ATTACHMENTS": "📎 Вложения",
"TICKET_CLOSED": "✅ Тикет закрыт.",
"TICKET_CLOSED_NO_REPLY": "❌ Тикет закрыт, ответить невозможно.",
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
"TICKET_CREATE_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
"TICKET_NOT_FOUND": "Тикет не найден.",
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
"TICKET_PRIORITY_LOW": "🟢 Низкий",
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_STATUS_ANSWERED": "Отвечен",
"TICKET_STATUS_CLOSED": "Закрыт",
"TICKET_STATUS_OPEN": "Открыт",
"TICKET_STATUS_PENDING": "В ожидании",
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"UNBLOCK": "✅ Разблокировать",
"USER_BLOCKED_FOREVER": "Вы заблокированы для обращений в поддержку.",
"USER_BLOCKED_UNTIL": "Вы заблокированы до {time}",
"VIEW_CLOSED_TICKETS": "🟢 Закрытые тикеты",
"VIEW_TICKET": "👁️ Посмотреть тикет",
"ADMIN_USERS_SUBMENU_TITLE": "👥 **Управление пользователями и подписками**\n\n",
"ADMIN_PROMO_SUBMENU_TITLE": "💰 **Промокоды и статистика**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_TITLE": "📨 **Коммуникации**\n\n",
"ADMIN_COMMUNICATIONS_SUBMENU_DESCRIPTION": "Управление рассылками и текстами интерфейса:",
"ADMIN_SUBMENU_SELECT_SECTION": "Выберите нужный раздел:",
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION": "Управление тикетами и настройками поддержки:",
"ADMIN_SUPPORT_SUBMENU_DESCRIPTION_MODERATOR": "Доступ к тикетам.",
"ADMIN_SUPPORT_MODERATION_TITLE": "🧑‍⚖️ <b>Модерация поддержки</b>",
"ADMIN_SUPPORT_MODERATION_DESCRIPTION": "Доступ к тикетам поддержки.",
"ADMIN_SUPPORT_AUDIT_TITLE": "🧾 <b>Аудит модераторов</b>",
"ADMIN_SUPPORT_AUDIT_EMPTY": "Пока пусто",
"ADMIN_SUPPORT_AUDIT_ROLE_MODERATOR": "Модератор",
"ADMIN_SUPPORT_AUDIT_ROLE_ADMIN": "Админ",
"ADMIN_SUPPORT_AUDIT_ACTION_CLOSE_TICKET": "Закрытие тикета",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_TIMED": "Блокировка (время)",
"ADMIN_SUPPORT_AUDIT_ACTION_BLOCK_PERM": "Блокировка (навсегда)",
"ADMIN_SUPPORT_AUDIT_ACTION_UNBLOCK": "Снятие блока",
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
"ADMIN_SYSTEM_SUBMENU_TITLE": "🛠️ **Системные функции**\n\n",
"ADMIN_SYSTEM_SUBMENU_DESCRIPTION": "Отчеты, обновления, логи, резервные копии и системные операции:",
"ADMIN_SUPPORT_SETTINGS_STATUS_ENABLED": "Включены",
"ADMIN_SUPPORT_SETTINGS_STATUS_DISABLED": "Отключены",
"ADMIN_SUPPORT_SETTINGS_MENU_LABEL": "Пункт «Техподдержка» в меню",
"ADMIN_SUPPORT_SETTINGS_MODE_TICKETS": "Тикеты",
"ADMIN_SUPPORT_SETTINGS_MODE_CONTACT": "Контакт",
"ADMIN_SUPPORT_SETTINGS_MODE_BOTH": "Оба",
"ADMIN_SUPPORT_SETTINGS_EDIT_DESCRIPTION": "📝 Изменить описание",
"ADMIN_SUPPORT_SETTINGS_ADMIN_NOTIFICATIONS": "Админ-уведомления",
"ADMIN_SUPPORT_SETTINGS_USER_NOTIFICATIONS": "Пользовательские уведомления",
"ADMIN_SUPPORT_SETTINGS_SLA_LABEL": "SLA",
"ADMIN_SUPPORT_SETTINGS_SLA_TIME": "⏳ Время SLA: {minutes} мин",
"ADMIN_SUPPORT_SETTINGS_MODERATORS_COUNT": "🧑‍⚖️ Модераторы: {count}",
"ADMIN_SUPPORT_SETTINGS_ADD_MODERATOR": "➕ Назначить модератора",
"ADMIN_SUPPORT_SETTINGS_REMOVE_MODERATOR": "➖ Удалить модератора",
"ADMIN_SUPPORT_SETTINGS_TITLE": "🛟 <b>Настройки поддержки</b>",
"ADMIN_SUPPORT_SETTINGS_DESCRIPTION": "Режим работы и видимость в меню. Ниже текущее описание меню поддержки:",
"ADMIN_SUPPORT_SLA_SETUP_PROMPT": "⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
"ADMIN_SUPPORT_SLA_INVALID": "❌ Введите корректное число минут (1-1440)",
"ADMIN_SUPPORT_SLA_SAVED": "✅ Значение SLA сохранено",
"ADMIN_SUPPORT_ASSIGN_MODERATOR_PROMPT": "🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
"ADMIN_SUPPORT_REMOVE_MODERATOR_PROMPT": "🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
"ADMIN_SUPPORT_INVALID_TELEGRAM_ID": "❌ Введите корректный Telegram ID (число)",
"ADMIN_SUPPORT_MODERATOR_REMOVED_SUCCESS": "✅ Модератор {tid} удалён",
"ADMIN_SUPPORT_MODERATOR_REMOVED_FAIL": "❌ Не удалось удалить модератора",
"ADMIN_SUPPORT_MODERATOR_ADDED_SUCCESS": "✅ Пользователь {tid} назначен модератором",
"ADMIN_SUPPORT_MODERATOR_ADDED_FAIL": "❌ Не удалось назначить модератора",
"ADMIN_SUPPORT_MODERATORS_EMPTY": "Список пуст",
"ADMIN_SUPPORT_MODERATORS_TITLE": "🧑‍⚖️ <b>Модераторы</b>",
"ADMIN_SUPPORT_SEND_DESCRIPTION": "📨 Прислать текст",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_TITLE": "📝 <b>Редактирование описания поддержки</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CURRENT": "Текущее описание:",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_TITLE": "<b>Контакт для режима «Контакт»</b>",
"ADMIN_SUPPORT_EDIT_DESCRIPTION_CONTACT_HINT": "Добавьте в описание при необходимости.",
"ADMIN_SUPPORT_DESCRIPTION_UPDATED": "✅ Описание обновлено.",
"ADMIN_SUPPORT_DESCRIPTION_SENT": "Текст отправлен ниже",
"ADMIN_SUPPORT_MESSAGE_DELETED": "Сообщение удалено"
}
+186 -569
View File
@@ -1,603 +1,220 @@
from __future__ import annotations
import asyncio
from typing import Dict, Any
import logging
from typing import Any, Dict
from app.config import settings
from app.localization.loader import (
DEFAULT_LANGUAGE,
clear_locale_cache,
load_locale,
)
_cached_rules = {}
_logger = logging.getLogger(__name__)
def _get_default_rules(language: str = "ru") -> str:
if language == "en":
return """
🔒 <b>Service Usage Rules</b>
_cached_rules: Dict[str, str] = {}
1. It is forbidden to use the service for illegal activities
2. Copyright infringement is prohibited
3. Spam and malware distribution are prohibited
4. Using the service for DDoS attacks is prohibited
5. One account - one user
6. Refunds are made only in exceptional cases
7. Administration reserves the right to block an account for violating the rules
<b>By accepting the rules, you agree to comply with them.</b>
"""
else:
return """
📋 <b>Правила использования сервиса</b>
def _get_cached_rules_value(language: str) -> str:
if language in _cached_rules:
return _cached_rules[language]
1. Запрещается использование сервиса для незаконной деятельности
2. Запрещается нарушение авторских прав
3. Запрещается спам и рассылка вредоносного ПО
4. Запрещается использование сервиса для DDoS атак
5. Один аккаунт - один пользователь
6. Возврат средств производится только в исключительных случаях
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
default = _get_default_rules(language)
_cached_rules[language] = default
return default
def _build_dynamic_values(language: str) -> Dict[str, Any]:
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
if language_code == "ru":
return {
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
),
}
if language_code == "en":
return {
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>RemnaWave Support</b>\n\n"
"This is the ticket center: create requests, view replies and history.\n\n"
"• 🎫 Create ticket — describe your issue or question\n"
"• 📋 My tickets — status and conversation\n"
"• 💬 Contact — message directly if needed\n\n"
"Prefer tickets — it helps us respond faster and keep context.\n"
),
}
return {}
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
"""
class Texts:
def __init__(self, language: str = "ru"):
self.language = language
@property
def RULES_TEXT(self) -> str:
if self.language in _cached_rules:
return _cached_rules[self.language]
return _get_default_rules(self.language)
BACK = "⬅️ Назад"
CANCEL = "❌ Отмена"
CONFIRM = "✅ Подтвердить"
CONTINUE = "➡️ Продолжить"
YES = "✅ Да"
NO = "❌ Нет"
LOADING = "⏳ Загрузка..."
ERROR = "❌ Произошла ошибка"
SUCCESS = "✅ Успешно"
def __init__(self, language: str = DEFAULT_LANGUAGE):
self.language = language or DEFAULT_LANGUAGE
raw_data = load_locale(self.language)
self._values = {key: value for key, value in raw_data.items()}
if self.language != DEFAULT_LANGUAGE:
fallback_data = load_locale(DEFAULT_LANGUAGE)
else:
fallback_data = self._values
self._fallback_values = {
key: value for key, value in fallback_data.items() if key not in self._values
}
self._values.update(_build_dynamic_values(self.language))
def __getattr__(self, item: str) -> Any:
if item == "language":
return super().__getattribute__(item)
try:
return self._get_value(item)
except KeyError as error:
raise AttributeError(item) from error
def __getitem__(self, item: str) -> Any:
return self._get_value(item)
def get(self, item: str, default: Any = None) -> Any:
try:
return self._get_value(item)
except KeyError:
return default
def t(self, key: str, default: Any = None) -> Any:
try:
return self._get_value(key)
except KeyError:
if default is not None:
return default
raise
def _get_value(self, item: str) -> Any:
if item == "RULES_TEXT":
return _get_cached_rules_value(self.language)
if item in self._values:
return self._values[item]
if item in self._fallback_values:
return self._fallback_values[item]
_logger.warning(
"Missing localization key '%s' for language '%s'",
item,
self.language,
)
raise KeyError(item)
@staticmethod
def format_price(kopeks: int) -> str:
return f"{int(kopeks / 100)}"
return settings.format_price(kopeks)
@staticmethod
def format_traffic(gb: float) -> str:
if gb == 0:
return "∞ (безлимит)"
elif gb >= 1024:
return f"{gb/1024:.1f} ТБ"
else:
return f"{gb:.0f} ГБ"
if gb >= 1024:
return f"{gb / 1024:.1f} ТБ"
return f"{gb:.0f} ГБ"
class RussianTexts(Texts):
def __init__(self):
super().__init__("ru")
WELCOME = """
🎉 <b>Добро пожаловать в VPN сервис!</b>
def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts:
return Texts(language)
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
🔐 <b>Преимущества:</b>
Высокая скорость подключения
Серверы в разных странах
Надежная защита данных
Круглосуточная поддержка
Для начала работы выберите язык интерфейса:
"""
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
RULES_ACCEPT = "✅ Принимаю правила"
RULES_DECLINE = "❌ Не принимаю"
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
REFERRAL_CODE_QUESTION = """
🤝 <b>У вас есть реферальный код от друга?</b>
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
Введите код или нажмите "Пропустить":
"""
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
MENU_REFERRALS = "🤝 Рефералы"
MENU_SUPPORT = "🛠️ Техподдержка"
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
SUBSCRIPTION_INFO = """
📱 <b>Информация о подписке</b>
📊 <b>Статус:</b> {status}
🎭 <b>Тип:</b> {type}
📅 <b>Действует до:</b> {end_date}
<b>Осталось дней:</b> {days_left}
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
🌍 <b>Серверы:</b> {countries_count} стран
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
💳 <b>Автоплатеж:</b> {autopay_status}
"""
TRIAL_AVAILABLE = """
🎁 <b>Тестовая подписка</b>
Вы можете получить бесплатную тестовую подписку:
<b>Период:</b> {days} дней
📈 <b>Трафик:</b> {traffic} ГБ
📱 <b>Устройства:</b> {devices} шт.
🌍 <b>Сервер:</b> 1 страна
Активировать тестовую подписку?
"""
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
CHANGE_DEVICES_INFO = """
📱 <b>Изменение количества устройств</b>
Текущий лимит: {current_devices} устройств
Выберите новое количество устройств:
💡 <b>Важно:</b>
При увеличении - доплата пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
"""
CHANGE_DEVICES_CONFIRM = """
📱 <b>Подтверждение изменения</b>
Текущее количество: {current_devices} устройств
Новое количество: {new_devices} устройств
Действие: {action}
💰 {cost}
Подтвердить изменение?
"""
CHANGE_DEVICES_SUCCESS_INCREASE = """
Количество устройств увеличено!
📱 Было: {old_count} Стало: {new_count}
💰 Списано: {amount}
"""
CHANGE_DEVICES_SUCCESS_DECREASE = """
Количество устройств уменьшено!
📱 Было: {old_count} Стало: {new_count}
Возврат средств не производится
"""
DEVICES_NO_CHANGE = "ℹ️ Количество устройств не изменилось"
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
BUY_SUBSCRIPTION_START = """
💎 <b>Настройка подписки</b>
Давайте настроим вашу подписку под ваши потребности.
Сначала выберите период подписки:
"""
SELECT_PERIOD = "Выберите период:"
SELECT_TRAFFIC = "Выберите пакет трафика:"
SELECT_COUNTRIES = "Выберите страны:"
SELECT_DEVICES = "Количество устройств:"
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
SUBSCRIPTION_SUMMARY = """
📋 <b>Итоговая конфигурация</b>
📅 <b>Период:</b> {period} дней
📈 <b>Трафик:</b> {traffic}
🌍 <b>Страны:</b> {countries}
📱 <b>Устройства:</b> {devices}
💰 <b>Итого к оплате:</b> {total_price}
Подтвердить покупку?
"""
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
<b>Пополните баланс на {amount} и попробуйте снова.</b>
"""
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
BALANCE_INFO = """
💰 <b>Баланс: {balance}</b>
Выберите действие:
"""
BALANCE_HISTORY = "📊 История операций"
BALANCE_TOP_UP = "💳 Пополнить"
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
TOP_UP_METHODS = """
💳 <b>Выберите способ оплаты</b>
Сумма: {amount}
"""
TOP_UP_STARS = "⭐ Telegram Stars"
TOP_UP_TRIBUTE = "💎 Банковская карта"
PROMOCODE_ENTER = "🎫 Введите промокод:"
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
PROMOCODE_INVALID = "❌ Неверный промокод"
PROMOCODE_EXPIRED = "❌ Промокод истек"
PROMOCODE_USED = "❌ Промокод уже использован"
REFERRAL_INFO = """
🤝 <b>Реферальная программа</b>
👥 <b>Приглашено:</b> {referrals_count} друзей
💰 <b>Заработано:</b> {earned_amount}
🔗 <b>Ваша реферальная ссылка:</b>
<code>{referral_link}</code>
🎫 <b>Ваш промокод:</b>
<code>{referral_code}</code>
💰 <b>Условия:</b>
За каждого друга: {registration_bonus}
Процент с пополнений: {commission_percent}%
"""
REFERRAL_INVITE_MESSAGE = """
🎯 <b>Приглашение в VPN сервис</b>
Привет! Приглашаю тебя в отличный VPN сервис!
🎁 По моей ссылке ты получишь бонус: {bonus}
🔗 Переходи: {link}
🎫 Или используй промокод: {code}
💪 Быстро, надежно, недорого!
"""
CREATE_INVITE = "📝 Создать приглашение"
TRIAL_ENDING_SOON = """
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через несколько часов.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {price}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
MAINTENANCE_MODE_ACTIVE = """
🔧 Технические работы!
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
Ориентировочное время завершения: неизвестно
🔄 Попробуйте позже
Приносим извинения за временные неудобства.
"""
MAINTENANCE_MODE_API_ERROR = """
🔧 Технические работы!
Сервис временно недоступен из-за проблем с подключением к серверам.
Мы работаем над восстановлением. Попробуйте через несколько минут.
🔄 Последняя проверка: {last_check}
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
Новая дата окончания: {new_end_date}
"""
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
💰 Ваш баланс: {balance}
💳 Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
SUPPORT_INFO = f"""
🛠 <b>Техническая поддержка</b>
По всем вопросам обращайтесь к нашей поддержке:
👤 {settings.SUPPORT_USERNAME}
Мы поможем с:
Настройкой подключения
Решением технических проблем
Вопросами по оплате
Другими вопросами
Время ответа: обычно в течение 1-2 часов
"""
CONTACT_SUPPORT = "💬 Написать в поддержку"
ADMIN_PANEL = """
<b>Административная панель</b>
Выберите раздел для управления:
"""
ADMIN_USERS = "👥 Пользователи"
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
ADMIN_PROMOCODES = "🎫 Промокоды"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Рефералы"
ADMIN_RULES = "📋 Правила"
ADMIN_REMNAWAVE = "🖥️ Remnawave"
ADMIN_STATISTICS = "📊 Статистика"
ACCESS_DENIED = "❌ Доступ запрещен"
USER_NOT_FOUND = "❌ Пользователь не найден"
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
INVALID_AMOUNT = "❌ Неверная сумма"
OPERATION_CANCELLED = "❌ Операция отменена"
SUBSCRIPTION_EXPIRING = """
<b>Подписка истекает!</b>
Ваша подписка истекает через {days} дней.
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
"""
SUBSCRIPTION_EXPIRED = """
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
"""
AUTOPAY_SUCCESS = """
<b>Автоплатеж выполнен</b>
Ваша подписка автоматически продлена на {days} дней.
Списано с баланса: {amount}
"""
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
SWITCH_TRAFFIC_INFO = """
🔄 <b>Переключение лимита трафика</b>
Текущий лимит: {current_traffic}
Выберите новый лимит трафика:
💡 <b>Важно:</b>
При увеличении - доплата за разницу пропорционально оставшемуся времени
При уменьшении - возврат средств не производится
Счетчик использованного трафика НЕ сбрасывается
"""
SWITCH_TRAFFIC_CONFIRM = """
🔄 <b>Подтверждение переключения трафика</b>
Текущий лимит: {current_traffic}
Новый лимит: {new_traffic}
Действие: {action}
💰 {cost}
Подтвердить переключение?
"""
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
Лимит трафика увеличен!
📊 Было: {old_traffic} Стало: {new_traffic}
💰 Списано: {amount}
"""
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
Лимит трафика уменьшен!
📊 Было: {old_traffic} Стало: {new_traffic}
Возврат средств не производится
"""
TRAFFIC_NO_CHANGE = "ℹ️ Лимит трафика не изменился"
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
AUTOPAY_FAILED = """
<b>Ошибка автоплатежа</b>
Не удалось списать средства для продления подписки.
Недостаточно средств на балансе: {balance}
Требуется: {required}
Пополните баланс и продлите подписку вручную.
"""
class EnglishTexts(Texts):
def __init__(self):
super().__init__("en")
WELCOME = """
🎉 <b>Welcome to VPN Service!</b>
Our service provides fast and secure internet access without restrictions.
🔐 <b>Advantages:</b>
High connection speed
Servers in different countries
Reliable data protection
24/7 support
To get started, select interface language:
"""
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
BACK = "⬅️ Back"
CANCEL = "❌ Cancel"
CONFIRM = "✅ Confirm"
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
MENU_TRIAL = "🎁 Trial subscription"
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
Top up {amount} and try again."""
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
LANGUAGES = {
"ru": RussianTexts,
"en": EnglishTexts
}
def get_texts(language: str = "ru") -> Texts:
return LANGUAGES.get(language, RussianTexts)()
async def get_rules_from_db(language: str = "ru") -> str:
async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str:
try:
from app.database.database import get_db
from app.database.crud.rules import get_current_rules_content
async for db in get_db():
rules = await get_current_rules_content(db, language)
if rules:
_cached_rules[language] = rules
return rules
break
except Exception as e:
print(f"Ошибка получения правил из БД: {e}")
default_rules = _get_default_rules(language)
_cached_rules[language] = default_rules
return default_rules
def get_rules_sync(language: str = "ru") -> str:
except Exception as error: # pragma: no cover - defensive logging
_logger.warning("Failed to load rules from DB for %s: %s", language, error)
default = _get_default_rules(language)
_cached_rules[language] = default
return default
def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
default_key = "RULES_TEXT_DEFAULT"
locale = load_locale(language)
if default_key in locale:
return locale[default_key]
fallback = load_locale(DEFAULT_LANGUAGE)
return fallback.get(default_key, "")
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
try:
if language in _cached_rules:
return _cached_rules[language]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
rules = loop.run_until_complete(get_rules_from_db(language))
loop.close()
return rules
except Exception as e:
print(f"Ошибка получения правил: {e}")
return _get_default_rules(language)
loop = asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(get_rules(language))
async def refresh_rules_cache(language: str = "ru"):
try:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
print(f"✅ Кеш правил для языка {language} обновлен")
except Exception as e:
print(f"Ошибка обновления кеша правил: {e}")
loop.create_task(get_rules(language))
return _get_cached_rules_value(language)
def clear_rules_cache():
global _cached_rules
async def get_rules(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
return await get_rules_from_db(language)
async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None:
if language in _cached_rules:
del _cached_rules[language]
await get_rules_from_db(language)
def clear_rules_cache() -> None:
_cached_rules.clear()
print("✅ Кеш правил очищен")
def reload_locales() -> None:
clear_locale_cache()
+69 -28
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
from datetime import datetime
from typing import Callable, Dict, Any, Awaitable
@@ -8,11 +9,32 @@ from aiogram.fsm.context import FSMContext
from app.config import settings
from app.database.database import get_db
from app.database.crud.user import get_user_by_telegram_id, create_user
from app.services.remnawave_service import RemnaWaveService
from app.states import RegistrationStates
from app.utils.check_reg_process import is_registration_process
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
async def _refresh_remnawave_description(
remnawave_uuid: str,
description: str,
telegram_id: int
) -> None:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.get_api_client() as api:
await api.update_user(uuid=remnawave_uuid, description=description)
logger.info(
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
)
except Exception as remnawave_error:
logger.error(
f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}"
)
class AuthMiddleware(BaseMiddleware):
async def __call__(
@@ -21,7 +43,7 @@ class AuthMiddleware(BaseMiddleware):
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
user: TgUser = None
if isinstance(event, (Message, CallbackQuery)):
user = event.from_user
@@ -42,24 +64,23 @@ class AuthMiddleware(BaseMiddleware):
if state:
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
is_registration_process = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (current_state in registration_states)
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
)
)
is_reg_process = is_registration_process(event, current_state)
if is_registration_process:
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
is_channel_check = (isinstance(event, CallbackQuery)
and event.data == "sub_channel_check")
is_start_command = (isinstance(event, Message)
and event.text
and event.text.startswith('/start'))
if is_reg_process or is_channel_check or is_start_command:
if is_start_command:
logger.info(f"🚀 Пропускаем команду /start от пользователя {user.id}")
elif is_channel_check:
logger.info(f"🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала")
else:
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
data['db'] = db
data['db_user'] = None
data['is_admin'] = False
@@ -95,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
@@ -105,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
and (
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
or event.data.startswith('language_select:')
)
)
)
@@ -135,27 +160,43 @@ class AuthMiddleware(BaseMiddleware):
if db_user.username != user.username:
old_username = db_user.username
db_user.username = user.username
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
profile_updated = True
if db_user.first_name != user.first_name:
safe_first = sanitize_telegram_name(user.first_name)
safe_last = sanitize_telegram_name(user.last_name)
if db_user.first_name != safe_first:
old_first_name = db_user.first_name
db_user.first_name = user.first_name
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
db_user.first_name = safe_first
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
profile_updated = True
if db_user.last_name != user.last_name:
if db_user.last_name != safe_last:
old_last_name = db_user.last_name
db_user.last_name = user.last_name
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
db_user.last_name = safe_last
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
profile_updated = True
db_user.last_activity = datetime.utcnow()
if profile_updated:
db_user.updated_at = datetime.utcnow()
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
if db_user.remnawave_uuid:
description = settings.format_remnawave_user_description(
full_name=db_user.full_name,
username=db_user.username,
telegram_id=db_user.telegram_id
)
asyncio.create_task(
_refresh_remnawave_description(
remnawave_uuid=db_user.remnawave_uuid,
description=description,
telegram_id=db_user.telegram_id
)
)
await db.commit()
data['db'] = db
@@ -170,4 +211,4 @@ class AuthMiddleware(BaseMiddleware):
if hasattr(event, 'data'):
logger.error(f"Callback data: {event.data}")
await db.rollback()
raise
raise
+210
View File
@@ -0,0 +1,210 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware, Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.database.database import get_db
from app.database.crud.subscription import deactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.check_reg_process import is_registration_process
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
class ChannelCheckerMiddleware(BaseMiddleware):
def __init__(self):
self.BAD_MEMBER_STATUS = (
ChatMemberStatus.LEFT,
ChatMemberStatus.KICKED,
ChatMemberStatus.RESTRICTED
)
self.GOOD_MEMBER_STATUS = (
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR
)
logger.info("🔧 ChannelCheckerMiddleware инициализирован")
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
elif event.callback_query:
telegram_id = event.callback_query.from_user.id
if telegram_id is None:
logger.debug("❌ telegram_id не найден, пропускаем")
return await handler(event, data)
# Админам разрешаем пропускать проверку подписки, чтобы не блокировать
# работу панели управления даже при отсутствии подписки. Важно делать
# это до обращения к состоянию, чтобы не выполнять лишние операции.
if settings.is_admin(telegram_id):
logger.debug(
"✅ Пользователь %s является администратором — пропускаем проверку подписки",
telegram_id,
)
return await handler(event, data)
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
is_reg_process = is_registration_process(event, current_state)
if is_reg_process:
logger.debug("✅ Событие разрешено (процесс регистрации), пропускаем проверку")
return await handler(event, data)
bot: Bot = data["bot"]
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
return await handler(event, data)
is_required = settings.CHANNEL_IS_REQUIRED_SUB
if not is_required:
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data)
channel_link = settings.CHANNEL_LINK
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
if member.status in self.GOOD_MEMBER_STATUS:
return await handler(event, data)
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
return await self._deny_message(event, bot, channel_link)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
return await self._deny_message(event, bot, channel_link)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
return await self._deny_message(event, bot, channel_link)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
logger.error(f"❌ Канал {channel_id} не найден: {e}")
elif "user not found" in str(e).lower():
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
return await self._deny_message(event, bot, channel_link)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
async for db in get_db():
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
logger.debug(
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
telegram_id,
)
break
subscription = user.subscription
if (not subscription.is_trial or
subscription.status != SubscriptionStatus.ACTIVE.value):
logger.debug(
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
telegram_id,
subscription.is_trial,
subscription.status,
)
break
await deactivate_subscription(db, subscription)
logger.info(
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
telegram_id,
)
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
user.remnawave_uuid,
api_error,
)
except Exception as db_error:
logger.error(
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
telegram_id,
db_error,
)
finally:
break
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, "from_user", None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
text = texts.t(
"CHANNEL_REQUIRED_TEXT",
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
)
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
elif isinstance(event, CallbackQuery):
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
logger.error(f"❌ Ошибка при отправке сообщения о подписке: {e}")
+22 -3
View File
@@ -4,6 +4,7 @@ import time
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery, TelegramObject
from aiogram.fsm.context import FSMContext
logger = logging.getLogger(__name__)
@@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware):
if now - last_call < self.rate_limit:
logger.warning(f"🚫 Throttling для пользователя {user_id}")
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
if isinstance(event, Message):
try:
fsm: FSMContext = data.get("state") # может отсутствовать
current = await fsm.get_state() if fsm else None
except Exception:
current = None
is_ticket_state = False
if current:
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
lowered = str(current)
is_ticket_state = (
(":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and
("TicketStates" in lowered or "AdminTicketStates" in lowered)
)
if is_ticket_state:
return
# В остальных случаях — явный блок
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
return
# Для callback допустим краткое уведомление
elif isinstance(event, CallbackQuery):
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
return
return
self.user_buckets[user_id] = now
+452 -164
View File
@@ -1,13 +1,23 @@
import logging
from typing import Optional, Dict, Any, List
from datetime import datetime
from aiogram import Bot
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, Subscription, Transaction
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import get_user_by_id
from app.database.models import (
AdvertisingCampaign,
PromoCodeType,
PromoGroup,
Subscription,
Transaction,
TransactionType,
User,
)
logger = logging.getLogger(__name__)
@@ -18,25 +28,147 @@ class AdminNotificationService:
self.bot = bot
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
if not referred_by_id:
return "Нет"
try:
referrer = await get_user_by_id(db, referred_by_id)
if not referrer:
return f"ID {referred_by_id} (не найден)"
if referrer.username:
return f"@{referrer.username} (ID: {referred_by_id})"
else:
return f"ID {referrer.telegram_id}"
except Exception as e:
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
return f"ID {referred_by_id}"
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
if getattr(user, "promo_group", None):
return user.promo_group
if not user.promo_group_id:
return None
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
# relationship might not be available — fallback to direct fetch
pass
if getattr(user, "promo_group", None):
return user.promo_group
try:
return await get_promo_group_by_id(db, user.promo_group_id)
except Exception as e:
logger.error(
"Ошибка загрузки промогруппы %s пользователя %s: %s",
user.promo_group_id,
user.telegram_id,
e,
)
return None
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
discount_map = {
"servers": ("Серверы", promo_group.server_discount_percent),
"traffic": ("Трафик", promo_group.traffic_discount_percent),
"devices": ("Устройства", promo_group.device_discount_percent),
}
for _, (title, percent) in discount_map.items():
if percent and percent > 0:
discount_lines.append(f"{title}: -{percent}%")
period_discounts_raw = promo_group.period_discounts or {}
period_items: List[tuple[int, int]] = []
if isinstance(period_discounts_raw, dict):
for raw_days, raw_percent in period_discounts_raw.items():
try:
days = int(raw_days)
percent = int(raw_percent)
except (TypeError, ValueError):
continue
if percent > 0:
period_items.append((days, percent))
period_items.sort(key=lambda item: item[0])
if period_items:
formatted_periods = ", ".join(
f"{days} д. — -{percent}%" for days, percent in period_items
)
discount_lines.append(f"• Периоды: {formatted_periods}")
if promo_group.apply_discounts_to_addons:
discount_lines.append("• Доп. услуги: ✅ скидка действует")
else:
discount_lines.append("• Доп. услуги: ❌ без скидки")
return discount_lines
def _format_promo_group_block(
self,
promo_group: Optional[PromoGroup],
*,
title: str = "Промогруппа",
icon: str = "🏷️",
) -> str:
if not promo_group:
return f"{icon} <b>{title}:</b> —"
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
discount_lines = self._format_promo_group_discounts(promo_group)
if discount_lines:
lines.append("💸 <b>Скидки:</b>")
lines.extend(discount_lines)
else:
lines.append("💸 <b>Скидки:</b> отсутствуют")
return "\n".join(lines)
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
mapping = {
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
}
if not promo_type:
return "️ Не указан"
return mapping.get(promo_type, f"{promo_type}")
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
if campaign.is_balance_bonus:
return [
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
]
if campaign.is_subscription_bonus:
default_devices = getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)
details = [
f"📅 Дней подписки: {campaign.subscription_duration_days or 0}",
f"📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ",
f"📱 Устройства: {campaign.subscription_device_limit or default_devices}",
]
if campaign.subscription_squads:
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
return details
return ["ℹ️ Бонусы не предусмотрены"]
async def send_trial_activation_notification(
self,
@@ -50,7 +182,9 @@ class AdminNotificationService:
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -58,6 +192,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
@@ -100,7 +236,9 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -108,6 +246,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -212,13 +352,30 @@ class AdminNotificationService:
) -> bool:
if not self._is_enabled():
return False
try:
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
deposit_count_result = await db.execute(
select(func.count())
.select_from(Transaction)
.where(
Transaction.user_id == user.id,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed.is_(True)
)
)
deposit_count = deposit_count_result.scalar_one() or 0
topup_status = "🆕 Первое пополнение" if deposit_count <= 1 else "🔄 Пополнение"
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
subscription_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = subscription_result.scalar_one_or_none()
subscription_status = self._get_subscription_status(subscription)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -226,6 +383,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
{promo_block}
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -237,7 +396,7 @@ class AdminNotificationService:
Изменение: +{settings.format_price(balance_change)}
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
📱 <b>Подписка:</b> {subscription_status}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -254,21 +413,31 @@ class AdminNotificationService:
subscription: Subscription,
transaction: Transaction,
extended_days: int,
old_end_date: datetime
old_end_date: datetime,
*,
new_end_date: datetime | None = None,
balance_after: int | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -277,24 +446,207 @@ class AdminNotificationService:
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
return False
async def _send_message(self, text: str) -> bool:
async def send_promocode_activation_notification(
self,
db: AsyncSession,
user: User,
promocode_data: Dict[str, Any],
effect_description: str,
) -> bool:
if not self._is_enabled():
return False
try:
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
message_lines = [
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🎟️ <b>Промокод:</b>",
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
f"🧾 Тип: {type_display}",
f"📊 Использования: {usage_info}",
]
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
if balance_bonus:
message_lines.append(
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
)
subscription_days = promocode_data.get("subscription_days", 0)
if subscription_days:
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
message_lines.extend(
[
"",
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
return False
async def send_campaign_link_visit_notification(
self,
db: AsyncSession,
telegram_user: types.User,
campaign: AdvertisingCampaign,
user: Optional[User] = None,
) -> bool:
if not self._is_enabled():
return False
try:
user_status = "🆕 Новый пользователь" if not user else "👥 Уже зарегистрирован"
promo_block = (
self._format_promo_group_block(await self._get_user_promo_group(db, user))
if user
else self._format_promo_group_block(None)
)
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
username = f"@{telegram_user.username}" if telegram_user.username else "отсутствует"
message_lines = [
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
"",
f"🧾 <b>Кампания:</b> {campaign.name}",
f"🆔 ID кампании: {campaign.id}",
f"🔗 Start-параметр: <code>{campaign.start_parameter}</code>",
"",
f"👤 <b>Пользователь:</b> {full_name}",
f"🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>",
f"📱 <b>Username:</b> {username}",
user_status,
"",
promo_block,
"",
"🎯 <b>Бонус кампании:</b>",
]
bonus_lines = self._format_campaign_bonus(campaign)
message_lines.extend(bonus_lines)
message_lines.extend(
[
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
return False
async def send_user_promo_group_change_notification(
self,
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
*,
reason: Optional[str] = None,
initiator: Optional[User] = None,
automatic: bool = False,
) -> bool:
if not self._is_enabled():
return False
try:
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
initiator_line = None
if initiator:
initiator_line = (
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
]
if old_group and old_group.id != new_group.id:
message_lines.extend(
[
"",
self._format_promo_group_block(
old_group, title="Предыдущая промогруппа", icon="♻️"
),
]
)
if initiator_line:
message_lines.extend(["", initiator_line])
if reason:
message_lines.extend(["", f"📝 Причина: {reason}"])
message_lines.extend(
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
return False
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
return False
@@ -307,8 +659,16 @@ class AdminNotificationService:
'disable_web_page_preview': True
}
if self.topic_id:
message_kwargs['message_thread_id'] = self.topic_id
# route to ticket-specific topic if provided
thread_id = None
if ticket_event and self.ticket_topic_id:
thread_id = self.ticket_topic_id
elif self.topic_id:
thread_id = self.topic_id
if thread_id:
message_kwargs['message_thread_id'] = thread_id
if reply_markup is not None:
message_kwargs['reply_markup'] = reply_markup
await self.bot.send_message(**message_kwargs)
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
@@ -332,6 +692,8 @@ class AdminNotificationService:
'telegram_stars': '⭐ Telegram Stars',
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': '💳 Mulen Pay (карта)',
'pal24': '🏦 PayPalych (СБП)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
}
@@ -339,22 +701,21 @@ class AdminNotificationService:
if not payment_method:
return '💰 С баланса'
return method_names.get(payment_method, f'💰 С баланса')
return method_names.get(payment_method, '💰 С баланса')
def _format_traffic(self, traffic_gb: int) -> str:
if traffic_gb == 0:
return "∞ Безлимит"
return f"{traffic_gb} ГБ"
def _get_subscription_status(self, user: User) -> str:
if not user.subscription:
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
if not subscription:
return "❌ Нет подписки"
sub = user.subscription
if sub.is_trial:
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
elif sub.is_active:
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
if subscription.is_trial:
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
elif subscription.is_active:
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
else:
return "❌ Неактивна"
@@ -387,40 +748,32 @@ class AdminNotificationService:
if details.get("auto_enabled", False):
icon = "⚠️"
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "warning"
else:
icon = "🔧"
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "info"
elif event_type == "disable":
icon = ""
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "success"
elif event_type == "api_status":
if status == "online":
icon = "🟢"
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
alert_type = "success"
else:
icon = "🔴"
title = "API REMNAWAVE НЕДОСТУПНО"
alert_type = "error"
elif event_type == "monitoring":
if status == "started":
icon = "🔍"
title = "МОНИТОРИНГ ЗАПУЩЕН"
alert_type = "info"
else:
icon = "⏹️"
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
alert_type = "info"
else:
icon = ""
title = "СИСТЕМА ТЕХРАБОТ"
alert_type = "info"
message_parts = [f"{icon} <b>{title}</b>", ""]
@@ -610,103 +963,6 @@ class AdminNotificationService:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
if details.get("manual_message"):
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_subscription_update_notification(
self,
db: AsyncSession,
@@ -722,50 +978,64 @@ class AdminNotificationService:
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
}
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
message = f"""{title}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
🔧 <b>Изменение:</b>
📋 Параметр: {param_name}"""
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🔧 <b>Изменение:</b>",
f"📋 Параметр: {param_name}",
]
if update_type == "servers":
old_servers_info = await self._format_servers_detailed(old_value)
new_servers_info = await self._format_servers_detailed(new_value)
message += f"""
📉 Было: {old_servers_info}
📈 Стало: {new_servers_info}"""
message_lines.extend(
[
f"📉 Было: {old_servers_info}",
f"📈 Стало: {new_servers_info}",
]
)
else:
message += f"""
📉 Было: {self._format_update_value(old_value, update_type)}
📈 Стало: {self._format_update_value(new_value, update_type)}"""
message_lines.extend(
[
f"📉 Было: {self._format_update_value(old_value, update_type)}",
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
]
)
if price_paid > 0:
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
else:
message += f"\n💸 Бесплатно"
message_lines.append("💸 Бесплатно")
message += f"""
message_lines.extend(
[
"",
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
f"🔗 <b>Рефер:</b> {referrer_info}",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Рефер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
@@ -801,3 +1071,21 @@ class AdminNotificationService:
return str(value)
return str(value)
async def send_ticket_event_notification(
self,
text: str,
keyboard: types.InlineKeyboardMarkup | None = None
) -> bool:
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
Учитывает настройки включенности в settings.
"""
# Respect runtime toggle for admin ticket notifications
try:
from app.services.support_settings_service import SupportSettingsService
runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled()
except Exception:
runtime_enabled = True
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
+550 -133
View File
@@ -12,6 +12,7 @@ import aiofiles
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.config import settings
@@ -21,7 +22,10 @@ from app.database.models import (
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
SubscriptionConversion, SentNotification, BroadcastHistory,
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
CryptoBotPayment, Base
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
server_squad_promo_groups
)
logger = logging.getLogger(__name__)
@@ -30,7 +34,7 @@ logger = logging.getLogger(__name__)
@dataclass
class BackupMetadata:
timestamp: str
version: str = "1.0"
version: str = "1.2"
database_type: str = "postgresql"
backup_type: str = "full"
tables_count: int = 0
@@ -60,16 +64,43 @@ class BackupService:
self._auto_backup_task = None
self._settings = self._load_settings()
self.backup_models = [
User, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, ServiceRule, SystemSetting,
SubscriptionConversion, SentNotification, BroadcastHistory,
ServerSquad, SubscriptionServer, UserMessage,
YooKassaPayment, CryptoBotPayment
self.backup_models_ordered = [
SystemSetting,
ServiceRule,
Squad,
ServerSquad,
PromoGroup,
User,
PromoCode,
WelcomeText,
UserMessage,
Subscription,
SubscriptionServer,
SubscriptionConversion,
Transaction,
YooKassaPayment,
CryptoBotPayment,
MulenPayPayment,
Pal24Payment,
PromoCodeUse,
ReferralEarning,
SentNotification,
DiscountOffer,
BroadcastHistory,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Ticket,
TicketMessage,
SupportAuditLog,
WebApiToken,
]
if self._settings.include_logs:
self.backup_models.append(MonitoringLog)
self.backup_models_ordered.append(MonitoringLog)
self.association_tables = {
"server_squad_promo_groups": server_squad_promo_groups,
}
def _load_settings(self) -> BackupSettings:
return BackupSettings(
@@ -82,6 +113,53 @@ class BackupService:
backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups")
)
def _parse_backup_time(self) -> Tuple[int, int]:
time_str = (self._settings.backup_time or "").strip()
try:
parts = time_str.split(":")
if len(parts) != 2:
raise ValueError("Invalid time format")
hours, minutes = map(int, parts)
if not (0 <= hours < 24 and 0 <= minutes < 60):
raise ValueError("Hours or minutes out of range")
return hours, minutes
except ValueError:
default_hours, default_minutes = 3, 0
logger.warning(
"Некорректное значение BACKUP_TIME='%s'. Используется значение по умолчанию 03:00.",
self._settings.backup_time
)
self._settings.backup_time = "03:00"
return default_hours, default_minutes
def _calculate_next_backup_datetime(self, reference: Optional[datetime] = None) -> datetime:
reference = reference or datetime.now()
hours, minutes = self._parse_backup_time()
next_run = reference.replace(hour=hours, minute=minutes, second=0, microsecond=0)
if next_run <= reference:
next_run += timedelta(days=1)
return next_run
def _get_backup_interval(self) -> timedelta:
hours = self._settings.backup_interval_hours
if hours <= 0:
logger.warning(
"Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.",
hours
)
hours = 24
self._settings.backup_interval_hours = hours
return timedelta(hours=hours)
async def create_backup(
self,
created_by: Optional[int] = None,
@@ -89,18 +167,19 @@ class BackupService:
include_logs: bool = None
) -> Tuple[bool, str, Optional[str]]:
try:
logger.info("🔄 Начинаем создание бекапа...")
logger.info("📄 Начинаем создание бекапа...")
if include_logs is None:
include_logs = self._settings.include_logs
models_to_backup = self.backup_models.copy()
models_to_backup = self.backup_models_ordered.copy()
if not include_logs and MonitoringLog in models_to_backup:
models_to_backup.remove(MonitoringLog)
elif include_logs and MonitoringLog not in models_to_backup:
models_to_backup.append(MonitoringLog)
backup_data = {}
association_data = {}
total_records = 0
async for db in get_db():
@@ -109,7 +188,16 @@ class BackupService:
table_name = model.__tablename__
logger.info(f"📊 Экспортируем таблицу: {table_name}")
result = await db.execute(select(model))
query = select(model)
if model == User:
query = query.options(selectinload(User.subscription))
elif model == Subscription:
query = query.options(selectinload(Subscription.user))
elif model == Transaction:
query = query.options(selectinload(Transaction.user))
result = await db.execute(query)
records = result.scalars().all()
table_data = []
@@ -118,8 +206,12 @@ class BackupService:
for column in model.__table__.columns:
value = getattr(record, column.name)
if isinstance(value, datetime):
if value is None:
record_dict[column.name] = None
elif isinstance(value, datetime):
record_dict[column.name] = value.isoformat()
elif isinstance(value, (list, dict)):
record_dict[column.name] = json_lib.dumps(value) if value else None
elif hasattr(value, '__dict__'):
record_dict[column.name] = str(value)
else:
@@ -131,7 +223,11 @@ class BackupService:
total_records += len(table_data)
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
association_data = await self._export_association_tables(db)
for records in association_data.values():
total_records += len(records)
break
except Exception as e:
logger.error(f"Ошибка при экспорте данных: {e}")
@@ -143,7 +239,7 @@ class BackupService:
timestamp=datetime.utcnow().isoformat(),
database_type="postgresql" if settings.is_postgresql() else "sqlite",
backup_type="full",
tables_count=len(models_to_backup),
tables_count=len(models_to_backup) + len(association_data),
total_records=total_records,
compressed=compress,
created_by=created_by,
@@ -156,10 +252,17 @@ class BackupService:
filename += ".gz"
backup_path = self.backup_dir / filename
file_snapshots = await self._collect_file_snapshots()
backup_structure = {
"metadata": asdict(metadata),
"data": backup_data
"data": backup_data,
"associations": association_data,
"files": file_snapshots,
"config": {
"backup_settings": asdict(self._settings)
}
}
if compress:
@@ -188,7 +291,7 @@ class BackupService:
size_mb = file_size / 1024 / 1024
message = (f"✅ Бекап успешно создан!\n"
f"📁 Файл: {filename}\n"
f"📊 Таблиц: {len(models_to_backup)}\n"
f"📊 Таблиц: {metadata.tables_count}\n"
f"📈 Записей: {total_records:,}\n"
f"💾 Размер: {size_mb:.2f} MB")
@@ -218,7 +321,7 @@ class BackupService:
clear_existing: bool = False
) -> Tuple[bool, str]:
try:
logger.info(f"🔄 Начинаем восстановление из {backup_file_path}")
logger.info(f"📄 Начинаем восстановление из {backup_file_path}")
backup_path = Path(backup_file_path)
if not backup_path.exists():
@@ -236,6 +339,8 @@ class BackupService:
metadata = backup_structure.get("metadata", {})
backup_data = backup_structure.get("data", {})
association_data = backup_structure.get("associations", {})
file_snapshots = backup_structure.get("files", {})
if not backup_data:
return False, "❌ Файл бекапа не содержит данных"
@@ -252,104 +357,56 @@ class BackupService:
logger.warning("🗑️ Очищаем существующие данные...")
await self._clear_database_tables(db)
for table_name, records in backup_data.items():
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
pre_restore_tables = {"promo_groups"}
for table_name in pre_restore_tables:
model = models_by_table.get(table_name)
if not model:
continue
records = backup_data.get(table_name, [])
if not records:
continue
model = None
for m in self.backup_models:
if m.__tablename__ == table_name:
model = m
break
if not model:
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
restored_records += restored
if restored:
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
await self._restore_users_without_referrals(db, backup_data, models_by_table)
for model in self.backup_models_ordered:
table_name = model.__tablename__
if table_name == "users" or table_name in pre_restore_tables:
continue
logger.info(f"📥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
for record_data in records:
try:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str and isinstance(value, str):
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
else:
processed_data[key] = value
# Проверяем существует ли запись с таким ID
primary_key_col = None
for col in model.__table__.columns:
if col.primary_key:
primary_key_col = col.name
break
if primary_key_col and primary_key_col in processed_data:
# Проверяем существование записи
existing_record = await db.execute(
select(model).where(
getattr(model, primary_key_col) == processed_data[primary_key_col]
)
)
existing = existing_record.scalar_one_or_none()
if existing:
# Обновляем существующую запись
for key, value in processed_data.items():
if key != primary_key_col: # Не обновляем primary key
setattr(existing, key, value)
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
else:
# Создаем новую запись
instance = model(**processed_data)
db.add(instance)
else:
# Если нет primary key или он не в данных, просто добавляем
instance = model(**processed_data)
db.add(instance)
restored_records += 1
except Exception as e:
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
continue
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
records = backup_data.get(table_name, [])
if not records:
continue
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
restored = await self._restore_table_records(db, model, table_name, records, clear_existing)
restored_records += restored
if restored:
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
await self._update_user_referrals(db, backup_data)
assoc_tables, assoc_records = await self._restore_association_tables(
db,
association_data,
clear_existing
)
restored_tables += assoc_tables
restored_records += assoc_records
await db.commit()
break
@@ -370,6 +427,11 @@ class BackupService:
if self.bot:
await self._send_backup_notification("restore_success", message)
if file_snapshots:
restored_files = await self._restore_file_snapshots(file_snapshots)
if restored_files:
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
return True, message
@@ -382,13 +444,290 @@ class BackupService:
return False, error_msg
async def _restore_users_without_referrals(self, db: AsyncSession, backup_data: dict, models_by_table: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info(f"👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей")
User = models_by_table["users"]
for user_data in users_data:
try:
processed_data = self._process_record_data(user_data, User, "users")
processed_data['referred_by_id'] = None
if 'id' in processed_data:
existing_user = await db.execute(
select(User).where(User.id == processed_data['id'])
)
existing = existing_user.scalar_one_or_none()
if existing:
for key, value in processed_data.items():
if key != 'id':
setattr(existing, key, value)
else:
instance = User(**processed_data)
db.add(instance)
else:
instance = User(**processed_data)
db.add(instance)
except Exception as e:
logger.error(f"Ошибка при восстановлении пользователя: {e}")
await db.rollback()
raise e
await db.commit()
logger.info("✅ Пользователи без реферальных связей восстановлены")
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
users_data = backup_data.get("users", [])
if not users_data:
return
logger.info("🔗 Обновляем реферальные связи пользователей")
for user_data in users_data:
try:
referred_by_id = user_data.get('referred_by_id')
user_id = user_data.get('id')
if referred_by_id and user_id:
referrer_result = await db.execute(
select(User).where(User.id == referred_by_id)
)
referrer = referrer_result.scalar_one_or_none()
if referrer:
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if user:
user.referred_by_id = referred_by_id
else:
logger.warning(f"Пользователь {user_id} не найден для обновления реферальной связи")
else:
logger.warning(f"Реферер {referred_by_id} не найден для пользователя {user_id}")
except Exception as e:
logger.error(f"Ошибка при обновлении реферальной связи: {e}")
continue
await db.commit()
logger.info("✅ Реферальные связи обновлены")
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
processed_data = {}
for key, value in record_data.items():
if value is None:
processed_data[key] = None
continue
column = getattr(model.__table__.columns, key, None)
if column is None:
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
continue
column_type_str = str(column.type).upper()
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
try:
if 'T' in value:
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
else:
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError) as e:
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
processed_data[key] = datetime.utcnow()
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = int(value)
except ValueError:
processed_data[key] = 0
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
try:
processed_data[key] = float(value)
except ValueError:
processed_data[key] = 0.0
elif 'JSON' in column_type_str:
if isinstance(value, str) and value.strip():
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
elif isinstance(value, (list, dict)):
processed_data[key] = value
else:
processed_data[key] = None
else:
processed_data[key] = value
return processed_data
def _get_primary_key_column(self, model) -> Optional[str]:
for col in model.__table__.columns:
if col.primary_key:
return col.name
return None
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
association_data: Dict[str, List[Dict[str, Any]]] = {}
for table_name, table_obj in self.association_tables.items():
try:
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
result = await db.execute(select(table_obj))
rows = result.mappings().all()
association_data[table_name] = [dict(row) for row in rows]
logger.info(
f"✅ Экспортировано {len(rows)} связей из {table_name}"
)
except Exception as e:
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
return association_data
async def _restore_association_tables(
self,
db: AsyncSession,
association_data: Dict[str, List[Dict[str, Any]]],
clear_existing: bool
) -> Tuple[int, int]:
if not association_data:
return 0, 0
restored_tables = 0
restored_records = 0
if "server_squad_promo_groups" in association_data:
restored = await self._restore_server_squad_promo_groups(
db,
association_data["server_squad_promo_groups"],
clear_existing
)
restored_tables += 1
restored_records += restored
return restored_tables, restored_records
async def _restore_server_squad_promo_groups(
self,
db: AsyncSession,
records: List[Dict[str, Any]],
clear_existing: bool
) -> int:
if not records:
return 0
if clear_existing:
await db.execute(server_squad_promo_groups.delete())
restored = 0
for record in records:
server_id = record.get("server_squad_id")
promo_id = record.get("promo_group_id")
if server_id is None or promo_id is None:
logger.warning(
"Пропущена некорректная запись server_squad_promo_groups: %s",
record
)
continue
try:
await db.execute(
server_squad_promo_groups.insert().values(
server_squad_id=server_id,
promo_group_id=promo_id
)
)
restored += 1
except IntegrityError:
logger.debug(
"Запись server_squad_promo_groups (%s, %s) уже существует",
server_id,
promo_id
)
except Exception as e:
logger.error(
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
server_id,
promo_id,
e
)
await db.rollback()
raise e
return restored
async def _restore_table_records(
self,
db: AsyncSession,
model,
table_name: str,
records: List[Dict[str, Any]],
clear_existing: bool
) -> int:
restored_count = 0
for record_data in records:
try:
processed_data = self._process_record_data(record_data, model, table_name)
primary_key_col = self._get_primary_key_column(model)
if primary_key_col and primary_key_col in processed_data:
existing_record = await db.execute(
select(model).where(
getattr(model, primary_key_col) == processed_data[primary_key_col]
)
)
existing = existing_record.scalar_one_or_none()
if existing and not clear_existing:
for key, value in processed_data.items():
if key != primary_key_col:
setattr(existing, key, value)
else:
instance = model(**processed_data)
db.add(instance)
else:
instance = model(**processed_data)
db.add(instance)
restored_count += 1
except Exception as e:
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
logger.error(f"Проблемные данные: {record_data}")
await db.rollback()
raise e
return restored_count
async def _clear_database_tables(self, db: AsyncSession):
tables_order = [
"subscription_servers", "sent_notifications", "broadcast_history",
"subscription_conversions", "referral_earnings", "promocode_uses",
"transactions", "yookassa_payments", "cryptobot_payments",
"subscriptions", "users", "promocodes", "server_squads",
"service_rules", "system_settings", "monitoring_logs", "user_messages"
"server_squad_promo_groups",
"ticket_messages", "tickets", "support_audit_logs",
"advertising_campaign_registrations", "advertising_campaigns",
"subscription_servers", "sent_notifications",
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
"referral_earnings", "promocode_uses",
"yookassa_payments", "cryptobot_payments",
"mulenpay_payments", "pal24_payments",
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"server_squads", "squads", "service_rules",
"system_settings", "web_api_tokens", "monitoring_logs"
]
for table_name in tables_order:
@@ -398,6 +737,57 @@ class BackupService:
except Exception as e:
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
async def _collect_file_snapshots(self) -> Dict[str, Dict[str, Any]]:
snapshots: Dict[str, Dict[str, Any]] = {}
app_config_path = settings.get_app_config_path()
if app_config_path:
path_obj = Path(app_config_path)
if path_obj.exists() and path_obj.is_file():
try:
async with aiofiles.open(path_obj, 'r', encoding='utf-8') as f:
content = await f.read()
snapshots["app_config"] = {
"path": str(path_obj),
"content": content,
"modified_at": datetime.fromtimestamp(
path_obj.stat().st_mtime
).isoformat()
}
logger.info(
"📁 Добавлен в бекап файл конфигурации: %s",
path_obj
)
except Exception as e:
logger.error(
"Ошибка чтения файла конфигурации %s: %s",
path_obj,
e
)
return snapshots
async def _restore_file_snapshots(self, file_snapshots: Dict[str, Dict[str, Any]]) -> int:
restored_files = 0
if not file_snapshots:
return restored_files
app_config_snapshot = file_snapshots.get("app_config")
if app_config_snapshot:
target_path = Path(settings.get_app_config_path())
target_path.parent.mkdir(parents=True, exist_ok=True)
try:
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
await f.write(app_config_snapshot.get("content", ""))
restored_files += 1
logger.info("📁 Файл app-config восстановлен по пути %s", target_path)
except Exception as e:
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
return restored_files
async def get_backup_list(self) -> List[Dict[str, Any]]:
backups = []
@@ -424,7 +814,8 @@ class BackupService:
"file_size_bytes": file_stats.st_size,
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
"created_by": metadata.get("created_by"),
"database_type": metadata.get("database_type", "unknown")
"database_type": metadata.get("database_type", "unknown"),
"version": metadata.get("version", "1.0")
}
backups.append(backup_info)
@@ -443,6 +834,7 @@ class BackupService:
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
"created_by": None,
"database_type": "unknown",
"version": "unknown",
"error": f"Ошибка чтения: {str(e)}"
})
@@ -509,34 +901,59 @@ class BackupService:
async def start_auto_backup(self):
if self._auto_backup_task and not self._auto_backup_task.done():
self._auto_backup_task.cancel()
if self._settings.auto_backup_enabled:
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop())
logger.info(f"🔄 Автобекапы включены, интервал: {self._settings.backup_interval_hours}ч")
next_run = self._calculate_next_backup_datetime()
interval = self._get_backup_interval()
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop(next_run))
logger.info(
"📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s",
interval.total_seconds() / 3600,
next_run.strftime("%d.%m.%Y %H:%M:%S")
)
async def stop_auto_backup(self):
if self._auto_backup_task and not self._auto_backup_task.done():
self._auto_backup_task.cancel()
logger.info("️ Автобекапы остановлены")
logger.info("️ Автобекапы остановлены")
async def _auto_backup_loop(self, next_run: Optional[datetime] = None):
next_run = next_run or self._calculate_next_backup_datetime()
interval = self._get_backup_interval()
async def _auto_backup_loop(self):
while True:
try:
await asyncio.sleep(self._settings.backup_interval_hours * 3600)
logger.info("🔄 Запуск автоматического бекапа...")
now = datetime.now()
delay = (next_run - now).total_seconds()
if delay > 0:
logger.info(
"⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)",
next_run.strftime("%d.%m.%Y %H:%M:%S"),
delay / 3600
)
await asyncio.sleep(delay)
else:
logger.info(
"⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно",
next_run.strftime("%d.%m.%Y %H:%M:%S")
)
logger.info("📄 Запуск автоматического бекапа...")
success, message, _ = await self.create_backup()
if success:
logger.info(f"✅ Автобекап завершен: {message}")
else:
logger.error(f"❌ Ошибка автобекапа: {message}")
next_run = next_run + interval
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Ошибка в цикле автобекапов: {e}")
await asyncio.sleep(3600)
next_run = datetime.now() + interval
async def _send_backup_notification(
self,
@@ -551,7 +968,7 @@ class BackupService:
icons = {
"success": "",
"error": "",
"restore_success": "📥",
"restore_success": "🔥",
"restore_error": ""
}
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
from app.handlers.admin.messages import (
create_broadcast_keyboard,
get_custom_users,
get_target_users,
)
logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
@dataclass(slots=True)
class BroadcastMediaConfig:
type: str
file_id: str
caption: Optional[str] = None
@dataclass(slots=True)
class BroadcastConfig:
target: str
message_text: str
selected_buttons: list[str]
media: Optional[BroadcastMediaConfig] = None
initiator_name: Optional[str] = None
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
cancel_event: asyncio.Event
class BroadcastService:
"""Handles broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._bot: Optional[Bot] = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_bot(self, bot: Bot) -> None:
self._bot = bot
def is_running(self, broadcast_id: int) -> bool:
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
if self._bot is None:
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning("Рассылка %s уже запущена", broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f"broadcast-{broadcast_id}",
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: BroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
return
broadcast.status = "in_progress"
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
for index, user in enumerate(recipients, start=1):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
failed_count += 1
continue
try:
await self._deliver_message(telegram_id, config, keyboard)
sent_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
if index % 20 == 0:
await asyncio.sleep(1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc: # noqa: BLE001
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async with AsyncSessionLocal() as session:
if target.startswith("custom_"):
criteria = target[len("custom_"):]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
return create_broadcast_keyboard(selected_buttons)
async def _deliver_message(
self,
telegram_id: int,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
) -> None:
if not self._bot:
raise RuntimeError("Телеграм-бот не инициализирован")
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == "photo":
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "video":
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "document":
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
reply_markup=keyboard,
)
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = "cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
)
broadcast.completed_at = datetime.utcnow()
await session.commit()
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=True,
)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count or broadcast.failed_count
broadcast.status = "failed"
broadcast.completed_at = datetime.utcnow()
await session.commit()
broadcast_service = BroadcastService()
+171
View File
@@ -0,0 +1,171 @@
import logging
from dataclasses import dataclass
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import record_campaign_registration
from app.database.crud.subscription import (
create_paid_subscription,
get_subscription_by_user_id,
)
from app.database.crud.user import add_user_balance
from app.database.models import AdvertisingCampaign, User
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@dataclass
class CampaignBonusResult:
success: bool
bonus_type: Optional[str] = None
balance_kopeks: int = 0
subscription_days: Optional[int] = None
subscription_traffic_gb: Optional[int] = None
subscription_device_limit: Optional[int] = None
subscription_squads: Optional[List[str]] = None
class AdvertisingCampaignService:
def __init__(self) -> None:
self.subscription_service = SubscriptionService()
async def apply_campaign_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
if not campaign.is_active:
logger.warning(
"⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
)
return CampaignBonusResult(success=False)
if campaign.is_balance_bonus:
return await self._apply_balance_bonus(db, user, campaign)
if campaign.is_subscription_bonus:
return await self._apply_subscription_bonus(db, user, campaign)
logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
return CampaignBonusResult(success=False)
async def _apply_balance_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
amount = campaign.balance_bonus_kopeks or 0
if amount <= 0:
logger.info("️ Кампания %s не имеет бонуса на баланс", campaign.id)
return CampaignBonusResult(success=False)
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
success = await add_user_balance(
db,
user,
amount,
description=description,
)
if not success:
return CampaignBonusResult(success=False)
await record_campaign_registration(
db,
campaign_id=campaign.id,
user_id=user.id,
bonus_type="balance",
balance_bonus_kopeks=amount,
)
logger.info(
"💰 Пользователю %s начислен бонус %s₽ по кампании %s",
user.telegram_id,
amount / 100,
campaign.id,
)
return CampaignBonusResult(
success=True,
bonus_type="balance",
balance_kopeks=amount,
)
async def _apply_subscription_bonus(
self,
db: AsyncSession,
user: User,
campaign: AdvertisingCampaign,
) -> CampaignBonusResult:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription:
logger.warning(
"⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
user.telegram_id,
campaign.id,
)
return CampaignBonusResult(success=False)
duration_days = campaign.subscription_duration_days or 0
if duration_days <= 0:
logger.info(
"️ Кампания %s не содержит корректной длительности подписки",
campaign.id,
)
return CampaignBonusResult(success=False)
traffic_limit = campaign.subscription_traffic_gb
device_limit = (
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
)
squads = list(campaign.subscription_squads or [])
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
squads = [settings.TRIAL_SQUAD_UUID]
new_subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
)
try:
await self.subscription_service.create_remnawave_user(db, new_subscription)
except Exception as error:
logger.error(
"❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
campaign.id,
error,
)
await record_campaign_registration(
db,
campaign_id=campaign.id,
user_id=user.id,
bonus_type="subscription",
subscription_duration_days=duration_days,
)
logger.info(
"🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
user.telegram_id,
campaign.id,
duration_days,
)
return CampaignBonusResult(
success=True,
bonus_type="subscription",
subscription_days=duration_days,
subscription_traffic_gb=traffic_limit or 0,
subscription_device_limit=device_limit,
subscription_squads=squads,
)
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
import hashlib
import logging
from typing import Optional, Dict, Any
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class MulenPayService:
"""Интеграция с Mulen Pay API."""
def __init__(self) -> None:
self.api_key = settings.MULENPAY_API_KEY
self.shop_id = settings.MULENPAY_SHOP_ID
self.secret_key = settings.MULENPAY_SECRET_KEY
self.base_url = settings.MULENPAY_BASE_URL.rstrip("/")
@property
def is_configured(self) -> bool:
return bool(
settings.is_mulenpay_enabled()
and self.api_key
and self.shop_id
and self.secret_key
)
async def _request(
self,
method: str,
endpoint: str,
*,
json_data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("MulenPay service is not configured")
return None
url = f"{self.base_url}{endpoint}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_data,
params=params,
) as response:
data = await response.json(content_type=None)
if response.status >= 400:
logger.error(
"MulenPay API error %s %s: %s", response.status, endpoint, data
)
return None
return data
except aiohttp.ClientError as error:
logger.error("MulenPay API request error: %s", error)
return None
except Exception as error: # pragma: no cover - safety
logger.error("Unexpected MulenPay error: %s", error, exc_info=True)
return None
@staticmethod
def _format_amount(amount_kopeks: int) -> str:
return f"{amount_kopeks / 100:.2f}"
def _build_signature(self, currency: str, amount_str: str) -> str:
raw_string = f"{currency}{amount_str}{self.shop_id}{self.secret_key}".encode()
return hashlib.sha1(raw_string).hexdigest()
async def create_payment(
self,
*,
amount_kopeks: int,
description: str,
uuid: str,
items: list,
language: str = "ru",
subscribe: Optional[str] = None,
hold_time: Optional[int] = None,
website_url: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("MulenPay service is not configured")
return None
amount_str = self._format_amount(amount_kopeks)
currency = "rub"
payload = {
"currency": currency,
"amount": amount_str,
"uuid": uuid,
"shopId": self.shop_id,
"description": description,
"items": items,
"language": language,
"sign": self._build_signature(currency, amount_str),
}
if subscribe:
payload["subscribe"] = subscribe
if hold_time is not None:
payload["holdTime"] = hold_time
if website_url:
payload["website_url"] = website_url
response = await self._request("POST", "/v2/payments", json_data=payload)
if not response or not response.get("success"):
logger.error("Failed to create MulenPay payment: %s", response)
return None
return response
async def get_payment(self, payment_id: int) -> Optional[Dict[str, Any]]:
return await self._request("GET", f"/v2/payments/{payment_id}")
@@ -0,0 +1,257 @@
import json
import logging
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict
from app.config import settings
logger = logging.getLogger(__name__)
class NotificationSettingsService:
"""Runtime-editable notification settings stored on disk."""
_storage_path: Path = Path("data/notification_settings.json")
_data: Dict[str, Dict[str, Any]] = {}
_loaded: bool = False
_DEFAULTS: Dict[str, Dict[str, Any]] = {
"trial_inactive_1h": {"enabled": True},
"trial_inactive_24h": {"enabled": True},
"trial_channel_unsubscribed": {"enabled": True},
"expired_1d": {"enabled": True},
"expired_second_wave": {
"enabled": True,
"discount_percent": 10,
"valid_hours": 24,
},
"expired_third_wave": {
"enabled": True,
"discount_percent": 20,
"valid_hours": 24,
"trigger_days": 5,
},
}
@classmethod
def _ensure_dir(cls) -> None:
try:
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as exc: # pragma: no cover - filesystem guard
logger.error("Failed to create notification settings dir: %s", exc)
@classmethod
def _load(cls) -> None:
if cls._loaded:
return
cls._ensure_dir()
try:
if cls._storage_path.exists():
raw = cls._storage_path.read_text(encoding="utf-8")
cls._data = json.loads(raw) if raw.strip() else {}
else:
cls._data = {}
except Exception as exc:
logger.error("Failed to load notification settings: %s", exc)
cls._data = {}
changed = cls._apply_defaults()
if changed:
cls._save()
cls._loaded = True
@classmethod
def _apply_defaults(cls) -> bool:
changed = False
for key, defaults in cls._DEFAULTS.items():
current = cls._data.get(key)
if not isinstance(current, dict):
cls._data[key] = deepcopy(defaults)
changed = True
continue
for def_key, def_value in defaults.items():
if def_key not in current:
current[def_key] = def_value
changed = True
return changed
@classmethod
def _save(cls) -> bool:
cls._ensure_dir()
try:
cls._storage_path.write_text(
json.dumps(cls._data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return True
except Exception as exc:
logger.error("Failed to save notification settings: %s", exc)
return False
@classmethod
def _get(cls, key: str) -> Dict[str, Any]:
cls._load()
value = cls._data.get(key)
if not isinstance(value, dict):
value = deepcopy(cls._DEFAULTS.get(key, {}))
cls._data[key] = value
return value
@classmethod
def get_config(cls) -> Dict[str, Dict[str, Any]]:
cls._load()
return deepcopy(cls._data)
@classmethod
def _set_field(cls, key: str, field: str, value: Any) -> bool:
cls._load()
section = cls._get(key)
section[field] = value
cls._data[key] = section
return cls._save()
@classmethod
def set_enabled(cls, key: str, enabled: bool) -> bool:
return cls._set_field(key, "enabled", bool(enabled))
@classmethod
def is_enabled(cls, key: str) -> bool:
return bool(cls._get(key).get("enabled", True))
# Trial inactivity helpers
@classmethod
def is_trial_inactive_1h_enabled(cls) -> bool:
return cls.is_enabled("trial_inactive_1h")
@classmethod
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_inactive_1h", enabled)
@classmethod
def is_trial_inactive_24h_enabled(cls) -> bool:
return cls.is_enabled("trial_inactive_24h")
@classmethod
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_inactive_24h", enabled)
@classmethod
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
return cls.is_enabled("trial_channel_unsubscribed")
@classmethod
def set_trial_channel_unsubscribed_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("trial_channel_unsubscribed", enabled)
# Expired subscription notifications
@classmethod
def is_expired_1d_enabled(cls) -> bool:
return cls.is_enabled("expired_1d")
@classmethod
def set_expired_1d_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("expired_1d", enabled)
@classmethod
def is_second_wave_enabled(cls) -> bool:
return cls.is_enabled("expired_second_wave")
@classmethod
def set_second_wave_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("expired_second_wave", enabled)
@classmethod
def get_second_wave_discount_percent(cls) -> int:
value = cls._get("expired_second_wave").get("discount_percent", 10)
try:
return max(0, min(100, int(value)))
except (TypeError, ValueError):
return 10
@classmethod
def set_second_wave_discount_percent(cls, percent: int) -> bool:
try:
percent_int = max(0, min(100, int(percent)))
except (TypeError, ValueError):
return False
return cls._set_field("expired_second_wave", "discount_percent", percent_int)
@classmethod
def get_second_wave_valid_hours(cls) -> int:
value = cls._get("expired_second_wave").get("valid_hours", 24)
try:
return max(1, min(168, int(value)))
except (TypeError, ValueError):
return 24
@classmethod
def set_second_wave_valid_hours(cls, hours: int) -> bool:
try:
hours_int = max(1, min(168, int(hours)))
except (TypeError, ValueError):
return False
return cls._set_field("expired_second_wave", "valid_hours", hours_int)
@classmethod
def is_third_wave_enabled(cls) -> bool:
return cls.is_enabled("expired_third_wave")
@classmethod
def set_third_wave_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled("expired_third_wave", enabled)
@classmethod
def get_third_wave_discount_percent(cls) -> int:
value = cls._get("expired_third_wave").get("discount_percent", 20)
try:
return max(0, min(100, int(value)))
except (TypeError, ValueError):
return 20
@classmethod
def set_third_wave_discount_percent(cls, percent: int) -> bool:
try:
percent_int = max(0, min(100, int(percent)))
except (TypeError, ValueError):
return False
return cls._set_field("expired_third_wave", "discount_percent", percent_int)
@classmethod
def get_third_wave_valid_hours(cls) -> int:
value = cls._get("expired_third_wave").get("valid_hours", 24)
try:
return max(1, min(168, int(value)))
except (TypeError, ValueError):
return 24
@classmethod
def set_third_wave_valid_hours(cls, hours: int) -> bool:
try:
hours_int = max(1, min(168, int(hours)))
except (TypeError, ValueError):
return False
return cls._set_field("expired_third_wave", "valid_hours", hours_int)
@classmethod
def get_third_wave_trigger_days(cls) -> int:
value = cls._get("expired_third_wave").get("trigger_days", 5)
try:
return max(2, min(60, int(value)))
except (TypeError, ValueError):
return 5
@classmethod
def set_third_wave_trigger_days(cls, days: int) -> bool:
try:
days_int = max(2, min(60, int(days)))
except (TypeError, ValueError):
return False
return cls._set_field("expired_third_wave", "trigger_days", days_int)
@classmethod
def are_notifications_globally_enabled(cls) -> bool:
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
+119
View File
@@ -0,0 +1,119 @@
"""High level integration with PayPalych API."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, Optional
from app.config import settings
from app.external.pal24_client import Pal24Client, Pal24APIError
logger = logging.getLogger(__name__)
class Pal24Service:
"""Wrapper around :class:`Pal24Client` providing domain helpers."""
BILL_SUCCESS_STATES = {"SUCCESS", "OVERPAID"}
BILL_FAILED_STATES = {"FAIL", "CANCELLED"}
BILL_PENDING_STATES = {"NEW", "PROCESS", "UNDERPAID"}
def __init__(self, client: Optional[Pal24Client] = None) -> None:
self.client = client or Pal24Client()
@property
def is_configured(self) -> bool:
return self.client.is_configured and settings.is_pal24_enabled()
async def create_bill(
self,
*,
amount_kopeks: int,
user_id: int,
order_id: str,
description: str,
ttl_seconds: Optional[int] = None,
custom_payload: Optional[Dict[str, Any]] = None,
payer_email: Optional[str] = None,
payment_method: Optional[str] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise Pal24APIError("Pal24 service is not configured")
amount_decimal = Pal24Client.normalize_amount(amount_kopeks)
extra_payload: Dict[str, Any] = {
"custom": custom_payload or {},
"ttl": ttl_seconds,
}
if payer_email:
extra_payload["payer_email"] = payer_email
if payment_method:
extra_payload["payment_method"] = payment_method
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
logger.info(
"Создаем Pal24 счет: user_id=%s, order_id=%s, amount=%s, ttl=%s",
user_id,
order_id,
amount_decimal,
ttl_seconds,
)
response = await self.client.create_bill(
amount=amount_decimal,
shop_id=settings.PAL24_SHOP_ID,
order_id=order_id,
description=description,
type_="normal",
**filtered_payload,
)
logger.info("Pal24 счет создан: %s", response)
return response
async def get_bill_status(self, bill_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем статус Pal24 счета %s", bill_id)
return await self.client.get_bill_status(bill_id)
async def get_payment_status(self, payment_id: str) -> Dict[str, Any]:
logger.debug("Запрашиваем статус Pal24 платежа %s", payment_id)
return await self.client.get_payment_status(payment_id)
@staticmethod
def parse_postback(payload: Dict[str, Any]) -> Dict[str, Any]:
required_fields = ["InvId", "OutSum", "Status", "SignatureValue"]
missing = [field for field in required_fields if field not in payload]
if missing:
raise Pal24APIError(f"Pal24 postback missing fields: {', '.join(missing)}")
inv_id = str(payload["InvId"])
out_sum = str(payload["OutSum"])
signature = str(payload["SignatureValue"])
if not Pal24Client.verify_signature(out_sum, inv_id, signature):
raise Pal24APIError("Pal24 postback signature mismatch")
logger.info(
"Получен Pal24 postback: InvId=%s, Status=%s, TrsId=%s",
inv_id,
payload.get("Status"),
payload.get("TrsId"),
)
return payload
@staticmethod
def convert_to_kopeks(amount: str) -> int:
decimal_amount = Decimal(str(amount))
return int((decimal_amount * Decimal("100")).quantize(Decimal("1")))
@staticmethod
def get_expiration(ttl_seconds: Optional[int]) -> Optional[datetime]:
if not ttl_seconds:
return None
return datetime.utcnow() + timedelta(seconds=ttl_seconds)
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.models import PromoGroup, User
from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
async def _notify_admins_about_auto_assignment(
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
total_spent_kopeks: int,
):
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
bot_token = getattr(settings, "BOT_TOKEN", None)
if not bot_token:
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
return
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
try:
notification_service = AdminNotificationService(bot)
reason = (
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
if hasattr(settings, "format_price")
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}"
)
await notification_service.send_user_promo_group_change_notification(
db,
user,
old_group,
new_group,
reason=reason,
initiator=None,
automatic=True,
)
except Exception as exc:
logger.error(
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
user.telegram_id,
exc,
)
finally:
try:
await bot.session.close()
except Exception:
pass
async def _get_best_group_for_spending(
db: AsyncSession,
total_spent_kopeks: int,
min_threshold_kopeks: int = 0,
) -> Optional[PromoGroup]:
if total_spent_kopeks <= 0:
return None
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.desc(), PromoGroup.id.desc())
)
groups = result.scalars().all()
for group in groups:
threshold = group.auto_assign_total_spent_kopeks or 0
if (
threshold
and total_spent_kopeks >= threshold
and threshold > min_threshold_kopeks
):
return group
return None
async def maybe_assign_promo_group_by_total_spent(
db: AsyncSession,
user_id: int,
) -> Optional[PromoGroup]:
user = await db.get(User, user_id)
if not user:
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
return None
old_group = None
if user.promo_group_id:
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
pass
old_group = getattr(user, "promo_group", None)
total_spent = await get_user_total_spent_kopeks(db, user_id)
if total_spent <= 0:
return None
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
target_group = await _get_best_group_for_spending(
db,
total_spent,
min_threshold_kopeks=previous_threshold,
)
if not target_group:
return None
try:
previous_group_id = user.promo_group_id
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
if target_threshold <= previous_threshold:
logger.debug(
"Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s",
target_group.name,
target_threshold,
previous_threshold,
user.telegram_id,
)
return None
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
logger.debug(
"Пользователь %s уже находится в актуальной промогруппе '%s', повторная выдача не требуется",
user.telegram_id,
target_group.name,
)
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return target_group
user.auto_promo_group_assigned = True
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
if target_group.id != previous_group_id:
user.promo_group_id = target_group.id
user.promo_group = target_group
logger.info(
"🤖 Пользователь %s автоматически переведен в промогруппу '%s' за траты %s",
user.telegram_id,
target_group.name,
total_spent / 100,
)
else:
logger.info(
"🤖 Пользователь %s уже находится в подходящей промогруппе '%s', отмечаем автоприсвоение",
user.telegram_id,
target_group.name,
)
await db.commit()
await db.refresh(user)
if target_group.id != previous_group_id:
await _notify_admins_about_auto_assignment(
db,
user,
old_group,
target_group,
total_spent,
)
return target_group
except Exception as exc:
logger.error(
"Ошибка при автоматическом назначении промогруппы пользователю %s: %s",
user_id,
exc,
)
await db.rollback()
return None
+18 -7
View File
@@ -49,23 +49,34 @@ class PromoCodeService:
return {"success": False, "error": "already_used_by_user"}
result_description = await self._apply_promocode_effects(db, user, promocode)
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
await mark_user_as_had_paid_subscription(db, user)
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
await create_promocode_use(db, promocode.id, user_id)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
promocode_data = {
"code": promocode.code,
"type": promocode.type,
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
"subscription_days": promocode.subscription_days,
"max_uses": promocode.max_uses,
"current_uses": promocode.current_uses,
"valid_until": promocode.valid_until,
}
return {
"success": True,
"description": result_description
"description": result_description,
"promocode": promocode_data,
}
except Exception as e:
+122 -60
View File
@@ -1,4 +1,5 @@
import logging
from contextlib import asynccontextmanager
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
@@ -20,17 +21,56 @@ from app.database.models import (
logger = logging.getLogger(__name__)
class RemnaWaveConfigurationError(Exception):
"""Raised when RemnaWave API configuration is missing."""
class RemnaWaveService:
def __init__(self):
auth_params = settings.get_remnawave_auth_params()
self.api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
)
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
self._config_error: Optional[str] = None
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
self.api: Optional[RemnaWaveAPI]
if self._config_error:
self.api = None
else:
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=auth_params.get("secret_key"),
username=auth_params.get("username"),
password=auth_params.get("password")
)
@property
def is_configured(self) -> bool:
return self._config_error is None
@property
def configuration_error(self) -> Optional[str]:
return self._config_error
def _ensure_configured(self) -> None:
if not self.is_configured or self.api is None:
raise RemnaWaveConfigurationError(
self._config_error or "RemnaWave API не настроен"
)
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
yield api
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
@@ -62,7 +102,7 @@ class RemnaWaveService:
async def get_system_statistics(self) -> Dict[str, Any]:
try:
async with self.api as api:
async with self.get_api_client() as api:
logger.info("Получение системной статистики RemnaWave...")
try:
@@ -255,7 +295,7 @@ class RemnaWaveService:
async def get_all_nodes(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
nodes = await api.get_all_nodes()
result = []
@@ -284,7 +324,7 @@ class RemnaWaveService:
async def test_connection(self) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
stats = await api.get_system_stats()
logger.info("✅ Соединение с Remnawave API работает")
return True
@@ -295,7 +335,7 @@ class RemnaWaveService:
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
node = await api.get_node_by_uuid(node_uuid)
if not node:
@@ -321,7 +361,7 @@ class RemnaWaveService:
async def manage_node(self, node_uuid: str, action: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
if action == "enable":
await api.enable_node(node_uuid)
elif action == "disable":
@@ -340,7 +380,7 @@ class RemnaWaveService:
async def restart_all_nodes(self) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
result = await api.restart_all_nodes()
if result:
@@ -354,7 +394,7 @@ class RemnaWaveService:
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
data = {
'uuid': squad_uuid,
'inbounds': inbound_uuids
@@ -368,7 +408,7 @@ class RemnaWaveService:
async def get_all_squads(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
result = []
@@ -390,7 +430,7 @@ class RemnaWaveService:
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squad = await api.create_internal_squad(name, inbounds)
logger.info(f"✅ Создан новый сквад: {name}")
@@ -402,7 +442,7 @@ class RemnaWaveService:
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
await api.update_internal_squad(uuid, name, inbounds)
logger.info(f"✅ Обновлен сквад {uuid}")
@@ -414,7 +454,7 @@ class RemnaWaveService:
async def delete_squad(self, uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
result = await api.delete_internal_squad(uuid)
if result:
@@ -432,7 +472,7 @@ class RemnaWaveService:
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
async with self.api as api:
async with self.get_api_client() as api:
panel_users = []
start = 0
size = 100
@@ -458,6 +498,7 @@ class RemnaWaveService:
'usedTrafficBytes': user_obj.used_traffic_bytes,
'hwidDeviceLimit': user_obj.hwid_device_limit,
'subscriptionUrl': user_obj.subscription_url,
'subscriptionCryptoLink': user_obj.happ_crypto_link,
'activeInternalSquads': user_obj.active_internal_squads
}
panel_users.append(user_dict)
@@ -549,7 +590,7 @@ class RemnaWaveService:
if db_user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
@@ -581,6 +622,7 @@ class RemnaWaveService:
subscription.autopay_enabled = False
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.subscription_crypto_link = ""
db_user.remnawave_uuid = None
@@ -637,14 +679,18 @@ class RemnaWaveService:
subscription_data = {
'user_id': user.id,
'status': status.value,
'is_trial': False,
'is_trial': False,
'end_date': expire_at,
'traffic_limit_gb': traffic_limit_gb,
'traffic_used_gb': traffic_used_gb,
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
'connected_squads': squad_uuids,
'remnawave_short_uuid': panel_user.get('shortUuid'),
'subscription_url': panel_user.get('subscriptionUrl', '')
'subscription_url': panel_user.get('subscriptionUrl', ''),
'subscription_crypto_link': (
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
}
subscription = await create_subscription(db, **subscription_data)
@@ -667,7 +713,11 @@ class RemnaWaveService:
device_limit=1,
connected_squads=[],
remnawave_short_uuid=panel_user.get('shortUuid'),
subscription_url=panel_user.get('subscriptionUrl', '')
subscription_url=panel_user.get('subscriptionUrl', ''),
subscription_crypto_link=(
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
)
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
except Exception as basic_error:
@@ -733,6 +783,13 @@ class RemnaWaveService:
panel_url = panel_user.get('subscriptionUrl', '')
if not subscription.subscription_url or subscription.subscription_url != panel_url:
subscription.subscription_url = panel_url
panel_crypto_link = (
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
subscription.subscription_crypto_link = panel_crypto_link
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
@@ -763,7 +820,7 @@ class RemnaWaveService:
users = await get_users_list(db, offset=0, limit=10000)
async with self.api as api:
async with self.get_api_client() as api:
for user in users:
if not user.subscription:
continue
@@ -779,6 +836,11 @@ class RemnaWaveService:
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
stats["updated"] += 1
@@ -793,7 +855,11 @@ class RemnaWaveService:
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=f"Bot user: {user.full_name}",
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
@@ -816,7 +882,7 @@ class RemnaWaveService:
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
users = await api.get_user_by_telegram_id(telegram_id)
if not users:
@@ -839,17 +905,23 @@ class RemnaWaveService:
return None
async def test_api_connection(self) -> Dict[str, Any]:
if not self.is_configured:
return {
"status": "not_configured",
"message": self.configuration_error or "RemnaWave API не настроен",
"api_url": settings.REMNAWAVE_API_URL,
}
try:
async with self.api as api:
async with self.get_api_client() as api:
system_stats = await api.get_system_stats()
return {
"status": "connected",
"message": "Подключение успешно",
"api_url": settings.REMNAWAVE_API_URL,
"system_info": system_stats
}
except RemnaWaveAPIError as e:
return {
"status": "error",
@@ -857,6 +929,12 @@ class RemnaWaveService:
"status_code": e.status_code,
"api_url": settings.REMNAWAVE_API_URL
}
except RemnaWaveConfigurationError as e:
return {
"status": "not_configured",
"message": str(e),
"api_url": settings.REMNAWAVE_API_URL,
}
except Exception as e:
return {
"status": "error",
@@ -866,7 +944,7 @@ class RemnaWaveService:
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
usage_data = await api.get_nodes_realtime_usage()
return usage_data
@@ -876,7 +954,7 @@ class RemnaWaveService:
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squad = await api.get_internal_squad_by_uuid(squad_uuid)
if squad:
return {
@@ -893,7 +971,7 @@ class RemnaWaveService:
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
return response.get('response', {}).get('eventSent', False)
except Exception as e:
@@ -902,25 +980,16 @@ class RemnaWaveService:
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
return response.get('response', {}).get('eventSent', False)
except Exception as e:
logger.error(f"Error removing users from squad: {e}")
return False
async def delete_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
response = await api.delete_internal_squad(squad_uuid)
return response
except Exception as e:
logger.error(f"Error deleting squad: {e}")
return False
async def get_all_inbounds(self) -> List[Dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('GET', '/api/config-profiles/inbounds')
inbounds_data = response.get('response', {}).get('inbounds', [])
@@ -941,7 +1010,7 @@ class RemnaWaveService:
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
data = {
'uuid': squad_uuid,
'name': new_name
@@ -952,18 +1021,9 @@ class RemnaWaveService:
logger.error(f"Error renaming squad: {e}")
return False
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
try:
async with self.api as api:
squad = await api.create_internal_squad(name, inbound_uuids)
return squad is not None
except Exception as e:
logger.error(f"Error creating squad: {e}")
return False
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
start_str = start_date.isoformat() + "Z"
end_str = end_date.isoformat() + "Z"
@@ -1041,7 +1101,7 @@ class RemnaWaveService:
if user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
@@ -1104,6 +1164,7 @@ class RemnaWaveService:
user.subscription.autopay_days_before = 3
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.subscription_crypto_link = ""
user.subscription.updated_at = datetime.utcnow()
await db.commit()
@@ -1127,7 +1188,7 @@ class RemnaWaveService:
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
async with self.api as api:
async with self.get_api_client() as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
@@ -1191,7 +1252,7 @@ class RemnaWaveService:
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
async with self.api as api:
async with self.get_api_client() as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
@@ -1281,11 +1342,12 @@ class RemnaWaveService:
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
if rw_user:
subscription.remnawave_short_uuid = rw_user.short_uuid
subscription.subscription_url = rw_user.subscription_url
subscription.subscription_crypto_link = rw_user.happ_crypto_link
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
issues_fixed += 1
except Exception as rw_error:
@@ -1529,7 +1591,7 @@ class RemnaWaveService:
try:
start_time = datetime.utcnow()
async with self.api as api:
async with self.get_api_client() as api:
try:
system_stats = await api.get_system_stats()
api_available = True
+378
View File
@@ -0,0 +1,378 @@
import asyncio
import logging
from dataclasses import dataclass
from datetime import date, datetime, time as datetime_time, timedelta, timezone
from enum import Enum
from typing import Optional, Tuple
from zoneinfo import ZoneInfo
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import func, select
from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
Ticket,
TicketStatus,
Transaction,
TransactionType,
)
logger = logging.getLogger(__name__)
class ReportingServiceError(RuntimeError):
"""Base error for the reporting service."""
class ReportPeriod(Enum):
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
@dataclass(slots=True)
class ReportPeriodRange:
start_msk: datetime
end_msk: datetime
label: str
class ReportingService:
"""Generates admin summary reports and can schedule daily delivery."""
def __init__(self) -> None:
self.bot: Optional[Bot] = None
self._task: Optional[asyncio.Task] = None
self._moscow_tz = ZoneInfo("Europe/Moscow")
def set_bot(self, bot: Bot) -> None:
self.bot = bot
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
async def start(self) -> None:
await self.stop()
if not settings.ADMIN_REPORTS_ENABLED:
logger.info("Сервис отчетов отключен настройками")
return
if not self.bot:
logger.warning("Невозможно запустить сервис отчетов без экземпляра бота")
return
chat_id = settings.get_reports_chat_id()
if not chat_id:
logger.warning("Сервис отчетов не запущен: не указан чат для отправки отчетов")
return
send_time = settings.get_reports_send_time()
if not send_time:
logger.warning("Сервис отчетов не запущен: не указано время ежедневной отправки")
return
self._task = asyncio.create_task(self._auto_daily_loop(send_time))
logger.info(
"📊 Сервис отчетов запущен: ежедневная отправка в %s по МСК",
send_time.strftime("%H:%M"),
)
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def send_report(
self,
period: ReportPeriod,
*,
report_date: Optional[date] = None,
send_to_topic: bool = False,
) -> str:
report_text = await self._build_report(period, report_date)
if send_to_topic:
await self._deliver_report(report_text)
return report_text
async def _auto_daily_loop(self, send_time: datetime_time) -> None:
try:
next_run_utc, report_date = self._calculate_next_run(send_time)
while True:
now_utc = datetime.now(timezone.utc)
delay = (next_run_utc - now_utc).total_seconds()
if delay > 0:
await asyncio.sleep(delay)
try:
await self.send_report(
ReportPeriod.DAILY,
report_date=report_date,
send_to_topic=True,
)
logger.info(
"📊 Автоматический отчет за %s отправлен",
report_date.strftime("%d.%m.%Y"),
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
logger.error("Ошибка автоматической отправки отчета: %s", exc)
next_run_utc, report_date = self._calculate_next_run(send_time)
except asyncio.CancelledError:
logger.info("Сервис отчетов остановлен")
raise
except Exception as exc: # noqa: BLE001
logger.error("Критическая ошибка в сервисе отчетов: %s", exc)
def _calculate_next_run(
self,
send_time: datetime_time,
) -> Tuple[datetime, date]:
now_msk = datetime.now(self._moscow_tz)
candidate = datetime.combine(now_msk.date(), send_time, tzinfo=self._moscow_tz)
if now_msk >= candidate:
candidate += timedelta(days=1)
report_date = (candidate - timedelta(days=1)).date()
return candidate.astimezone(timezone.utc), report_date
async def _deliver_report(self, report_text: str) -> None:
if not self.bot:
raise ReportingServiceError("Бот не инициализирован для отправки отчета")
chat_id = settings.get_reports_chat_id()
if not chat_id:
raise ReportingServiceError("Не задан чат для отправки отчета")
topic_id = settings.get_reports_topic_id()
try:
await self.bot.send_message(
chat_id=chat_id,
text=report_text,
message_thread_id=topic_id,
)
except (TelegramBadRequest, TelegramForbiddenError) as exc:
logger.error("Не удалось отправить отчет: %s", exc)
raise ReportingServiceError("Не удалось отправить отчет в чат") from exc
async def _build_report(
self,
period: ReportPeriod,
report_date: Optional[date],
) -> str:
period_range = self._get_period_range(period, report_date)
start_utc = period_range.start_msk.astimezone(timezone.utc).replace(tzinfo=None)
end_utc = period_range.end_msk.astimezone(timezone.utc).replace(tzinfo=None)
async with AsyncSessionLocal() as session:
totals = await self._collect_current_totals(session)
period_stats = await self._collect_period_stats(session, start_utc, end_utc)
header = (
f"📊 <b>Отчет за {period_range.label}</b>"
if period == ReportPeriod.DAILY
else f"📊 <b>Отчет за период {period_range.label}</b>"
)
lines = [
header,
"",
"🎯 <b>Триалы</b>",
f"• Активных сейчас: {totals['active_trials']}",
f"• Новых за период: {period_stats['new_trials']}",
"",
"💎 <b>Платные подписки</b>",
f"• Активных сейчас: {totals['active_paid']}",
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
"",
"🎟️ <b>Тикеты поддержки</b>",
f"• Активных сейчас: {totals['open_tickets']}",
f"• Новых за период: {period_stats['new_tickets']}",
"",
"💰 <b>Платежи</b>",
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
f"• Пополнений: {period_stats['deposits_count']} на сумму "
f"{self._format_amount(period_stats['deposits_amount'])}",
f"• Всего поступлений: {period_stats['total_payments_count']} на сумму "
f"{self._format_amount(period_stats['total_payments_amount'])}",
]
return "\n".join(lines)
def _get_period_range(
self,
period: ReportPeriod,
report_date: Optional[date],
) -> ReportPeriodRange:
now_msk = datetime.now(self._moscow_tz)
if period == ReportPeriod.DAILY:
target_date = report_date or (now_msk.date() - timedelta(days=1))
start = datetime.combine(target_date, datetime_time.min, tzinfo=self._moscow_tz)
end = start + timedelta(days=1)
elif period == ReportPeriod.WEEKLY:
end_date = report_date or now_msk.date()
start_date = end_date - timedelta(days=7)
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
elif period == ReportPeriod.MONTHLY:
end_date = report_date or now_msk.date()
start_date = end_date - timedelta(days=30)
start = datetime.combine(start_date, datetime_time.min, tzinfo=self._moscow_tz)
end = datetime.combine(end_date, datetime_time.min, tzinfo=self._moscow_tz)
else: # pragma: no cover - defensive branch
raise ReportingServiceError(f"Неизвестный период отчета: {period}")
label = self._format_period_label(start, end)
return ReportPeriodRange(start, end, label)
async def _collect_current_totals(self, session) -> dict:
stats = await get_subscriptions_statistics(session)
open_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.status.in_(
[
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value,
TicketStatus.PENDING.value,
]
)
)
)
open_tickets = int(open_tickets_result.scalar() or 0)
return {
"active_trials": stats.get("trial_subscriptions", 0) or 0,
"active_paid": stats.get("paid_subscriptions", 0) or 0,
"open_tickets": open_tickets,
}
async def _collect_period_stats(
self,
session,
start_utc: datetime,
end_utc: datetime,
) -> dict:
new_trials_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == True, # noqa: E712
)
)
new_trials = int(new_trials_result.scalar() or 0)
direct_paid_result = await session.execute(
select(func.count(Subscription.id)).where(
Subscription.created_at >= start_utc,
Subscription.created_at < end_utc,
Subscription.is_trial == False, # noqa: E712
)
)
direct_paid = int(direct_paid_result.scalar() or 0)
conversions_result = await session.execute(
select(func.count(SubscriptionConversion.id)).where(
SubscriptionConversion.converted_at >= start_utc,
SubscriptionConversion.converted_at < end_utc,
)
)
conversions_count = int(conversions_result.scalar() or 0)
subscription_payments_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
)
).one()
deposits_row = (
await session.execute(
select(
func.count(Transaction.id),
func.coalesce(func.sum(Transaction.amount_kopeks), 0),
).where(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True, # noqa: E712
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
)
)
).one()
subscription_payments_count = int(subscription_payments_row[0] or 0)
subscription_payments_amount = int(subscription_payments_row[1] or 0)
deposits_count = int(deposits_row[0] or 0)
deposits_amount = int(deposits_row[1] or 0)
total_payments_count = subscription_payments_count + deposits_count
total_payments_amount = subscription_payments_amount + deposits_amount
new_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
new_tickets = int(new_tickets_result.scalar() or 0)
return {
"new_trials": new_trials,
"new_paid_subscriptions": direct_paid + conversions_count,
"subscription_payments_count": subscription_payments_count,
"subscription_payments_amount": subscription_payments_amount,
"deposits_count": deposits_count,
"deposits_amount": deposits_amount,
"total_payments_count": total_payments_count,
"total_payments_amount": total_payments_amount,
"new_tickets": new_tickets,
}
def _format_period_label(self, start: datetime, end: datetime) -> str:
start_date = start.astimezone(self._moscow_tz).date()
end_boundary = (end - timedelta(seconds=1)).astimezone(self._moscow_tz)
end_date = end_boundary.date()
if start_date == end_date:
return start_date.strftime("%d.%m.%Y")
return (
f"{start_date.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
)
def _format_amount(self, amount_kopeks: int) -> str:
if not amount_kopeks:
return "0 ₽"
rubles = amount_kopeks / 100
return f"{rubles:,.2f}".replace(",", " ")
reporting_service = ReportingService()
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import asyncio
import logging
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
@dataclass
class ServerStatusEntry:
address: str
instance: Optional[str]
protocol: Optional[str]
name: str
flag: str
display_name: str
latency_ms: Optional[int]
is_online: bool
class ServerStatusError(Exception):
"""Raised when server status information cannot be fetched or parsed."""
class ServerStatusService:
_LATENCY_PATTERN = re.compile(
r"xray_proxy_latency_ms\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
)
_STATUS_PATTERN = re.compile(
r"xray_proxy_status\{(?P<labels>[^}]*)\}\s+(?P<value>[-+]?\d+(?:\.\d+)?)"
)
_LABEL_PATTERN = re.compile(r"(?P<key>[a-zA-Z_][a-zA-Z0-9_]*)=\"(?P<value>(?:\\.|[^\"])*)\"")
_FLAG_PATTERN = re.compile(r"^([\U0001F1E6-\U0001F1FF]{2})\s*(.*)$")
def __init__(self) -> None:
self._logger = logging.getLogger(self.__class__.__name__)
async def get_servers(self) -> List[ServerStatusEntry]:
mode = settings.get_server_status_mode()
if mode != "xray":
raise ServerStatusError("Server status integration is not enabled")
url = settings.get_server_status_metrics_url()
if not url:
raise ServerStatusError("Metrics URL is not configured")
timeout = aiohttp.ClientTimeout(total=settings.get_server_status_request_timeout())
auth = None
auth_credentials = settings.get_server_status_metrics_auth()
if auth_credentials:
username, password = auth_credentials
auth = aiohttp.BasicAuth(username, password)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(
url,
auth=auth,
ssl=settings.SERVER_STATUS_METRICS_VERIFY_SSL,
) as response:
if response.status != 200:
text = await response.text()
raise ServerStatusError(
f"Unexpected response status: {response.status}"
f" - {text[:200]}"
)
metrics_body = await response.text()
except asyncio.TimeoutError as error:
raise ServerStatusError("Request to metrics endpoint timed out") from error
except aiohttp.ClientError as error:
raise ServerStatusError("Failed to fetch metrics") from error
return self._parse_metrics(metrics_body)
def _parse_metrics(self, body: str) -> List[ServerStatusEntry]:
servers: Dict[Tuple[str, str, str, str], ServerStatusEntry] = {}
for match in self._LATENCY_PATTERN.finditer(body):
labels = self._parse_labels(match.group("labels"))
key = self._build_key(labels)
entry = servers.get(key)
if not entry:
entry = self._create_entry(labels)
servers[key] = entry
try:
value = float(match.group("value"))
entry.latency_ms = int(round(value))
except (TypeError, ValueError):
entry.latency_ms = None
for match in self._STATUS_PATTERN.finditer(body):
labels = self._parse_labels(match.group("labels"))
key = self._build_key(labels)
entry = servers.get(key)
if not entry:
entry = self._create_entry(labels)
servers[key] = entry
try:
value = float(match.group("value"))
entry.is_online = value >= 1
except (TypeError, ValueError):
entry.is_online = False
return sorted(
servers.values(),
key=lambda item: (
0 if item.is_online else 1,
(item.display_name or item.name).lower(),
),
)
def _build_key(self, labels: Dict[str, str]) -> Tuple[str, str, str, str]:
return (
labels.get("address", ""),
labels.get("instance", ""),
labels.get("protocol", ""),
labels.get("name", labels.get("address", "")),
)
def _create_entry(self, labels: Dict[str, str]) -> ServerStatusEntry:
name = labels.get("name") or labels.get("address") or "Unknown"
flag, display_name = self._extract_flag(name)
return ServerStatusEntry(
address=labels.get("address", ""),
instance=labels.get("instance"),
protocol=labels.get("protocol"),
name=name,
flag=flag,
display_name=display_name or name,
latency_ms=None,
is_online=False,
)
def _extract_flag(self, name: str) -> Tuple[str, str]:
match = self._FLAG_PATTERN.match(name)
if not match:
return "", name
flag, remainder = match.groups()
return flag, remainder.strip()
def _parse_labels(self, labels_str: str) -> Dict[str, str]:
labels: Dict[str, str] = {}
for match in self._LABEL_PATTERN.finditer(labels_str):
key = match.group("key")
value = match.group("value").replace('\\"', '"')
labels[key] = value
return labels
@@ -0,0 +1,58 @@
from typing import Optional
from app.database.models import User
from app.utils.cache import UserCache
_CHECKOUT_SESSION_KEY = "subscription_checkout"
_CHECKOUT_TTL_SECONDS = 3600
async def save_subscription_checkout_draft(
user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS
) -> bool:
"""Persist subscription checkout draft data in cache."""
return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl)
async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]:
"""Retrieve subscription checkout draft from cache."""
return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY)
async def clear_subscription_checkout_draft(user_id: int) -> bool:
"""Remove stored subscription checkout draft for the user."""
return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY)
async def has_subscription_checkout_draft(user_id: int) -> bool:
draft = await get_subscription_checkout_draft(user_id)
return draft is not None
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
"""
Determine whether checkout resume button should be available for the user.
Only users without an active paid subscription or users currently on trial
are eligible to continue assembling the subscription from the saved draft.
"""
if not has_draft:
return False
subscription = getattr(user, "subscription", None)
if subscription is None:
return True
if getattr(subscription, "is_trial", False):
return True
if getattr(subscription, "actual_status", None) == "expired":
return True
return False
+575 -100
View File
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, SubscriptionStatus
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
from app.external.remnawave_api import (
RemnaWaveAPI, RemnaWaveUser, UserStatus,
TrafficLimitStrategy, RemnaWaveAPIError
@@ -20,6 +20,60 @@ from app.utils.pricing_utils import (
logger = logging.getLogger(__name__)
def _resolve_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
if user is not None:
try:
return user.get_promo_discount(category, period_days)
except AttributeError:
pass
if promo_group is not None:
return promo_group.get_discount_percent(category, period_days)
return 0
def _resolve_addon_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
group = promo_group or (getattr(user, "promo_group", None) if user else None)
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
return 0
return _resolve_discount_percent(
user,
promo_group,
category,
period_days=period_days,
)
def get_traffic_reset_strategy():
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
strategy_mapping = {
'NO_RESET': 'NO_RESET',
'DAY': 'DAY',
'WEEK': 'WEEK',
'MONTH': 'MONTH'
}
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
return getattr(TrafficLimitStrategy, mapped_strategy)
class SubscriptionService:
def __init__(self):
@@ -33,9 +87,12 @@ class SubscriptionService:
)
async def create_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
self,
db: AsyncSession,
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -66,11 +123,24 @@ class SubscriptionService:
status=UserStatus.ACTIVE,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
@@ -79,22 +149,36 @@ class SubscriptionService:
expire_at=subscription.end_date,
status=UserStatus.ACTIVE,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
telegram_id=user.telegram_id,
hwid_device_limit=subscription.device_limit,
description=f"Bot user: {user.full_name}",
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
user.remnawave_uuid = updated_user.uuid
await db.commit()
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
@@ -107,7 +191,10 @@ class SubscriptionService:
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -135,28 +222,64 @@ class SubscriptionService:
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
traffic_limit_strategy=get_traffic_reset_strategy(),
hwid_device_limit=subscription.device_limit,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
user.remnawave_uuid,
user.telegram_id,
reset_reason,
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
status_text = "активным" if is_actually_active else "истёкшим"
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
logger.info(f"📊 Стратегия сброса трафика: MONTH")
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def _reset_user_traffic(
self,
api: RemnaWaveAPI,
user_uuid: str,
telegram_id: int,
reset_reason: Optional[str] = None,
) -> None:
if not user_uuid:
return
try:
await api.reset_user_traffic(user_uuid)
reason_text = f" ({reset_reason})" if reset_reason else ""
logger.info(
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
)
except Exception as exc:
logger.warning(
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
)
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.api as api:
await api.disable_user(user_uuid)
@@ -183,6 +306,7 @@ class SubscriptionService:
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
@@ -235,9 +359,12 @@ class SubscriptionService:
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
server_squad_ids: List[int],
devices: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
@@ -246,64 +373,200 @@ class SubscriptionService:
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
traffic_price = settings.get_traffic_price(traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_prices.append(server.price_kopeks)
total_servers_price += server.price_kopeks
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}")
server_price = server.price_kopeks
server_discount = server_price * servers_discount_percent // 100
discounted_server_price = server_price - server_discount
server_prices.append(discounted_server_price)
total_servers_price += discounted_server_price
log_message = f"Сервер {server.display_name}: {server_price/100}"
if server_discount > 0:
log_message += (
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
)
logger.debug(log_message)
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + total_servers_price + devices_price
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({devices}): {devices_price/100}")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
if discounted_devices_price > 0:
message = f" Устройства ({devices}): {devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
subscription.connected_squads,
db,
promo_group_id=promo_group.id if promo_group else None,
)
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount = servers_price * servers_discount_percent // 100
discounted_servers_price = servers_price - servers_discount
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount = devices_price * devices_discount_percent // 100
discounted_devices_price = devices_price - devices_discount
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
total_price = base_price + servers_price + devices_price + traffic_price
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount = traffic_price * traffic_discount_percent // 100
discounted_traffic_price = traffic_price - traffic_discount
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = (
base_price
+ discounted_servers_price
+ discounted_devices_price
+ discounted_traffic_price
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}")
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}")
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f" {base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}"
if servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
)
logger.info(message)
if devices_price > 0:
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
)
logger.info(message)
if traffic_price > 0:
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
@@ -345,6 +608,7 @@ class SubscriptionService:
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.subscription_crypto_link = ""
subscription.connected_squads = []
user.remnawave_uuid = None
@@ -360,9 +624,11 @@ class SubscriptionService:
return False
async def get_countries_price_by_uuids(
self,
country_uuids: List[str],
db: AsyncSession
self,
country_uuids: List[str],
db: AsyncSession,
*,
promo_group_id: Optional[int] = None,
) -> Tuple[int, List[int]]:
try:
from app.database.crud.server_squad import get_server_squad_by_uuid
@@ -372,7 +638,12 @@ class SubscriptionService:
for country_uuid in country_uuids:
server = await get_server_squad_by_uuid(db, country_uuid)
if server and server.is_available and not server.is_full:
is_allowed = True
if promo_group_id is not None and server:
allowed_ids = {pg.id for pg in server.allowed_promo_groups}
is_allowed = promo_group_id in allowed_ids
if server and server.is_available and not server.is_full and is_allowed:
price = server.price_kopeks
total_price += price
prices_list.append(price)
@@ -403,9 +674,12 @@ class SubscriptionService:
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
server_squad_ids: List[int],
devices: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
@@ -416,75 +690,216 @@ class SubscriptionService:
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
promo_group = promo_group or (user.promo_group if user else None)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
server_prices = []
total_servers_price = 0
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_price_per_month = server.price_kopeks
server_price_total = server_price_per_month * months_in_period
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
discounted_server_per_month = server_price_per_month - server_discount_per_month
server_price_total = discounted_server_per_month * months_in_period
server_prices.append(server_price_total)
total_servers_price += server_price_total
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}")
log_message = (
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}"
)
if server_discount_per_month > 0:
log_message += (
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
)
logger.debug(log_message)
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f" {base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price_with_months(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
db: AsyncSession,
*,
user: Optional[User] = None,
promo_group: Optional[PromoGroup] = None,
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
base_price_original = PERIOD_PRICES.get(period_days, 0)
if user is None:
user = getattr(subscription, "user", None)
promo_group = promo_group or (user.promo_group if user else None)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
subscription.connected_squads,
db,
promo_group_id=promo_group.id if promo_group else None,
)
total_servers_price = servers_price_per_month * months_in_period
servers_discount_percent = _resolve_discount_percent(
user,
promo_group,
"servers",
period_days=period_days,
)
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
total_servers_price = discounted_servers_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
devices_discount_percent = _resolve_discount_percent(
user,
promo_group,
"devices",
period_days=period_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
total_devices_price = discounted_devices_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_price = traffic_price_per_month * months_in_period
traffic_discount_percent = _resolve_discount_percent(
user,
promo_group,
"traffic",
period_days=period_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
total_traffic_price = discounted_traffic_per_month * months_in_period
period_discount_percent = _resolve_discount_percent(
user,
promo_group,
"period",
period_days=period_days,
)
base_discount_total = base_price_original * period_discount_percent // 100
base_price = base_price_original - base_discount_total
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f" {base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"
)
if servers_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_devices_price > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
if total_traffic_price > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
@@ -503,32 +918,92 @@ class SubscriptionService:
if additional_server_ids is None:
additional_server_ids = []
current_time = datetime.utcnow()
months_to_pay = get_remaining_months(subscription.end_date)
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
user = getattr(subscription, "user", None)
promo_group = user.promo_group if user else None
total_price = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
total_price += traffic_price_per_month * months_to_pay
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}")
traffic_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"traffic",
period_days=period_hint_days,
)
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
traffic_total_price = discounted_traffic_per_month * months_to_pay
total_price += traffic_total_price
message = (
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}"
f" = {traffic_total_price/100}"
)
if traffic_discount_per_month > 0:
message += (
f" (скидка {traffic_discount_percent}%:"
f" -{traffic_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_price += devices_price_per_month * months_to_pay
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}")
devices_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"devices",
period_days=period_hint_days,
)
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
devices_total_price = discounted_devices_per_month * months_to_pay
total_price += devices_total_price
message = (
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}"
f" = {devices_total_price/100}"
)
if devices_discount_per_month > 0:
message += (
f" (скидка {devices_discount_percent}%:"
f" -{devices_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
if additional_server_ids and db:
for server_id in additional_server_ids:
from app.database.crud.server_squad import get_server_squad_by_id
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
server_total_price = server_price_per_month * months_to_pay
servers_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"servers",
period_days=period_hint_days,
)
server_discount_per_month = (
server_price_per_month * servers_discount_percent // 100
)
discounted_server_per_month = (
server_price_per_month - server_discount_per_month
)
server_total_price = discounted_server_per_month * months_to_pay
total_price += server_total_price
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}")
message = (
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}"
f" = {server_total_price/100}"
)
if server_discount_per_month > 0:
message += (
f" (скидка {servers_discount_percent}%:"
f" -{server_discount_per_month * months_to_pay/100}₽)"
)
logger.info(message)
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}")
return total_price
+221
View File
@@ -0,0 +1,221 @@
import json
import logging
from pathlib import Path
from typing import Dict
from app.config import settings
logger = logging.getLogger(__name__)
class SupportSettingsService:
"""Runtime editable support settings with JSON persistence."""
_storage_path: Path = Path("data/support_settings.json")
_data: Dict = {}
_loaded: bool = False
@classmethod
def _ensure_dir(cls) -> None:
try:
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.error(f"Failed to ensure settings dir: {e}")
@classmethod
def _load(cls) -> None:
if cls._loaded:
return
cls._ensure_dir()
try:
if cls._storage_path.exists():
cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8"))
else:
cls._data = {}
except Exception as e:
logger.error(f"Failed to load support settings: {e}")
cls._data = {}
cls._loaded = True
@classmethod
def _save(cls) -> bool:
cls._ensure_dir()
try:
cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8")
return True
except Exception as e:
logger.error(f"Failed to save support settings: {e}")
return False
# Mode
@classmethod
def get_system_mode(cls) -> str:
cls._load()
mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower()
return mode if mode in {"tickets", "contact", "both"} else "both"
@classmethod
def set_system_mode(cls, mode: str) -> bool:
mode_clean = (mode or "").strip().lower()
if mode_clean not in {"tickets", "contact", "both"}:
return False
cls._load()
cls._data["system_mode"] = mode_clean
return cls._save()
# Main menu visibility
@classmethod
def is_support_menu_enabled(cls) -> bool:
cls._load()
if "menu_enabled" in cls._data:
return bool(cls._data["menu_enabled"])
return bool(settings.SUPPORT_MENU_ENABLED)
@classmethod
def set_support_menu_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["menu_enabled"] = bool(enabled)
return cls._save()
# Contact vs tickets helpers
@classmethod
def is_tickets_enabled(cls) -> bool:
return cls.get_system_mode() in {"tickets", "both"}
@classmethod
def is_contact_enabled(cls) -> bool:
return cls.get_system_mode() in {"contact", "both"}
# Descriptions (per language)
@classmethod
def get_support_info_text(cls, language: str) -> str:
cls._load()
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
overrides = cls._data.get("support_info_texts") or {}
text = overrides.get(lang)
if text and isinstance(text, str) and text.strip():
return text
# Fallback to dynamic localization default
from app.localization.texts import get_texts
return get_texts(lang).SUPPORT_INFO
@classmethod
def set_support_info_text(cls, language: str, text: str) -> bool:
cls._load()
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
texts_map = cls._data.get("support_info_texts") or {}
texts_map[lang] = text or ""
cls._data["support_info_texts"] = texts_map
return cls._save()
# Notifications & SLA
@classmethod
def get_admin_ticket_notifications_enabled(cls) -> bool:
cls._load()
if "admin_ticket_notifications_enabled" in cls._data:
return bool(cls._data["admin_ticket_notifications_enabled"])
# fallback to global admin notifications setting
return bool(settings.is_admin_notifications_enabled())
@classmethod
def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["admin_ticket_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_user_ticket_notifications_enabled(cls) -> bool:
cls._load()
if "user_ticket_notifications_enabled" in cls._data:
return bool(cls._data["user_ticket_notifications_enabled"])
# fallback to global enable notifications
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
@classmethod
def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["user_ticket_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_sla_enabled(cls) -> bool:
cls._load()
if "ticket_sla_enabled" in cls._data:
return bool(cls._data["ticket_sla_enabled"])
return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True))
@classmethod
def set_sla_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["ticket_sla_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_sla_minutes(cls) -> int:
cls._load()
minutes = cls._data.get("ticket_sla_minutes")
if isinstance(minutes, int) and minutes > 0:
return minutes
return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5))
@classmethod
def set_sla_minutes(cls, minutes: int) -> bool:
try:
minutes_int = int(minutes)
except Exception:
return False
if minutes_int <= 0:
return False
cls._load()
cls._data["ticket_sla_minutes"] = minutes_int
return cls._save()
# Moderators management
@classmethod
def get_moderators(cls) -> list[int]:
cls._load()
raw = cls._data.get("moderators") or []
moderators: list[int] = []
for item in raw:
try:
moderators.append(int(item))
except Exception:
continue
return moderators
@classmethod
def is_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
return tid in cls.get_moderators()
@classmethod
def add_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
cls._load()
moderators = set(cls.get_moderators())
moderators.add(tid)
cls._data["moderators"] = sorted(moderators)
return cls._save()
@classmethod
def remove_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
cls._load()
moderators = set(cls.get_moderators())
if tid in moderators:
moderators.remove(tid)
cls._data["moderators"] = sorted(moderators)
return cls._save()
return True
+706
View File
@@ -0,0 +1,706 @@
import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings, settings
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
)
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
logger = logging.getLogger(__name__)
def _title_from_key(key: str) -> str:
parts = key.split("_")
if not parts:
return key
return " ".join(part.capitalize() for part in parts)
def _truncate(value: str, max_len: int = 60) -> str:
value = value.strip()
if len(value) <= max_len:
return value
return value[: max_len - 1] + ""
@dataclass(slots=True)
class SettingDefinition:
key: str
category_key: str
category_label: str
python_type: Type[Any]
type_label: str
is_optional: bool
@property
def display_name(self) -> str:
return _title_from_key(self.key)
@dataclass(slots=True)
class ChoiceOption:
value: Any
label: str
description: Optional[str] = None
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
CATEGORY_TITLES: Dict[str, str] = {
"SUPPORT": "💬 Ссылка на поддержку",
"LOCALIZATION": "🌍 Языковые настройки",
"MAINTENANCE": "🛠️ Режим обслуживания",
"CHANNEL": "📣 Обязательная подписка",
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
"ADMIN_REPORTS": "📊 Автоматические отчеты",
"TRIAL": "🎁 Триальная подписка",
"PAID_SUBSCRIPTION": "💰 Платные подписки",
"PERIODS": "📅 Периоды подписки",
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
"TRAFFIC": "🚦 Настройки трафика",
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
"DISCOUNTS": "🎯 Промо и скидки",
"PAYMENT": "⚙️ Общие настройки платежей",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "💎 CryptoBot",
"YOOKASSA": "💸 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 Pal24/PayPalych",
"REMNAWAVE": "🔗 RemnaWave API",
"REFERRAL": "🤝 Реферальная система",
"AUTOPAY": "🔄 Автопродление",
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
"HAPP": "🅷 Happ настройки",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Приложения и DeepLinks",
"MINIAPP": "📱 Mini App",
"DATABASE": "🗄️ Режим БД",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "💾 SQLite",
"REDIS": "🧠 Redis",
"MONITORING": "📈 Общий мониторинг",
"NOTIFICATIONS": "🔔 Уведомления пользователям",
"SERVER": "🖥️ Статус серверов",
"BACKUP": "💾 Система бэкапов",
"VERSION": "🔄 Обновления",
"LOG": "📝 Логирование",
"WEBHOOK": "🌐 Webhook",
"WEB_API": "🌐 Web API",
"DEBUG": "🔧 Режим разработки",
}
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
"LOCALES_PATH": "DATABASE",
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"PAYMENT_SERVICE_NAME": "PAYMENT",
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
"SUPPORT_": "SUPPORT",
"ADMIN_NOTIFICATIONS": "ADMIN_NOTIFICATIONS",
"ADMIN_REPORTS": "ADMIN_REPORTS",
"CHANNEL_": "CHANNEL",
"POSTGRES_": "POSTGRES",
"SQLITE_": "SQLITE",
"REDIS_": "REDIS",
"REMNAWAVE": "REMNAWAVE",
"TRIAL_": "TRIAL",
"TRAFFIC_PACKAGES": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC": "TRAFFIC_PACKAGES",
"TRAFFIC_": "TRAFFIC",
"REFERRAL_": "REFERRAL",
"AUTOPAY_": "AUTOPAY",
"TELEGRAM_STARS": "TELEGRAM",
"TRIBUTE_": "TRIBUTE",
"YOOKASSA_": "YOOKASSA",
"CRYPTOBOT_": "CRYPTOBOT",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
"MINIAPP_": "MINIAPP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
"SERVER_STATUS": "SERVER",
"MAINTENANCE_": "MAINTENANCE",
"VERSION_CHECK": "VERSION",
"BACKUP_": "BACKUP",
"WEBHOOK_": "WEBHOOK",
"LOG_": "LOG",
"WEB_API_": "WEB_API",
"DEBUG": "DEBUG",
}
CHOICES: Dict[str, List[ChoiceOption]] = {
"DATABASE_MODE": [
ChoiceOption("auto", "🤖 Авто"),
ChoiceOption("postgresql", "🐘 PostgreSQL"),
ChoiceOption("sqlite", "💾 SQLite"),
],
"REMNAWAVE_AUTH_TYPE": [
ChoiceOption("api_key", "🔑 API Key"),
ChoiceOption("basic_auth", "🧾 Basic Auth"),
],
"REMNAWAVE_USER_DELETE_MODE": [
ChoiceOption("delete", "🗑 Удалять"),
ChoiceOption("disable", "🚫 Деактивировать"),
],
"TRAFFIC_SELECTION_MODE": [
ChoiceOption("selectable", "📦 Выбор пакетов"),
ChoiceOption("fixed", "📏 Фиксированный лимит"),
],
"DEFAULT_TRAFFIC_RESET_STRATEGY": [
ChoiceOption("NO_RESET", "♾️ Без сброса"),
ChoiceOption("DAY", "📅 Ежедневно"),
ChoiceOption("WEEK", "🗓 Еженедельно"),
ChoiceOption("MONTH", "📆 Ежемесячно"),
],
"SUPPORT_SYSTEM_MODE": [
ChoiceOption("tickets", "🎫 Только тикеты"),
ChoiceOption("contact", "💬 Только контакт"),
ChoiceOption("both", "🔁 Оба варианта"),
],
"CONNECT_BUTTON_MODE": [
ChoiceOption("guide", "📘 Гайд"),
ChoiceOption("miniapp_subscription", "🧾 Mini App подписка"),
ChoiceOption("miniapp_custom", "🧩 Mini App (ссылка)"),
ChoiceOption("link", "🔗 Прямая ссылка"),
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
],
"SERVER_STATUS_MODE": [
ChoiceOption("disabled", "🚫 Отключено"),
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
ChoiceOption("external_link_miniapp", "🧭 Mini App ссылка"),
ChoiceOption("xray", "📊 XRay Checker"),
],
"YOOKASSA_PAYMENT_MODE": [
ChoiceOption("full_payment", "💳 Полная оплата"),
ChoiceOption("partial_payment", "🪙 Частичная оплата"),
ChoiceOption("advance", "💼 Аванс"),
ChoiceOption("full_prepayment", "📦 Полная предоплата"),
ChoiceOption("partial_prepayment", "📦 Частичная предоплата"),
ChoiceOption("credit", "💰 Кредит"),
ChoiceOption("credit_payment", "💸 Погашение кредита"),
],
"YOOKASSA_PAYMENT_SUBJECT": [
ChoiceOption("commodity", "📦 Товар"),
ChoiceOption("excise", "🥃 Подакцизный товар"),
ChoiceOption("job", "🛠 Работа"),
ChoiceOption("service", "🧾 Услуга"),
ChoiceOption("gambling_bet", "🎲 Ставка"),
ChoiceOption("gambling_prize", "🏆 Выигрыш"),
ChoiceOption("lottery", "🎫 Лотерея"),
ChoiceOption("lottery_prize", "🎁 Приз лотереи"),
ChoiceOption("intellectual_activity", "🧠 Интеллектуальная деятельность"),
ChoiceOption("payment", "💱 Платеж"),
ChoiceOption("agent_commission", "🤝 Комиссия агента"),
ChoiceOption("composite", "🧩 Композитный"),
ChoiceOption("another", "📄 Другое"),
],
"YOOKASSA_VAT_CODE": [
ChoiceOption(1, "1 — НДС не облагается"),
ChoiceOption(2, "2 — НДС 0%"),
ChoiceOption(3, "3 — НДС 10%"),
ChoiceOption(4, "4 — НДС 20%"),
ChoiceOption(5, "5 — НДС 10/110"),
ChoiceOption(6, "6 — НДС 20/120"),
],
"MULENPAY_LANGUAGE": [
ChoiceOption("ru", "🇷🇺 Русский"),
ChoiceOption("en", "🇬🇧 Английский"),
],
"LOG_LEVEL": [
ChoiceOption("DEBUG", "🐞 Debug"),
ChoiceOption("INFO", "️ Info"),
ChoiceOption("WARNING", "⚠️ Warning"),
ChoiceOption("ERROR", "❌ Error"),
ChoiceOption("CRITICAL", "🔥 Critical"),
],
}
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
_callback_tokens: Dict[str, str] = {}
_token_to_key: Dict[str, str] = {}
_choice_tokens: Dict[str, Dict[Any, str]] = {}
_choice_token_lookup: Dict[str, Dict[str, Any]] = {}
@classmethod
def initialize_definitions(cls) -> None:
if cls._definitions:
return
for key, field in Settings.model_fields.items():
if key in cls.EXCLUDED_KEYS:
continue
annotation = field.annotation
python_type, is_optional = cls._normalize_type(annotation)
type_label = cls._type_to_label(python_type, is_optional)
category_key = cls._resolve_category_key(key)
category_label = cls.CATEGORY_TITLES.get(
category_key,
category_key.capitalize() if category_key else "Прочее",
)
cls._definitions[key] = SettingDefinition(
key=key,
category_key=category_key or "other",
category_label=category_label,
python_type=python_type,
type_label=type_label,
is_optional=is_optional,
)
cls._register_callback_token(key)
if key in cls.CHOICES:
cls._ensure_choice_tokens(key)
@classmethod
def _resolve_category_key(cls, key: str) -> str:
override = cls.CATEGORY_KEY_OVERRIDES.get(key)
if override:
return override
for prefix, category in sorted(
cls.CATEGORY_PREFIX_OVERRIDES.items(), key=lambda item: len(item[0]), reverse=True
):
if key.startswith(prefix):
return category
if "_" not in key:
return key.upper()
prefix = key.split("_", 1)[0]
return prefix.upper()
@classmethod
def _normalize_type(cls, annotation: Any) -> Tuple[Type[Any], bool]:
if annotation is None:
return str, True
origin = get_origin(annotation)
if origin is Union:
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
nested_type, nested_optional = cls._normalize_type(args[0])
return nested_type, True
return str, True
if annotation in {int, float, bool, str}:
return annotation, False
if annotation in {Optional[int], Optional[float], Optional[bool], Optional[str]}:
nested = get_args(annotation)[0]
return nested, True
# Paths, lists, dicts и прочее будем хранить как строки
return str, False
@classmethod
def _type_to_label(cls, python_type: Type[Any], is_optional: bool) -> str:
base = {
bool: "bool",
int: "int",
float: "float",
str: "str",
}.get(python_type, "str")
return f"optional[{base}]" if is_optional else base
@classmethod
def get_categories(cls) -> List[Tuple[str, str, int]]:
cls.initialize_definitions()
categories: Dict[str, List[SettingDefinition]] = {}
for definition in cls._definitions.values():
categories.setdefault(definition.category_key, []).append(definition)
result: List[Tuple[str, str, int]] = []
for category_key, items in categories.items():
label = items[0].category_label
result.append((category_key, label, len(items)))
result.sort(key=lambda item: item[1])
return result
@classmethod
def get_settings_for_category(cls, category_key: str) -> List[SettingDefinition]:
cls.initialize_definitions()
filtered = [
definition
for definition in cls._definitions.values()
if definition.category_key == category_key
]
filtered.sort(key=lambda definition: definition.key)
return filtered
@classmethod
def get_definition(cls, key: str) -> SettingDefinition:
cls.initialize_definitions()
return cls._definitions[key]
@classmethod
def has_override(cls, key: str) -> bool:
return key in cls._overrides_raw
@classmethod
def get_current_value(cls, key: str) -> Any:
return getattr(settings, key)
@classmethod
def get_original_value(cls, key: str) -> Any:
return cls._original_values.get(key)
@classmethod
def format_value(cls, value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "✅ Да" if value else "❌ Нет"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (list, dict, tuple, set)):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value)
@classmethod
def format_value_for_list(cls, key: str) -> str:
value = cls.get_current_value(key)
formatted = cls.format_value(value)
if formatted == "":
return formatted
return _truncate(formatted)
@classmethod
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
cls.initialize_definitions()
return cls.CHOICES.get(key, [])
@classmethod
def has_choices(cls, key: str) -> bool:
return bool(cls.get_choice_options(key))
@classmethod
def get_callback_token(cls, key: str) -> str:
cls.initialize_definitions()
return cls._callback_tokens[key]
@classmethod
def resolve_callback_token(cls, token: str) -> str:
cls.initialize_definitions()
return cls._token_to_key[token]
@classmethod
def get_choice_token(cls, key: str, value: Any) -> Optional[str]:
cls.initialize_definitions()
cls._ensure_choice_tokens(key)
return cls._choice_tokens.get(key, {}).get(value)
@classmethod
def resolve_choice_token(cls, key: str, token: str) -> Any:
cls.initialize_definitions()
cls._ensure_choice_tokens(key)
return cls._choice_token_lookup.get(key, {})[token]
@classmethod
def _register_callback_token(cls, key: str) -> None:
if key in cls._callback_tokens:
return
base = hashlib.blake2s(key.encode("utf-8"), digest_size=6).hexdigest()
candidate = base
counter = 1
while candidate in cls._token_to_key and cls._token_to_key[candidate] != key:
suffix = cls._encode_base36(counter)
candidate = f"{base}{suffix}"[:16]
counter += 1
cls._callback_tokens[key] = candidate
cls._token_to_key[candidate] = key
@classmethod
def _ensure_choice_tokens(cls, key: str) -> None:
if key in cls._choice_tokens:
return
options = cls.CHOICES.get(key, [])
value_to_token: Dict[Any, str] = {}
token_to_value: Dict[str, Any] = {}
for index, option in enumerate(options):
token = cls._encode_base36(index)
value_to_token[option.value] = token
token_to_value[token] = option.value
cls._choice_tokens[key] = value_to_token
cls._choice_token_lookup[key] = token_to_value
@staticmethod
def _encode_base36(number: int) -> str:
if number < 0:
raise ValueError("number must be non-negative")
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
if number == 0:
return "0"
result = []
while number:
number, rem = divmod(number, 36)
result.append(alphabet[rem])
return "".join(reversed(result))
@classmethod
async def initialize(cls) -> None:
cls.initialize_definitions()
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting))
rows = result.scalars().all()
overrides: Dict[str, Optional[str]] = {}
for row in rows:
if row.key in cls._definitions:
overrides[row.key] = row.value
for key, raw_value in overrides.items():
try:
parsed_value = cls.deserialize_value(key, raw_value)
except Exception as error:
logger.error("Не удалось применить настройку %s: %s", key, error)
continue
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, parsed_value)
await cls._sync_default_web_api_token()
@classmethod
async def reload(cls) -> None:
cls._overrides_raw.clear()
await cls.initialize()
@classmethod
def deserialize_value(cls, key: str, raw_value: Optional[str]) -> Any:
if raw_value is None:
return None
definition = cls.get_definition(key)
python_type = definition.python_type
if python_type is bool:
value_lower = raw_value.strip().lower()
if value_lower in {"1", "true", "on", "yes", "да"}:
return True
if value_lower in {"0", "false", "off", "no", "нет"}:
return False
raise ValueError(f"Неверное булево значение: {raw_value}")
if python_type is int:
return int(raw_value)
if python_type is float:
return float(raw_value)
return raw_value
@classmethod
def serialize_value(cls, key: str, value: Any) -> Optional[str]:
if value is None:
return None
definition = cls.get_definition(key)
python_type = definition.python_type
if python_type is bool:
return "true" if value else "false"
if python_type in {int, float}:
return str(value)
return str(value)
@classmethod
def parse_user_value(cls, key: str, user_input: str) -> Any:
definition = cls.get_definition(key)
text = (user_input or "").strip()
if text.lower() in {"отмена", "cancel"}:
raise ValueError("Ввод отменен пользователем")
if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}:
return None
python_type = definition.python_type
if python_type is bool:
lowered = text.lower()
if lowered in {"1", "true", "on", "yes", "да", "вкл", "enable", "enabled"}:
return True
if lowered in {"0", "false", "off", "no", "нет", "выкл", "disable", "disabled"}:
return False
raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')")
if python_type is int:
parsed_value: Any = int(text)
elif python_type is float:
parsed_value = float(text.replace(",", "."))
else:
parsed_value = text
choices = cls.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if python_type is str:
lowered_map = {
str(option.value).lower(): option.value for option in choices
}
normalized = lowered_map.get(str(parsed_value).lower())
if normalized is not None:
parsed_value = normalized
elif parsed_value not in allowed_values:
readable = ", ".join(
f"{option.label} ({cls.format_value(option.value)})" for option in choices
)
raise ValueError(f"Доступные значения: {readable}")
elif parsed_value not in allowed_values:
readable = ", ".join(
f"{option.label} ({cls.format_value(option.value)})" for option in choices
)
raise ValueError(f"Доступные значения: {readable}")
return parsed_value
@classmethod
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, value)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
async def reset_value(cls, db: AsyncSession, key: str) -> None:
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
original = cls.get_original_value(key)
cls._apply_to_settings(key, original)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
def _apply_to_settings(cls, key: str, value: Any) -> None:
try:
setattr(settings, key, value)
except Exception as error:
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
@staticmethod
async def _sync_default_web_api_token() -> None:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
return
success = await ensure_default_web_api_token()
if not success:
logger.warning(
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
)
@classmethod
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
definition = cls.get_definition(key)
current = cls.get_current_value(key)
original = cls.get_original_value(key)
has_override = cls.has_override(key)
return {
"key": key,
"name": definition.display_name,
"current": cls.format_value(current),
"original": cls.format_value(original),
"type": definition.type_label,
"category_key": definition.category_key,
"category_label": definition.category_label,
"has_override": has_override,
}
bot_configuration_service = BotConfigurationService
+7 -34
View File
@@ -5,8 +5,6 @@ from datetime import datetime
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.database import get_db
from app.database.models import Transaction, TransactionType, PaymentMethod
@@ -15,7 +13,7 @@ from app.database.crud.transaction import (
)
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
from app.external.tribute import TributeService as TributeAPI
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
@@ -216,7 +214,7 @@ class TributeService:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
try:
amount_rubles = amount_kopeks / 100
@@ -224,34 +222,8 @@ class TributeService:
user = await get_user_by_telegram_id(session, user_id)
break
user_language = user.language if user else "ru"
texts = get_texts(user_language)
has_active_subscription = (
user
and user.subscription
and not user.subscription.is_trial
and user.subscription.is_active
)
first_button = InlineKeyboardButton(
text=(
texts.MENU_EXTEND_SUBSCRIPTION
if has_active_subscription
else texts.MENU_BUY_SUBSCRIPTION
),
callback_data=(
"subscription_extend" if has_active_subscription else "menu_buy"
)
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[first_button],
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
]
)
payment_service = PaymentService(self.bot)
keyboard = await payment_service.build_topup_success_keyboard(user)
text = (
f"✅ **Платеж успешно получен!**\n\n"
@@ -267,10 +239,11 @@ class TributeService:
reply_markup=keyboard,
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
try:
+480 -105
View File
@@ -1,19 +1,26 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update
from sqlalchemy import delete, select, update, func
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
get_users_count, get_users_statistics, get_inactive_users,
add_user_balance, subtract_user_balance, update_user, delete_user
add_user_balance, subtract_user_balance, update_user, delete_user,
get_users_spending_stats
)
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.transaction import get_user_transactions_count
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
)
from app.config import settings
@@ -22,6 +29,59 @@ logger = logging.getLogger(__name__)
class UserService:
async def _send_balance_notification(
self,
bot: Bot,
user: User,
amount_kopeks: int,
admin_name: str
) -> bool:
"""Отправляет уведомление пользователю о пополнении/списании баланса"""
try:
if amount_kopeks > 0:
# Пополнение
emoji = "💰"
action = "пополнен"
amount_text = f"+{settings.format_price(amount_kopeks)}"
message = (
f"{emoji} <b>Баланс пополнен!</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Спасибо за использование нашего сервиса! 🎉"
)
else:
# Списание
emoji = "💸"
action = "списан"
amount_text = f"-{settings.format_price(abs(amount_kopeks))}"
message = (
f"{emoji} <b>Средства списаны с баланса</b>\n\n"
f"💵 <b>Сумма:</b> {amount_text}\n"
f"👤 <b>Администратор:</b> {admin_name}\n"
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
)
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML"
)
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
return True
except TelegramForbiddenError:
logger.warning(f"⚠️ Пользователь {user.telegram_id} заблокировал бота")
return False
except TelegramBadRequest as e:
logger.error(f"❌ Ошибка Telegram API при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при отправке уведомления пользователю {user.telegram_id}: {e}")
return False
async def get_user_profile(
self,
db: AsyncSession,
@@ -83,19 +143,32 @@ class UserService:
"has_next": False,
"has_prev": False
}
async def get_users_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20,
status: Optional[UserStatus] = None
status: Optional[UserStatus] = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
order_by_last_activity: bool = False,
order_by_total_spent: bool = False,
order_by_purchase_count: bool = False
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
users = await get_users_list(
db, offset=offset, limit=limit, status=status
db,
offset=offset,
limit=limit,
status=status,
order_by_balance=order_by_balance,
order_by_traffic=order_by_traffic,
order_by_last_activity=order_by_last_activity,
order_by_total_spent=order_by_total_spent,
order_by_purchase_count=order_by_purchase_count,
)
total_count = await get_users_count(db, status=status)
@@ -120,34 +193,200 @@ class UserService:
"has_next": False,
"has_prev": False
}
async def get_user_spending_stats_map(
self,
db: AsyncSession,
user_ids: List[int]
) -> Dict[int, Dict[str, int]]:
try:
return await get_users_spending_stats(db, user_ids)
except Exception as e:
logger.error(f"Ошибка получения статистики трат пользователей: {e}")
return {}
async def get_users_by_campaign_page(
self,
db: AsyncSession,
page: int = 1,
limit: int = 20
) -> Dict[str, Any]:
try:
offset = (page - 1) * limit
campaign_ranked = (
select(
AdvertisingCampaignRegistration.user_id.label("user_id"),
AdvertisingCampaignRegistration.campaign_id.label("campaign_id"),
AdvertisingCampaignRegistration.created_at.label("created_at"),
func.row_number()
.over(
partition_by=AdvertisingCampaignRegistration.user_id,
order_by=AdvertisingCampaignRegistration.created_at.desc(),
)
.label("rn"),
)
.cte("campaign_ranked")
)
latest_campaign = (
select(
campaign_ranked.c.user_id,
campaign_ranked.c.campaign_id,
campaign_ranked.c.created_at,
)
.where(campaign_ranked.c.rn == 1)
.subquery()
)
query = (
select(
User,
AdvertisingCampaign.name.label("campaign_name"),
latest_campaign.c.created_at,
)
.join(latest_campaign, latest_campaign.c.user_id == User.id)
.join(
AdvertisingCampaign,
AdvertisingCampaign.id == latest_campaign.c.campaign_id,
)
.order_by(
AdvertisingCampaign.name.asc(),
latest_campaign.c.created_at.desc(),
)
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
users = [row[0] for row in rows]
campaign_map = {
row[0].id: {
"campaign_name": row[1],
"registered_at": row[2],
}
for row in rows
}
total_stmt = select(func.count()).select_from(latest_campaign)
total_result = await db.execute(total_stmt)
total_count = total_result.scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 1
return {
"users": users,
"campaigns": campaign_map,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
"has_next": page < total_pages,
"has_prev": page > 1,
}
except Exception as e:
logger.error(f"Ошибка получения пользователей по кампаниям: {e}")
return {
"users": [],
"campaigns": {},
"current_page": 1,
"total_pages": 1,
"total_count": 0,
"has_next": False,
"has_prev": False,
}
async def update_user_balance(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
admin_id: int
admin_id: int,
bot: Optional[Bot] = None,
admin_name: Optional[str] = None
) -> bool:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False
# Сохраняем старый баланс для уведомления
old_balance = user.balance_kopeks
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description=description)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
return True
success = True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
success = await subtract_user_balance(
db,
user,
abs(amount_kopeks),
description,
create_transaction=True,
payment_method=PaymentMethod.MANUAL,
)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
return success
# Отправляем уведомление пользователю, если операция прошла успешно
if success and bot:
# Обновляем пользователя для получения нового баланса
await db.refresh(user)
# Получаем имя администратора
if not admin_name:
admin_user = await get_user_by_id(db, admin_id)
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
return success
except Exception as e:
logger.error(f"Ошибка изменения баланса пользователя: {e}")
return False
async def update_user_promo_group(
self,
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None, None, None
old_group = user.promo_group
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
return False, None, None, old_group
user.promo_group_id = promo_group.id
user.promo_group = promo_group
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(
"👥 Промогруппа пользователя %s обновлена на '%s'",
user.telegram_id,
promo_group.name,
)
return True, user, promo_group, old_group
except Exception as e:
await db.rollback()
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
return False, None, None, None
async def block_user(
self,
db: AsyncSession,
@@ -238,48 +477,124 @@ class UserService:
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
if user.remnawave_uuid:
from app.config import settings
delete_mode = settings.get_remnawave_user_delete_mode()
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
from app.services.remnawave_service import RemnaWaveService
remnawave_service = RemnaWaveService()
if delete_mode == "delete":
# Удаляем пользователя из панели Remnawave
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
else:
logger.warning(f"⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave")
else:
# Деактивируем пользователя в панели Remnawave
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})")
except Exception as e:
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
logger.warning(f"⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}")
# Если основное действие не удалось, попытаемся хотя бы деактивировать
if delete_mode == "delete":
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback")
except Exception as fallback_e:
logger.error(f"❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}")
try:
from app.database.models import UserMessage
from sqlalchemy import update
sent_notifications_result = await db.execute(
select(SentNotification).where(SentNotification.user_id == user_id)
)
sent_notifications = sent_notifications_result.scalars().all()
result = await db.execute(
if sent_notifications:
logger.info(f"🔄 Удаляем {len(sent_notifications)} уведомлений")
await db.execute(
delete(SentNotification).where(SentNotification.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления уведомлений: {e}")
try:
if user.subscription:
subscription_servers_result = await db.execute(
select(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
subscription_servers = subscription_servers_result.scalars().all()
if subscription_servers:
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления связей подписка-сервер: {e}")
try:
user_messages_result = await db.execute(
update(UserMessage)
.where(UserMessage.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
if user_messages_result.rowcount > 0:
logger.info(f"🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
try:
from app.database.models import PromoCode
from sqlalchemy import update
result = await db.execute(
promocodes_result = await db.execute(
update(PromoCode)
.where(PromoCode.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
if promocodes_result.rowcount > 0:
logger.info(f"🔄 Обновлено {promocodes_result.rowcount} промокодов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления промокодов: {e}")
try:
welcome_texts_result = await db.execute(
update(WelcomeText)
.where(WelcomeText.created_by == user_id)
.values(created_by=None)
)
if welcome_texts_result.rowcount > 0:
logger.info(f"🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления приветственных текстов: {e}")
try:
referrals_result = await db.execute(
update(User)
.where(User.referred_by_id == user_id)
.values(referred_by_id=None)
)
if referrals_result.rowcount > 0:
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
try:
from app.database.models import YooKassaPayment
from sqlalchemy import select
yookassa_result = await db.execute(
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
@@ -287,33 +602,82 @@ class UserService:
if yookassa_payments:
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
await db.flush()
logger.info(f"✅ YooKassa платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
try:
from app.database.models import CryptoBotPayment
from sqlalchemy import select, delete
cryptobot_result = await db.execute(
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
cryptobot_payments = cryptobot_result.scalars().all()
if cryptobot_payments:
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
await db.execute(
update(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
await db.flush()
logger.info(f"✅ CryptoBot платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
try:
mulenpay_result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
)
mulenpay_payments = mulenpay_result.scalars().all()
if mulenpay_payments:
logger.info(f"🔄 Удаляем {len(mulenpay_payments)} MulenPay платежей")
await db.execute(
update(MulenPayPayment)
.where(MulenPayPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления MulenPay платежей: {e}")
try:
pal24_result = await db.execute(
select(Pal24Payment).where(Pal24Payment.user_id == user_id)
)
pal24_payments = pal24_result.scalars().all()
if pal24_payments:
logger.info(f"🔄 Удаляем {len(pal24_payments)} Pal24 платежей")
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(Pal24Payment).where(Pal24Payment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления Pal24 платежей: {e}")
try:
transactions_result = await db.execute(
select(Transaction).where(Transaction.user_id == user_id)
@@ -326,49 +690,55 @@ class UserService:
delete(Transaction).where(Transaction.user_id == user_id)
)
await db.flush()
logger.info(f"✅ Транзакции удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления транзакций: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
promocode_uses_result = await db.execute(
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
promocode_uses = promocode_uses_result.scalars().all()
if promocode_uses:
logger.info(f"🔄 Удаляем {len(promocode_uses)} использований промокодов")
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
referral_earnings_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
referral_earnings = referral_earnings_result.scalars().all()
if referral_earnings:
logger.info(f"🔄 Удаляем {len(referral_earnings)} реферальных доходов")
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
referral_records_result = await db.execute(
select(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
referral_records = referral_records_result.scalars().all()
if referral_records:
logger.info(f"🔄 Удаляем {len(referral_records)} записей о рефералах")
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
logger.error(f"❌ Ошибка удаления записей о рефералах: {e}")
try:
from app.database.models import BroadcastHistory
await db.execute(
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
try:
from app.database.models import SubscriptionConversion
conversions_result = await db.execute(
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
@@ -380,46 +750,51 @@ class UserService:
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
await db.flush()
logger.info(f"✅ Записи конверсий удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
if user.subscription:
try:
try:
broadcast_history_result = await db.execute(
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
broadcast_history = broadcast_history_result.scalars().all()
if broadcast_history:
logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок")
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id == user.subscription.id
)
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
if user.subscription:
try:
from app.database.models import Subscription
except Exception as e:
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
try:
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.created_by == user_id)
)
campaigns = campaigns_result.scalars().all()
if campaigns:
logger.info(f"🔄 Очищаем создателя у {len(campaigns)} рекламных кампаний")
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.created_by == user_id)
.values(created_by=None)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления рекламных кампаний: {e}")
try:
if user.subscription:
logger.info(f"🔄 Удаляем подписку {user.subscription.id}")
await db.execute(
delete(Subscription).where(Subscription.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления подписки: {e}")
try:
from sqlalchemy import update
referrals_result = await db.execute(
update(User)
.where(User.referred_by_id == user_id)
.values(referred_by_id=None)
)
if referrals_result.rowcount > 0:
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
logger.error(f"❌ Ошибка удаления подписки: {e}")
try:
await db.execute(
delete(User).where(User.id == user_id)
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import secrets
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud import web_api_token as crud
from app.database.models import WebApiToken
from app.database.universal_migration import ensure_default_web_api_token
from app.utils.security import generate_api_token, hash_api_token
class WebApiTokenService:
"""Сервис для управления токенами административного веб-API."""
def __init__(self):
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
def hash_token(self, token: str) -> str:
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
async def authenticate(
self,
db: AsyncSession,
token_value: str,
*,
remote_ip: Optional[str] = None,
) -> Optional[WebApiToken]:
normalized_value = token_value.strip()
if not normalized_value:
return None
async def _load_token(value: str) -> Optional[WebApiToken]:
token_hash = self.hash_token(value)
return await crud.get_token_by_hash(db, token_hash)
token = await _load_token(normalized_value)
if not token:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if default_token and secrets.compare_digest(default_token, normalized_value):
await ensure_default_web_api_token()
token = await _load_token(default_token)
if not token or not token.is_active:
return None
if token.expires_at and token.expires_at < datetime.utcnow():
return None
token.last_used_at = datetime.utcnow()
if remote_ip:
token.last_used_ip = remote_ip
await db.flush()
return token
async def create_token(
self,
db: AsyncSession,
*,
name: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
token_value: Optional[str] = None,
) -> Tuple[str, WebApiToken]:
plain_token = token_value or generate_api_token()
token_hash = self.hash_token(plain_token)
token = await crud.create_token(
db,
name=name,
token_hash=token_hash,
token_prefix=plain_token[:12],
description=description,
expires_at=expires_at,
created_by=created_by,
)
return plain_token, token
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = False
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = True
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
web_api_token_service = WebApiTokenService()
+104 -1
View File
@@ -11,7 +11,6 @@ from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
@@ -149,6 +148,110 @@ class YooKassaService:
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
return None
async def create_sbp_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
return {
"error": True,
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": "redirect",
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({
"type": "sbp"
})
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
+63 -3
View File
@@ -1,6 +1,7 @@
from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_language = State()
waiting_for_rules_accept = State()
waiting_for_referral_code = State()
@@ -16,6 +17,7 @@ class SubscriptionStates(StatesGroup):
adding_devices = State()
extending_subscription = State()
confirming_traffic_reset = State()
cart_saved_for_topup = State()
class BalanceStates(StatesGroup):
waiting_for_amount = State()
@@ -41,19 +43,52 @@ class AdminStates(StatesGroup):
setting_promocode_value = State()
setting_promocode_uses = State()
setting_promocode_expiry = State()
creating_campaign_name = State()
creating_campaign_start = State()
creating_campaign_bonus = State()
creating_campaign_balance = State()
creating_campaign_subscription_days = State()
creating_campaign_subscription_traffic = State()
creating_campaign_subscription_devices = State()
creating_campaign_subscription_servers = State()
editing_campaign_name = State()
editing_campaign_start = State()
editing_campaign_balance = State()
editing_campaign_subscription_days = State()
editing_campaign_subscription_traffic = State()
editing_campaign_subscription_devices = State()
editing_campaign_subscription_servers = State()
waiting_for_broadcast_message = State()
waiting_for_broadcast_media = State()
confirming_broadcast = State()
creating_promo_group_name = State()
creating_promo_group_traffic_discount = State()
creating_promo_group_server_discount = State()
creating_promo_group_device_discount = State()
creating_promo_group_period_discount = State()
creating_promo_group_auto_assign = State()
editing_promo_group_menu = State()
editing_promo_group_name = State()
editing_promo_group_traffic_discount = State()
editing_promo_group_server_discount = State()
editing_promo_group_device_discount = State()
editing_promo_group_period_discount = State()
editing_promo_group_auto_assign = State()
editing_squad_price = State()
editing_traffic_price = State()
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
editing_rules_page = State()
editing_notification_value = State()
confirming_sync = State()
editing_server_name = State()
@@ -61,6 +96,7 @@ class AdminStates(StatesGroup):
editing_server_country = State()
editing_server_limit = State()
editing_server_description = State()
editing_server_promo_groups = State()
creating_server_uuid = State()
creating_server_name = State()
@@ -69,10 +105,34 @@ class AdminStates(StatesGroup):
editing_welcome_text = State()
waiting_for_message_buttons = "waiting_for_message_buttons"
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
viewing_user_from_traffic_list = State()
viewing_user_from_last_activity_list = State()
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
class TicketStates(StatesGroup):
waiting_for_title = State()
waiting_for_message = State()
waiting_for_reply = State()
class AdminTicketStates(StatesGroup):
waiting_for_reply = State()
waiting_for_block_duration = State()
class SupportSettingsStates(StatesGroup):
waiting_for_desc = State()
class BotConfigStates(StatesGroup):
waiting_for_value = State()
class AutoPayStates(StatesGroup):
setting_autopay_days = State()
confirming_autopay_toggle = State()
+24 -4
View File
@@ -67,13 +67,28 @@ class CacheService:
async def delete(self, key: str) -> bool:
if not self._connected:
return False
try:
deleted = await self.redis_client.delete(key)
return deleted > 0
except Exception as e:
logger.error(f"Ошибка удаления из кеша {key}: {e}")
return False
async def delete_pattern(self, pattern: str) -> int:
if not self._connected:
return 0
try:
keys = await self.redis_client.keys(pattern)
if not keys:
return 0
deleted = await self.redis_client.delete(*keys)
return int(deleted)
except Exception as e:
logger.error(f"Ошибка удаления ключей по шаблону {pattern}: {e}")
return 0
async def exists(self, key: str) -> bool:
if not self._connected:
@@ -203,14 +218,19 @@ class UserCache:
@staticmethod
async def set_user_session(
user_id: int,
session_key: str,
data: Any,
user_id: int,
session_key: str,
data: Any,
expire: int = 1800
) -> bool:
key = cache_key("session", user_id, session_key)
return await cache.set(key, data, expire)
@staticmethod
async def delete_user_session(user_id: int, session_key: str) -> bool:
key = cache_key("session", user_id, session_key)
return await cache.delete(key)
class SystemCache:
+35
View File
@@ -0,0 +1,35 @@
from typing import Optional
from aiogram.fsm.context import FSMContext
from aiogram.types import TelegramObject, Message, CallbackQuery
from app.states import RegistrationStates
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
registration_callbacks = [
"rules_accept",
"rules_decline",
"referral_skip"
]
language_select_prefix = "language_select:"
if current_state in registration_states:
return True
if (isinstance(event, CallbackQuery)
and event.data
and (
event.data in registration_callbacks
or event.data.startswith(language_select_prefix)
)):
return True
return False
+44 -25
View File
@@ -28,7 +28,7 @@ def format_date(dt: Union[datetime, str], format_str: str = "%d.%m.%Y") -> str:
return dt.strftime(format_str)
def format_time_ago(dt: Union[datetime, str]) -> str:
def format_time_ago(dt: Union[datetime, str], language: str = "ru") -> str:
if isinstance(dt, str):
if dt == "now" or dt == "":
dt = datetime.now()
@@ -40,32 +40,51 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
now = datetime.utcnow()
diff = now - dt
language_code = (language or "ru").split("-")[0].lower()
if diff.days > 0:
if diff.days == 1:
return "вчера"
elif diff.days < 7:
return f"{diff.days} дн. назад"
elif diff.days < 30:
weeks = diff.days // 7
return f"{weeks} нед. назад"
elif diff.days < 365:
months = diff.days // 30
return f"{months} мес. назад"
else:
years = diff.days // 365
return f"{years} г. назад"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} ч. назад"
elif diff.seconds > 60:
minutes = diff.seconds // 60
return f"{minutes} мин. назад"
else:
return "только что"
return "yesterday" if language_code == "en" else "вчера"
if diff.days < 7:
value = diff.days
if language_code == "en":
suffix = "day" if value == 1 else "days"
return f"{value} {suffix} ago"
return f"{value} дн. назад"
if diff.days < 30:
value = diff.days // 7
if language_code == "en":
suffix = "week" if value == 1 else "weeks"
return f"{value} {suffix} ago"
return f"{value} нед. назад"
if diff.days < 365:
value = diff.days // 30
if language_code == "en":
suffix = "month" if value == 1 else "months"
return f"{value} {suffix} ago"
return f"{value} мес. назад"
value = diff.days // 365
if language_code == "en":
suffix = "year" if value == 1 else "years"
return f"{value} {suffix} ago"
return f"{value} г. назад"
if diff.seconds > 3600:
value = diff.seconds // 3600
if language_code == "en":
suffix = "hour" if value == 1 else "hours"
return f"{value} {suffix} ago"
return f"{value} ч. назад"
if diff.seconds > 60:
value = diff.seconds // 60
if language_code == "en":
suffix = "minute" if value == 1 else "minutes"
return f"{value} {suffix} ago"
return f"{value} мин. назад"
return "just now" if language_code == "en" else "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
+49 -5
View File
@@ -1,5 +1,6 @@
from pathlib import Path
from aiogram.types import Message, FSInputFile, InputMediaPhoto
from aiogram.exceptions import TelegramBadRequest
from app.config import settings
@@ -15,20 +16,63 @@ _original_edit_text = Message.edit_text
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
if not settings.ENABLE_LOGO_MODE:
return await _original_answer(self, text, **kwargs)
# Если caption слишком длинный для фото — отправим как текст
try:
if text is not None and len(text) > 900:
return await _original_answer(self, text, **kwargs)
except Exception:
pass
if LOGO_PATH.exists():
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
try:
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except Exception:
# Фоллбек, если Telegram ругается на caption: отправим как текст
return await _original_answer(self, text, **kwargs)
return await _original_answer(self, text, **kwargs)
async def _edit_with_photo(self: Message, text: str, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
if not settings.ENABLE_LOGO_MODE:
return await _original_edit_text(self, text, **kwargs)
if self.photo:
media = self.photo[-1].file_id
if is_qr_message(self) and LOGO_PATH.exists():
# Если caption потенциально слишком длинный — отправим как текст вместо caption
try:
if text is not None and len(text) > 900:
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
except Exception:
pass
# Всегда используем логотип если включен режим логотипа,
# кроме специальных случаев (QR сообщения)
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
media = FSInputFile(LOGO_PATH)
elif is_qr_message(self) and LOGO_PATH.exists():
media = FSInputFile(LOGO_PATH)
else:
media = self.photo[-1].file_id
media_kwargs = {"media": media, "caption": text}
if "parse_mode" in kwargs:
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
_pm = kwargs.pop("parse_mode")
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
else:
media_kwargs["parse_mode"] = "HTML"
try:
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
except TelegramBadRequest:
# Фоллбек: удалим и отправим обычный текст без фото
try:
await self.delete()
except Exception:
pass
return await _original_answer(self, text, **kwargs)
return await _original_edit_text(self, text, **kwargs)
+175
View File
@@ -0,0 +1,175 @@
from typing import List, Dict, Tuple
from app.config import settings
from app.localization.texts import get_texts
def get_available_payment_methods() -> List[Dict[str, str]]:
"""
Возвращает список доступных способов оплаты с их настройками
"""
methods = []
if settings.TELEGRAM_STARS_ENABLED:
methods.append({
"id": "stars",
"name": "Telegram Stars",
"icon": "",
"description": "быстро и удобно",
"callback": "topup_stars"
})
if settings.is_yookassa_enabled():
methods.append({
"id": "yookassa",
"name": "Банковская карта",
"icon": "💳",
"description": "через YooKassa",
"callback": "topup_yookassa"
})
if settings.TRIBUTE_ENABLED:
methods.append({
"id": "tribute",
"name": "Банковская карта",
"icon": "💳",
"description": "через Tribute",
"callback": "topup_tribute"
})
if settings.is_mulenpay_enabled():
methods.append({
"id": "mulenpay",
"name": "Банковская карта",
"icon": "💳",
"description": "через Mulen Pay",
"callback": "topup_mulenpay"
})
if settings.is_pal24_enabled():
methods.append({
"id": "pal24",
"name": "СБП",
"icon": "🏦",
"description": "через PayPalych",
"callback": "topup_pal24"
})
if settings.is_cryptobot_enabled():
methods.append({
"id": "cryptobot",
"name": "Криптовалюта",
"icon": "🪙",
"description": "через CryptoBot",
"callback": "topup_cryptobot"
})
# Поддержка всегда доступна
methods.append({
"id": "support",
"name": "Через поддержку",
"icon": "🛠️",
"description": "другие способы",
"callback": "topup_support"
})
return methods
def get_payment_methods_text(language: str) -> str:
"""
Генерирует текст с описанием доступных способов оплаты
"""
texts = get_texts(language)
methods = get_available_payment_methods()
if len(methods) <= 1: # Только поддержка
return texts.t(
"PAYMENT_METHODS_ONLY_SUPPORT",
"""💳 <b>Способы пополнения баланса</b>
В данный момент автоматические способы оплаты временно недоступны.
Обратитесь в техподдержку для пополнения баланса.
Выберите способ пополнения:""",
)
text = texts.t(
"PAYMENT_METHODS_TITLE",
"💳 <b>Способы пополнения баланса</b>",
) + "\n\n"
text += texts.t(
"PAYMENT_METHODS_PROMPT",
"Выберите удобный для вас способ оплаты:",
) + "\n\n"
for method in methods:
method_id = method['id'].upper()
name = texts.t(
f"PAYMENT_METHOD_{method_id}_NAME",
f"{method['icon']} <b>{method['name']}</b>",
)
description = texts.t(
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
method['description'],
)
text += f"{name} - {description}\n"
text += "\n" + texts.t(
"PAYMENT_METHODS_FOOTER",
"Выберите способ пополнения:",
)
return text
def is_payment_method_available(method_id: str) -> bool:
"""
Проверяет, доступен ли конкретный способ оплаты
"""
if method_id == "stars":
return settings.TELEGRAM_STARS_ENABLED
elif method_id == "yookassa":
return settings.is_yookassa_enabled()
elif method_id == "tribute":
return settings.TRIBUTE_ENABLED
elif method_id == "mulenpay":
return settings.is_mulenpay_enabled()
elif method_id == "pal24":
return settings.is_pal24_enabled()
elif method_id == "cryptobot":
return settings.is_cryptobot_enabled()
elif method_id == "support":
return True # Поддержка всегда доступна
else:
return False
def get_payment_method_status() -> Dict[str, bool]:
"""
Возвращает статус всех способов оплаты
"""
return {
"stars": settings.TELEGRAM_STARS_ENABLED,
"yookassa": settings.is_yookassa_enabled(),
"tribute": settings.TRIBUTE_ENABLED,
"mulenpay": settings.is_mulenpay_enabled(),
"pal24": settings.is_pal24_enabled(),
"cryptobot": settings.is_cryptobot_enabled(),
"support": True
}
def get_enabled_payment_methods_count() -> int:
"""
Возвращает количество включенных способов оплаты (не считая поддержку)
"""
count = 0
if settings.TELEGRAM_STARS_ENABLED:
count += 1
if settings.is_yookassa_enabled():
count += 1
if settings.TRIBUTE_ENABLED:
count += 1
if settings.is_mulenpay_enabled():
count += 1
if settings.is_pal24_enabled():
count += 1
if settings.is_cryptobot_enabled():
count += 1
return count
+42 -9
View File
@@ -7,7 +7,12 @@ from .message_patch import LOGO_PATH, is_qr_message
def _resolve_media(message: types.Message):
if message.photo and not is_qr_message(message):
# Всегда используем логотип если включен режим логотипа,
# кроме специальных случаев (QR сообщения)
if settings.ENABLE_LOGO_MODE and not is_qr_message(message):
return FSInputFile(LOGO_PATH)
# Только если режим логотипа выключен, используем фото из сообщения
elif message.photo:
return message.photo[-1].file_id
return FSInputFile(LOGO_PATH)
@@ -18,6 +23,7 @@ async def edit_or_answer_photo(
keyboard: types.InlineKeyboardMarkup,
parse_mode: str | None = "HTML",
) -> None:
# Если режим логотипа выключен — работаем текстом
if not settings.ENABLE_LOGO_MODE:
try:
if callback.message.photo:
@@ -42,17 +48,44 @@ async def edit_or_answer_photo(
)
return
# Если текст слишком длинный для caption — отправим как текст
if caption and len(caption) > 1000:
try:
if callback.message.photo:
await callback.message.delete()
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
except TelegramBadRequest:
pass
return
media = _resolve_media(callback.message)
try:
await callback.message.edit_media(
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
reply_markup=keyboard,
)
except TelegramBadRequest:
await callback.message.delete()
await callback.message.answer_photo(
FSInputFile(LOGO_PATH),
caption=caption,
reply_markup=keyboard,
parse_mode=parse_mode,
)
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
try:
await callback.message.delete()
except Exception:
pass
try:
# Отправим как фото с логотипом
await callback.message.answer_photo(
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
caption=caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
)
except Exception:
# Последний фоллбек — обычный текст
await callback.message.answer(
caption,
reply_markup=keyboard,
parse_mode=(parse_mode or "HTML"),
)

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