636 Commits

Author SHA1 Message Date
Vladless 4fa5c882ba yookassa ip adresses 2026-02-25 23:27:58 +03:00
Vladless 8f031d3d1f Handle session commit failure with rollback and do not re-raise 2026-02-14 23:44:23 +03:00
Vladless 534313ce12 admins do not touch the shadow ban 2026-02-14 22:42:17 +03:00
Vladless 586ff2e895 admins do not touch the shadow ban 2026-02-14 22:27:52 +03:00
Vladless 3f62af27b7 indempotence of cash registers/ fixed regime of countries 2026-02-14 22:11:45 +03:00
Vladless bf01c6bcfc chande modules logic 2026-02-14 17:03:19 +03:00
Vladless ec8adb0907 add logs in session middleware 2026-02-14 14:06:05 +03:00
Vladless eafe0c56af working on asynchronous behavior 2026-02-14 13:10:31 +03:00
Vladless b09a71aeaf update api 2026-02-14 13:02:59 +03:00
Vladless dd6ce9ff1d commit in payments 2026-02-13 22:00:12 +03:00
Vladless 90291798eb fix int yokassa 2026-02-13 21:32:28 +03:00
Vladislav Lisitsyn 1d42ab1f04 5.1-Release
5.1-Release
2026-02-12 21:58:58 +03:00
Vladislav Lisitsyn 74b991252a Merge branch 'main' into dev 2026-02-12 21:51:59 +03:00
Vladless f6b3c31d5a up bot version 2026-02-12 21:48:36 +03:00
Vladless 6324426b27 add support for composite callbacks 2026-02-12 20:58:36 +03:00
Capybara-z b58dc6ab82 fix middleware registration 2026-02-12 20:19:19 +03:00
Vladless fc130e86f9 logs in session 2026-02-12 20:00:25 +03:00
Vladless bb63732e91 fix Session in the Sales registers 2026-02-12 15:12:06 +03:00
Vladless a96717bf2a Correction of settings 2026-02-12 01:17:18 +03:00
Vladless a198d1bce0 yookassa receipt 2026-02-12 01:02:47 +03:00
Vladless 2041e66956 database optimization/ back button in the configurator/ query competition and more 2026-02-12 00:53:38 +03:00
Vladless 2f1d8ff9e5 short logs 2026-02-11 20:44:02 +03:00
Vladless 2a68f8e36c fixing a lot of custom emojis 2026-02-11 19:54:17 +03:00
Vladless 472a099a8f ruff format 2026-02-11 03:40:01 +03:00
Vladless 3ca8c0f960 partner api update 2026-02-11 00:28:29 +03:00
Vladless 13fd27aa96 partner color disable 2026-02-10 23:17:00 +03:00
Vladless cc57c8610b custom emoji in inline buttons/ CLI 0.3.9/ updated dependencies/ other minor changes 2026-02-10 22:35:39 +03:00
Vladislav Lisitsyn 3f8db29405 Merge pull request #258 from JustYay/dev
[Feature] WebAPP Open in browser option
2026-02-10 12:12:11 +03:00
JustYay bb8b07c318 webapp open in browser option 2026-02-10 16:51:39 +10:00
Capybara-z 0aa4cf0996 restrict moderator access 2026-02-10 01:51:37 +03:00
Vladless 355f210b8c partner api extension 2026-02-10 01:42:25 +03:00
Vladless b290fc6382 yukassa improvements 2026-02-10 01:32:16 +03:00
Vladless 0cc39b4d7f fixing unresolved characters in the payment response 2026-02-10 01:28:59 +03:00
Vladless 3c00f0cc10 custom emojis/ session optimization/ colored inline buttons 2026-02-10 00:57:30 +03:00
Capybara-z b9c0701fe6 refactor buttons.py layout 2026-02-08 23:54:05 +03:00
Capybara-z 0fd8948870 fix admin key display timezone 2026-02-08 23:10:50 +03:00
Vladless 833721bd60 API 0.5.1/ Minor changes and improvements 2026-02-08 22:30:05 +03:00
Capybara-z 8693115571 fix preserve device/traffic limits on coupon 2026-02-07 22:48:30 +03:00
Capybara-z 7f6f00286c fix improve trial bonus timing / add coupon button toggle 2026-02-07 17:38:15 +03:00
Vladislav Lisitsyn 5556bfc6c7 Merge pull request #257 from JustYay/dev
[FEAUTURE] Изменения прорядка касс через админ-панель
2026-02-07 01:52:09 +03:00
Boris Kovalskii 14b97a9c3d Providers order to admin 2026-02-07 08:36:03 +10:00
Boris Kovalskii 4372e651b9 Provider order to core 2026-02-07 08:34:16 +10:00
Boris Kovalskii 1f47f723d0 Add providers order 2026-02-07 08:33:24 +10:00
Capybara-z e4b79d4410 fix: InaccessibleMessage middleware / bulk restore_trials / auto-renew reset addons 2026-02-06 22:17:52 +03:00
Vladless 0873cdb16d avoid 204 responses for non-telegram webhooks 2026-02-06 00:17:24 +03:00
Vladless 0265e9cb50 all webhooks for middlewares 2026-02-05 22:04:49 +03:00
Vladless 3e1f78f94c webhook_guard 2026-02-05 20:49:49 +03:00
hteppl 81d44d9f7c Optimize IsAdminFilter and prevent checking empty from_user field 2026-02-05 17:34:58 +03:00
Capybara-z 62cde5d6d5 fixing a critical rights vulnerability 2026-02-05 16:58:55 +03:00
Vladless 22d82c9199 fixing a critical rights vulnerability 2026-02-05 16:46:45 +03:00
Capybara-z 84e9637453 fix tariff-server binding 2026-02-04 23:59:18 +03:00
Vladless db16c0d49c interest coupons/ picture for rates/ small improvements 2026-02-04 23:45:34 +03:00
Vladless a967352e9a subscriptions in order 2026-02-04 00:00:50 +03:00
Vladless 93acfa3af9 correcting the display of devices in the configurator 2026-02-01 14:14:24 +03:00
Vladless 8dca92b5d9 the launch script has been changed 2026-02-01 13:10:07 +03:00
Vladless 08f0ab2846 The coupon field has been changed 2026-02-01 12:36:25 +03:00
Vladless 6477afd00f Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More 2026-02-01 09:58:58 +03:00
Vladless 6478495e6e Only SBP for Yookassa/ Pagination in the 'My Subscriptions' menu 2026-01-28 00:07:00 +03:00
Vladless a60405e4f4 fix for invalid characters when creating a yookassa payment 2026-01-25 20:48:17 +03:00
Vladislav Lisitsyn f2a9f81ddf Merge pull request #256 from JustYay/dev
HOTFIX "Консистеность касс V2"
2026-01-25 17:09:24 +03:00
Boris Kovalskii 80bf976fe5 FIX "KassaI && Heleket" 2026-01-25 14:48:35 +10:00
Boris Kovalskii 1b2de84f2d HOTFIX "Консистентность касс"
- исправление edit_or_send_message в кассах
- добавление ожидаемого платежа
- исправление heleket (мусорил в базе)
2026-01-25 14:43:40 +10:00
Vladless 858968665b Optimize user activity updates and fix traffic top-up 2026-01-23 04:22:59 +03:00
Vladless af2d6507da probe and logging changes 2026-01-22 01:24:42 +03:00
Capybara-z 786e2d2288 fix addons config layout / fix selection handling 2026-01-22 00:04:43 +03:00
Vladless 12c1d5badb Added a picture to the renewal menu/ installing module dependencies/ changing the 'my gifts' menu 2026-01-21 23:23:12 +03:00
Vladless 2eeccf99ee fix tribute convertation 2026-01-21 20:47:41 +03:00
Vladless 3eb3fd229e fix delete key and incorrect debit of the balance 2026-01-20 22:58:40 +03:00
Capybara-z d88d1d9482 Add bulk cluster extend / HWID reset on device limit downgrade 2026-01-16 01:06:39 +03:00
Vladless abd0315244 fixing the external squad when reissuing a subscription 2026-01-15 14:27:23 +03:00
Capybara-z 80dc53f272 fix: tariff server binding, VLESS links from API, zero traffic logic 2026-01-14 21:29:07 +03:00
Capybara-z 218a5b544d Add API for managing tariff configurator / Improved version 0.4.0 (Alpha) 2026-01-10 21:11:16 +03:00
Capybara-z 2e25957855 Fix special notifications 2026-01-10 17:05:39 +03:00
Capybara-z f60a5b7506 Refactor special notifications / Refactor server tariff bindings 2026-01-08 23:18:33 +03:00
Capybara-z 316b6359fc Fix delete delay config 2026-01-06 17:26:09 +03:00
Capybara-z a6ed138c09 Notifications refactor / Admin gifts management / Admin menu user improvements / Fix revoke subscription 2026-01-06 16:30:25 +03:00
Capybara-z 9c8b2c8c50 fix use correct tariff price on key renewal / fix auto-renewal notification logic 2026-01-02 15:05:04 +03:00
Vladless 457048579a Fix node online count 2025-12-27 04:10:56 +03:00
Vladless 78afc78ab7 import remnawave users with tg_id parsing from username 2025-12-27 03:19:48 +03:00
Capybara-z e187d1167b add tariff group to key info 2025-12-26 22:26:32 +03:00
Vladless 35e9a6d104 changing the tariff through the admin panel without recreating it 2025-12-26 20:23:50 +03:00
Vladless f32ebc95cd Export user payments to CSV 2025-12-26 18:53:23 +03:00
Vladless 79ddde90ca CLI 0.3.8/update yookassa SDK 2025-12-26 18:22:30 +03:00
Vladless adfadcf127 CLI 0.3.8/update yookassa SDK 2025-12-26 18:16:11 +03:00
Capybara-z 8a0b9b7c98 fix traffic in key view 2025-12-26 16:48:20 +03:00
Capybara-z 9bec6fe341 add configurable backup archive 2025-12-25 00:08:11 +03:00
Vladless ab39653bab fix unlim traffic 2025-12-24 20:17:00 +03:00
Vladless a7e3f302d6 fixed option reset when extending 2025-12-23 21:45:50 +03:00
Vladless 38a8fe251a fix TelegramBadRequest in traffic pricing and null in configurator. Extended description of billing in the settings 2025-12-23 14:41:05 +03:00
Vladless 03763a79f6 bug fixes 2025-12-22 22:09:33 +03:00
Capybara-z bdbd262fce Fix subscription deletion in notifications 2025-12-22 13:44:32 +03:00
Vladless ea87cc0393 fix: overrides in addons pack/ mine in the balance after purchase/ import remnawave 2025-12-22 00:36:08 +03:00
Capybara-z 5f40605e9f fix tariff overrides and renewal limits 2025-12-21 23:31:57 +03:00
Capybara-z 86836da169 refact sync 2025-12-21 16:36:18 +03:00
Capybara-z 92e2e8cb3f Add discount tariff availability checks 2025-12-21 00:07:48 +03:00
Capybara-z f6ca1bda22 fix key config limits on expiry change 2025-12-18 00:16:57 +03:00
Vladless 2025329e5f recalculation during purchase 2025-12-15 23:46:03 +03:00
Vladless e71a20cd3e fixed balance write-off in gifts/ added settings in api 2025-12-15 20:18:19 +03:00
Vladless 20c9b36c26 minor fixes 2025-12-14 22:56:33 +03:00
Capybara-z 5c2bde726c Add revoke subs Remnawave 2025-12-14 22:42:51 +03:00
Capybara-z 7937bceb9c Add configurable webhook response for KassaAI 2025-12-14 21:45:04 +03:00
Capybara-z 5aa762aaf1 add key configuration management 2025-12-14 18:51:00 +03:00
Vladless 3d2269a368 add start_entry hook 2025-12-14 18:36:44 +03:00
Vladless 4f5878bc4e users cache 2025-12-11 16:41:49 +03:00
Vladless 851e452292 All middlewares now work through a single DB session 2025-12-11 15:49:38 +03:00
Vladless 985ae18bfb new version 2025-12-10 20:28:07 +03:00
Vladless 9c999e7220 Cli 0.3.7/ Warning 2025-12-10 16:32:32 +03:00
Vladless 877c005a6f country_mode in database/ payment options 2025-12-10 12:44:27 +03:00
Vladless 1b1780df55 migration between external squads/ make format 2025-12-10 07:34:48 +03:00
Vladless 9bacc0c8e3 add support remnawave 2.3.x 2025-12-09 23:59:31 +03:00
Capybara-z 01dea3cccf fix renew hwid configurator 2025-12-09 21:03:17 +03:00
Vladless 748adc6697 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-08 10:40:32 +03:00
Vladless 919335d858 normalize_expiry_ms 2025-12-08 10:39:14 +03:00
Capybara-z ab4418b503 Tariff configurator for discount tariffs 2025-12-08 01:59:35 +03:00
Vladless 654b20c18f multiple fixes 2025-12-07 23:01:21 +03:00
Vladless 8183c2b678 optimization of the start command 2025-12-07 00:32:43 +03:00
Vladislav Lisitsyn ccacc98b0e Merge pull request #254 from JustYay/dev
FIX "HWID Limit"
2025-12-06 03:32:19 +03:00
Boris Kovalskii 0d11a9b645 FIX "HWID Limit" 2025-12-06 10:27:33 +10:00
Vladless 6a29a7acd4 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-06 01:54:57 +03:00
Vladless ea91d1f7b0 gift rate configurator 2025-12-06 01:54:05 +03:00
Capybara-z ef59eeac4c Refactor admin key display logic 2025-12-04 00:49:01 +03:00
Capybara-z 26da97187c Refactor sender button parsing / Add tariff hooks 2025-12-03 02:26:17 +03:00
Vladless 48ebdeca0f fix tariffs in country mode 2025-12-01 23:13:55 +03:00
Vladless 2b1018d7d3 changing the logic of the fare selection button 2025-12-01 22:06:55 +03:00
Vladislav Lisitsyn 317d905a2a Merge pull request #253 from JustYay/dev
FIX "Создание лимита трафика"
2025-12-01 13:21:56 +03:00
Boris Kovalskii 610ccdd1f9 FIX "Создание лимита трафика"
FIX "Создание лимита трафика" при генерации ключа
2025-12-01 11:44:05 +10:00
Vladless d2288c62a3 fix notification options in settings 2025-11-30 22:43:51 +03:00
Vladless 9f10fed3e7 added support for external squads 2025-11-30 18:37:18 +03:00
Capybara-z 1140a6c050 fix deletion of minute subscriptions 2025-11-30 00:52:01 +03:00
Vladless f2d14d69b0 rate configurator/ purchase of conditions/ fixed payments/ refactoring/ major improvements and fixes 2025-11-29 19:08:01 +03:00
Capybara-z 28cfe91175 fix: show payment statuses in balance operations 2025-11-24 00:06:00 +03:00
Capybara-z 7a985eb9c6 refactor broadcast system with improved speed and flood control 2025-11-20 17:27:54 +03:00
Capybara-z afd99691f6 admin menu hooks 2025-11-18 16:37:25 +03:00
Capybara-z 153be452b0 Fix referral bonus calculation 2025-11-18 10:50:07 +03:00
Vladless 679e65ec44 minor fixes 2025-11-17 00:47:06 +03:00
Capybara-z f44bd9e4b6 Refactor hooks system / Fix country mode special groups / Fix location change filtering 2025-11-16 18:51:59 +03:00
Vladless 0385e15815 freezing subscriptions for admins/ organizing selection in country mode 2025-11-16 05:08:48 +03:00
Vladislav Lisitsyn b109834543 Merge pull request #252 from JustYay/dev
FIX "Консистентный дизайн касс"
2025-11-16 05:04:32 +03:00
Boris Kovalskii 9f353e8b5c FIX "Консистентный дизайн касс"
- FIX "Консистентный дизайн касс" Heleket и Kassai
- Оптимизация касс по PEP8
- Оптимизация функций KassaI
2025-11-16 12:00:19 +10:00
Vladless 8b77ccda9e Adding admin operations to the balance 2025-11-16 03:30:00 +03:00
Capybara-z 1d5c80cd35 fix admin crypto links / fix sender button formatting 2025-11-16 01:15:15 +03:00
Vladless 041e1c898d runtime settings loaded from DB/ refactoring admin-panel/ minor fixes and improvements 2025-11-15 23:14:34 +03:00
Capybara-z 76390df4bc Remove break in hooks / improve ban management 2025-11-14 17:32:41 +03:00
Capybara-z c3941397cf Add hooks extension 2025-11-13 19:10:31 +03:00
Capybara-z 05e8bfece4 fix country mode sync / add cryptolink hook 2025-11-12 16:17:30 +03:00
Vladislav Lisitsyn f6f9fdce18 Merge pull request #251 from TrackLine/dev
Добавление поддержки прямых ссылок на команды
2025-11-12 00:52:35 +03:00
Max Shalenkov 4525769370 fix new urls 2025-11-12 01:50:04 +04:00
Max Shalenkov 60447b41f6 add direct url function 2025-11-11 23:51:06 +04:00
Capybara-z 045fbddecb Merge pull request #250 from TrackLine/dev
remove WATA payments
2025-11-10 22:46:50 +03:00
Max Shalenkov 84d8024d08 remove WATA payments 2025-11-10 23:38:05 +04:00
Capybara-z 617cdbad10 refactor stats menu / fix subscription renewal 2025-11-04 23:55:48 +03:00
Vladless 532f066ffb a small fix for the back button 2025-11-01 23:53:53 +03:00
Capybara-z e29e903efa Fix special tariff notifications 2025-11-01 14:54:27 +03:00
Capybara-z 0c7da4b9fd Fix special tariff notifications 2025-11-01 14:53:15 +03:00
Capybara-z ea6538a487 fix sender banning issues 2025-10-25 18:33:39 +03:00
Capybara-z e1602da083 fix sender banning issues 2025-10-25 18:29:12 +03:00
Vladislav Lisitsyn 96b977adbd v4.4 → v.5 2025-10-25 17:13:19 +03:00
Vladless f7d792963c Resolve conflicts: prefer dev over main 2025-10-25 17:12:56 +03:00
Capybara-z f6eeeb8ea0 fix device limit 2025-10-25 15:29:29 +03:00
Capybara-z 738a72b628 fix text sub 2025-10-25 02:12:31 +03:00
Capybara-z 784e38c345 fix improve server sync filtering / tariff update logic 2025-10-25 02:10:28 +03:00
Capybara-z da44b4e041 Fix Remnawave sync 2025-10-25 00:57:32 +03:00
Vladislav Lisitsyn 12dd8f75fb Revamp README.md with improved formatting and visuals
Updated the README.md to enhance formatting, add images, and improve the overall presentation of SoloBot's features and capabilities.
2025-10-24 18:59:32 +03:00
Vladless 057a71cfce up bot version 2025-10-24 09:47:14 +03:00
Vladless 3c25fb3015 ruff format/ minor fixes and improvements 2025-10-24 09:39:54 +03:00
Capybara-z a573fdc4d4 preserve UUID in Remnawave sync 2025-10-24 02:39:32 +03:00
Capybara-z a6c34beff1 Fix server validation and add key renewal hooks 2025-10-23 21:54:40 +03:00
Capybara-z a256a2f338 fix: add subgroup/special group support to key renewals and cluster sync 2025-10-22 02:07:18 +03:00
Capybara-z e6313bf5a3 Refactor admin panel menu / fix QR code caching 2025-10-21 23:39:15 +03:00
Capybara-z 8fdd2dd3c4 Add router icon / multi-format media support 2025-10-18 00:21:14 +03:00
Vladless 7090daefc5 fix change locations/ delete update_subscription_button/ refactoring the deletion function 2025-10-17 22:23:16 +03:00
Vladislav Lisitsyn d1e739c029 Merge pull request #246 from JustYay/dev 2025-10-17 04:52:02 +03:00
Boris Kovalskii 92c2a1f2c7 FIX "Новые вебхуки" 2025-10-16 23:42:35 +00:00
Boris Kovalskii 0ca56d0967 Add files via upload 2025-10-16 23:41:47 +00:00
Boris Kovalskii 99bd6ce42f Delete web/kassai_payment.py 2025-10-17 09:41:27 +10:00
Boris Kovalskii d012d7a9c4 Delete web/heleket_payment.py 2025-10-17 09:41:17 +10:00
Vladislav Lisitsyn 82fc4a9b33 Merge pull request #245 from JustYay/dev
FIX "Сообщение о минимальном балансе"
2025-10-14 01:06:12 +03:00
Vladless 5a769f8972 optimization of database transactions/fixing middlewares/ cache of images 2025-10-14 01:05:52 +03:00
Boris Kovalskii a1e7db2ac5 FIX "Сообщение о минимальном балансе" 2025-10-13 21:52:01 +00:00
Capybara-z a1347dff04 fix notifications 2025-10-13 19:04:52 +03:00
Vladless 5a4df05b7b aggregated links for update key/ fix router button for country mode 2025-10-13 17:43:53 +03:00
Capybara-z eca7009a55 fix notification message handling 2025-10-13 10:11:26 +03:00
Vladless bdab2360c3 fix country_mode 2025-10-12 21:43:21 +03:00
Vladless 8d80360615 logging_level/ fix subgroup migrations and more 2025-10-12 20:59:18 +03:00
Vladless 50c73821a6 adding servers for special groups/ ruff formatting 2025-10-11 01:27:10 +03:00
Capybara-z f1a5465e36 refactor notifications with configurable timing / native crypto link handling / referral cache optimization 2025-10-10 23:56:34 +03:00
Oleg Kraev 7938a0dc4f Merge pull request #244 from JustYay/dev
FIX "message is not modified"
2025-10-10 08:24:04 +03:00
Boris Kovalskii 24c7adc73c FIX "message is not modified" 2025-10-10 05:19:35 +00:00
Vladless 42f1e81436 changed extension logic / fix: images when creating and back buttons 2025-10-10 03:36:19 +03:00
Vladless 66900c284c Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-10-09 18:37:09 +03:00
Vladislav Lisitsyn 4aa764a126 Merge pull request #242 from JustYay/dev
FIX "Добавление и ответы бота чатах"
2025-10-09 18:28:44 +03:00
Capybara-z 2e866103df add auto-blocking for failed broadcasts and improve send speed 2025-10-09 17:17:21 +03:00
Boris Kovalskii 3d7c76b113 FIX "Добавление и ответы бота чатах"
FIX "Добавление и ответы бота чатах" при обязательной подписке:

Теперь бот будет:
- Проверять подписку только в личных сообщениях
- Игнорировать все сообщения из групповых чатов
- Игнорировать анонимные сообщения от имени сообщества
- Игнорировать сообщения от других ботов
2025-10-09 03:16:11 +00:00
Vladislav Lisitsyn 15e9982530 Merge pull request #241 from JustYay/dev
FIX "Adding a chat for a mandatory subscription"
2025-10-08 18:56:38 +03:00
Vladless 199ea9f153 add pagination for modules menu 2025-10-07 23:19:16 +03:00
Vladless 32a2ea8d13 add logic for back button in payments 2025-10-07 14:45:18 +03:00
Vladless 5d73fd4960 fix Tribute router 2025-10-07 14:07:13 +03:00
Vladless 8f3882819a fix Tribute flow 2025-10-07 13:48:48 +03:00
Boris Kovalskii ebf1b22094 FIX "Добавление чата для обязательной подписки" 2025-10-07 00:12:21 +00:00
Vladless b09bde32e4 Module management/ TRIBUTE webapp/ small gift fix/ backup selection in CLI 2025-10-07 02:48:34 +03:00
Vladless 23e01da0fd fix connection button for router tariffs 2025-10-06 17:37:33 +03:00
Vladless d127624121 adding a fixed exchange rate for the dollar to the ruble/fixing server failures 2025-10-05 13:32:11 +03:00
Capybara-z d073e1a3bd Add hook system for tariff group management 2025-10-04 23:18:27 +03:00
Capybara-z 706d81f4bc fix restore trials logic 2025-10-02 16:02:00 +03:00
Capybara-z f43954cd77 fix encode happ crypt links all versions 2025-10-02 10:45:44 +03:00
Capybara-z dbb2395639 fix encode happ crypt3 links 2025-10-02 01:08:23 +03:00
Vladless e16f5a50ee update issue template 2025-10-01 14:12:26 +03:00
Vladless e4641a0e86 fix issue template format 2025-10-01 14:12:14 +03:00
Vladless 478a6ecd8b add issue template 2025-10-01 14:06:24 +03:00
Vladless 12a615b5a8 add issue template 2025-10-01 14:05:27 +03:00
Vladless 6a3def212c contributing guidelines / code of conduct / security policy / pr template 2025-10-01 13:57:44 +03:00
Vladless b829d32236 contributing guidelines / code of conduct / security policy / issue template / pr template 2025-10-01 13:31:25 +03:00
Vladislav Lisitsyn 52714536e3 Create CONTRIBUTING.md 2025-10-01 13:02:27 +03:00
Vladless ebdd7e124c fix happ cryptolink for remnawave webapp 2025-10-01 12:24:43 +03:00
Vladless be74a7cadc ruff formatting 2025-09-30 19:19:05 +03:00
Vladless 7a533dd0da link generator/ router and vless support/ selective server billing/ traffic usage counting/ cosmetic improvements and bug fixes 2025-09-30 19:17:05 +03:00
Vladless b5d13beb96 fix stars peyment status/ remove literal for schemas 2025-09-24 00:55:24 +03:00
Vladless e01e7b4d1e add pending for api payments 2025-09-24 00:22:13 +03:00
Vladless d1837bd414 minor fixes/api for payments 2025-09-23 22:41:11 +03:00
Vladislav Lisitsyn 04365dd335 Merge pull request #239 from JustYay/dev
FIX "Приведение касс к одному стилю"
2025-09-21 14:24:54 +03:00
Boris Kovalskii 1b6b2b2956 FIX "Отобжражение в handlers касс" 2025-09-21 04:17:06 +00:00
Boris Kovalskii 353545ce8d FIX "Приведение касс к одному стилю"
- Теперь создаем клавиатуры методами из keyboards
- Язык пользователя берется из БД
2025-09-21 04:10:10 +00:00
Capybara-z 20f83d1a82 fix extend hooks for key creation 2025-09-19 23:55:22 +03:00
Capybara-z c424c41048 extend hooks for key creation 2025-09-19 12:13:04 +03:00
Vladless 81751093d9 fix middlewares, gifts and more 2025-09-19 02:47:40 +03:00
Capybara-z b68b539c68 add extend keyboard hooks / refactor hot leads texts / fix hot leads notification logic 2025-09-18 21:23:58 +03:00
Vladless 17b9e92809 minor fixes for start menu 2025-09-16 23:34:48 +03:00
Capybara-z 4b8bea65be Add hook integration: cluster balancing, subscription routing, tariff menu 2025-09-16 22:09:32 +03:00
Vladislav Lisitsyn c1e26ca1ce Merge pull request #238 from JustYay/dev
FIX "Выбор касс и кнопка возврата"
2025-09-16 21:23:06 +03:00
Boris Kovalskii 425ba29e12 FIX "Кнопка back_to_pay"
Не работала кнопка возврата, для восстановления функцицональности сделано временное решение - возврат на balance
2025-09-16 12:43:30 +00:00
Boris Kovalskii 7cc6ddb4ef FIX "Активные платежки" 2025-09-16 12:38:02 +00:00
Vladislav Lisitsyn ea7310bcb8 Merge pull request #237 from JustYay/dev
Fix Currency rate and Heleket payment
2025-09-16 02:30:34 +03:00
Boris Kovalskii 284d79dd67 FIX "Комментарии 5" 2025-09-15 23:27:23 +00:00
Boris Kovalskii 412a31b938 FIX "Modules в Providers"
Теперь мы можем использовать modules для каждого провайдера, если несколько методов в одной платежке
2025-09-15 23:25:42 +00:00
Boris Kovalskii 1a6450284d FIX "Комментарии 3" 2025-09-15 23:04:27 +00:00
Boris Kovalskii 5e6333d08e Delete handlers/payments/kassai/kassai.py 2025-09-16 08:58:50 +10:00
Boris Kovalskii 3a23883e46 Delete handlers/payments/heleket/heleket.py 2025-09-16 08:58:33 +10:00
Boris Kovalskii b444367887 FIX "Комментарии 2" 2025-09-15 22:57:00 +00:00
Boris Kovalskii 752292cb4a FIX "Приведение Касс к Стандарту бота"
Heleket и KassaAI
2025-09-15 22:53:48 +00:00
Boris Kovalskii b0482cca1a FIX "Добавление быстрого флоу для кастомных касс" 2025-09-15 22:13:57 +00:00
Boris Kovalskii 4e1d7e51a9 FIX "Вернул роутер" 2025-09-15 21:53:14 +00:00
Boris Kovalskii e19e00aa04 FIX "Комментарии" 2025-09-15 21:46:42 +00:00
Boris Kovalskii 45041982d5 FIX "Двойное предложение выбрать Heleket"
Убрал один роутер, и выход сразу на страницу пополнения баланса
2025-09-15 21:45:06 +00:00
Boris Kovalskii c4bafc6c1d FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:43:10 +00:00
Boris Kovalskii 5baf44640d FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:39:07 +00:00
Vladless d6b920ff4d currency/fast flow for everyone/optimization and cache/new payment system/and more 2025-09-15 21:55:31 +03:00
Vladless 6d9e1aba4a Update cli for backup after update 2025-09-11 22:38:47 +03:00
Capybara-z 840e5e4c53 Fix traffic display on iPhones in subscription 2025-09-10 11:33:20 +03:00
Capybara-z e16580857f Fix traffic notifications timing and remove async hooks from gifts/coupons 2025-09-05 21:59:47 +03:00
Capybara-z fc3871fd8e add hooks integration for coupons and gifts keyboard 2025-09-05 18:06:00 +03:00
Vladless b1f0462aab add hooks for about_menu 2025-09-04 10:54:20 +03:00
Vladless 417089cef4 bot version 2025-09-03 23:15:39 +03:00
Capybara-z c08072a87b auto-initialize tariff weights on bot startup 2025-09-03 00:36:11 +03:00
Vladless a10316d215 bug fixes: silent_mode/ cash register indempotence/ antispam for key_country/ ban improvements 2025-09-02 23:38:52 +03:00
Capybara-z 4d3d23ac58 Fix device limit handling in subscription renewal 2025-09-01 21:38:19 +03:00
Capybara-z 9074876e3d Fixed hot leads export count mismatch \ Fixed ban message formatting for popup alerts \ Fixed user deletion back button navigation 2025-09-01 15:36:02 +03:00
Vladless 1487fb2858 add hook for user_info in admin-panel 2025-08-29 11:57:11 +03:00
Capybara-z 16d031d90e fix hooks 2025-08-29 10:35:12 +03:00
Capybara-z 1afa57bff4 fix text hot lead 2025-08-29 00:33:09 +03:00
Capybara-z 05c591336b Improved hot lead logic 2025-08-29 00:06:47 +03:00
Capybara-z cd9b92d036 Removed tblocker / Fixed trial subscriptions counting 2025-08-26 18:26:35 +03:00
Vladless 344a24a669 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-25 02:10:03 +03:00
Vladless a5883e9057 add support for franchise/ minor fixes 2025-08-25 02:08:18 +03:00
Vladislav Lisitsyn e292accccb Merge pull request #235 from TrackLine/dev
fix hook:view_key_menu
2025-08-24 21:50:27 +03:00
Vladislav Lisitsyn ed5426dd71 Merge pull request #236 from myr1001/dev
fix notifications for notifications module
2025-08-24 21:50:04 +03:00
Capybara-z 9b5ab83b1f Expanding the work of hooks 2025-08-24 00:13:40 +03:00
Capybara-z 4795107178 fix instructions 2025-08-23 14:18:35 +03:00
myr1001 379944200b Update kassai_payment.py
fix notofications for notifications module
2025-08-22 15:19:39 +03:00
myr1001 cb8453f415 Update wata_payment.py
fix notifications for notifications module
2025-08-22 15:18:42 +03:00
Capybara-z 69f86fe821 add module hooks to replace profile text / implement interception of gifts via hooks / add dynamic control of buttons in key interfaces / add insert_at button ordering via hooks 2025-08-21 17:00:03 +03:00
Max Shalenkov 099be68d29 fix hook:view_key_menu 2025-08-20 23:11:28 +04:00
Vladless 7ab58d4461 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-19 01:02:24 +03:00
Vladless 9bb95e8189 fixed auto-installation of dependencies 2025-08-18 23:45:55 +03:00
Capybara-z a257e61349 Fix hot leads logic / Add tariff sorting functionality 2025-08-18 21:50:02 +03:00
Vladless 44d6fe87dd minor fixes: utm_api/donations and module payments/users info in admin 2025-08-18 15:55:45 +03:00
Vladislav Lisitsyn b01eabdcda Merge pull request #234 from Vladless/feat/callback-answer
Callback answer middleware
2025-08-18 15:55:24 +03:00
Capybara-z 2a3f346434 add user message hook system / fix SSL certificate validation 2025-08-15 15:44:07 +03:00
Vladislav Lisitsyn 8597eed42d Merge pull request #233 from TrackLine/dev
Add hooks in start menu
2025-08-14 22:05:21 +03:00
Max Shalenkov 070773d28d remove comment 2025-08-14 23:03:30 +04:00
Max Shalenkov e2a6712382 add hooks in start menu 2025-08-14 23:01:24 +04:00
Capybara-z 4ab4eedd04 fix ssl check 2025-08-13 20:43:29 +03:00
Vladislav Lisitsyn e2e73e130e Merge pull request #232 from TrackLine/dev
Add new hooks for modules
2025-08-13 14:59:41 +03:00
Max Shalenkov fa1317a5fb fix imports 2025-08-13 15:58:45 +04:00
Max Shalenkov cb0a155597 add delete hook button; add hook for periodic notifications 2025-08-13 15:52:36 +04:00
Vladislav Lisitsyn 00f24acc4e Merge pull request #231 from TrackLine/dev
add hook in key_view
2025-08-12 21:52:59 +03:00
Max Shalenkov f5b4ab65ea add hook in key_view 2025-08-12 22:51:26 +04:00
Vladless b61c3daca8 Tribute/ Robokassa 2025-08-12 01:19:36 +03:00
hteppl 83d93a833d Fix topups was None in admin panel if payments doesn't exist 2025-08-11 08:56:21 +03:00
Vladless b999e4aced robokassa nomenclature/ minor fixes for API 2025-08-10 23:06:26 +03:00
Vladless 214c9f0c65 fix gifts country_mode tariffs 2025-08-07 17:41:36 +03:00
Vladless 31545a4225 fix gifts/custom amount for wata/change domain 2025-08-07 17:39:25 +03:00
Capybara-z 1441a16474 Add support for streaming modules / Add mailing for trial subscriptions 2025-08-06 23:07:27 +03:00
Capybara-z cec91b711f Add webhook support for modules / Implement hooks for payment keyboard 2025-08-04 20:31:28 +03:00
Vladless f87da6db7a silent mode/ wata flow/ api tracking/ refactoring 2025-08-02 21:13:50 +03:00
Vladless f02a6358e9 minor fixes for gifts_menu 2025-08-02 01:20:46 +03:00
Vladislav Lisitsyn 20c1bd6a75 Merge pull request #230 from TrackLine/dev
fix rates for wata payments
2025-08-01 23:02:31 +03:00
Vladless 254620774e fix delete modules 2025-08-01 22:41:23 +03:00
Capybara-z 3078089044 Location of module buttons 2025-08-01 22:37:37 +03:00
Vladless 9c67c401b9 the idempotence fix 2025-08-01 22:22:09 +03:00
Vladless af79f511e5 module architecture/ delete tg_id in gifts 2025-08-01 22:16:26 +03:00
Max Shalenkov b20ec194c5 fix wata 2025-08-01 19:57:35 +04:00
Max Shalenkov adfb556259 fix rates for wata payments 2025-08-01 19:56:13 +04:00
Capybara-z e9ff8d851e fix for days accrual in cluster 2025-08-01 15:19:26 +03:00
Vladless ac258cda37 module architecture/ moderator restrictions 2025-08-01 01:56:56 +03:00
Vladless 5fc4ce8930 module architecture/ formatting 2025-07-31 23:42:45 +03:00
Vladless 3aa7b1edc5 support for remnawave api 2.x 2025-07-30 20:02:40 +03:00
Vladless 10eca6c6ac minor fixes 2025-07-30 09:49:56 +03:00
Vladislav Lisitsyn b59b3f0e53 Merge pull request #228 from JustYay/dev
minor fix KassaAI
2025-07-30 09:45:46 +03:00
Boris Kovalskii 290ef28a7f minor fix KassaAI 2025-07-30 16:43:55 +10:00
Vladless 66bb7c8d33 minor fixes 2025-07-30 09:23:28 +03:00
Vladless b947a48ca2 zero-day fixes 2025-07-30 06:34:27 +03:00
Oleg Kraev 8dde5acbbc Merge pull request #226 from Vladless/feature/more-user-info
More user info
2025-07-30 00:27:16 +03:00
hteppl 760aab75cd Implement users topups amount in admin user profile 2025-07-30 00:25:00 +03:00
hteppl a6a77238f7 Remove uv.lock due to project currently is not supporting uv 2025-07-29 22:50:06 +03:00
Capybara-z f75b5e8312 refactor: migrate trial subscription to database-driven tariffs 2025-07-29 20:50:43 +03:00
Vladislav Lisitsyn 39701b1f55 Update README.md 2025-07-29 19:53:06 +03:00
Vladless 5f69a36077 formatting 2025-07-29 19:45:13 +03:00
Vladless 2556d3842b up bot version 2025-07-29 19:34:49 +03:00
Vladless ffec36fa54 antispam for create_key 2025-07-29 17:17:15 +03:00
Vladless da6815c5f1 fix gifts tariffs and format days 2025-07-28 20:33:50 +03:00
Capybara-z 76c0c468f5 fix: mass sender broadcasting / tariff group display in admin / correct device limit handling / configurable referral QR button / navigation fixes / fixed traffic notifications / fixed country shuffling 2025-07-27 21:34:04 +03:00
Capybara-z 357c8a01e7 Merge pull request #223 from JustYay/dev
Heleket, Wata, KassaAI fix
2025-07-26 13:50:46 +03:00
Boris Kovalskii 595c312e2b Heleket, Wata, KassaAI fix
Fix, when the payment is lower, than acceptable sum by merchant
2025-07-26 17:03:42 +10:00
Vladless 28435d2943 stars bot url for texts 2025-07-26 00:22:16 +03:00
Vladless 171634522e support invite links/ shadow bans/ bulk preemptive bans 2025-07-25 05:40:45 +03:00
Vladless 424cb50942 fix yoomoney router 2025-07-24 23:40:22 +03:00
Vladislav Lisitsyn 15ce453523 Merge pull request #222 from JustYay/dev
Crypto-payment via Heleket
2025-07-24 23:31:46 +03:00
Boris Kovalskii cfe8b2edcd Crypto payment via Heleket 2025-07-24 23:14:36 +10:00
Boris Kovalskii 3b64b28b86 we dont send new message 2025-07-24 20:25:01 +10:00
Boris Kovalskii 918872e4b4 Force text fix 2025-07-24 20:16:08 +10:00
Capybara-z f21b49d5fd Merge pull request #220 from JustYay/dev
Fix paymentid and success and fail urls
2025-07-24 13:03:43 +03:00
Boris Kovalskii 2bf2d98c88 Fix for old users 2025-07-24 20:01:55 +10:00
Boris Kovalskii 63cc967dfc Fix paymentid and success and fail urls 2025-07-24 19:10:14 +10:00
Vladislav Lisitsyn 9bea1d6a6c Merge pull request #219 from JustYay/dev
Back callback fixes
2025-07-24 07:27:56 +03:00
Boris Kovalskii 97234d8835 Security bug fix
Fix duplicate webhook accept
2025-07-24 13:53:33 +10:00
Boris Kovalskii c5e8917043 Custom amount and deletion fixes 2025-07-24 13:34:07 +10:00
Boris Kovalskii b39ae1a1ee Small fixes 2025-07-24 13:27:28 +10:00
Boris Kovalskii d5190ea07e Clear code 2025-07-24 13:24:21 +10:00
Boris Kovalskii 7f35ec0745 Delete message about payment from Kassa AI
Delete message about payment from Kassa AI after success payment, if the message is available
2025-07-24 13:22:45 +10:00
Boris Kovalskii 16b22101af Back callback fixes
Убрано лишнее меню из касс, при возвращении, сейчас возвращает сразу на вкладку balance
2025-07-24 12:53:49 +10:00
Vladislav Lisitsyn ccdb490b84 Merge pull request #218 from JustYay/dev
Kassa AI
2025-07-24 02:58:53 +03:00
Boris Kovalskii c12e61fb41 Clear code 2025-07-24 09:42:23 +10:00
Boris Kovalskii c877aef711 Kassa AI 2025-07-24 03:10:15 +10:00
Vladless a174b7bd81 save alias after update_sub/db pool 2025-07-21 23:03:03 +03:00
Vladless 43f4657089 minor fixes for install dependencies 2025-07-21 19:38:59 +03:00
Capybara-z 50d2d5f716 Fixed key display in admin panel / Fixed earnings calculation in statistics 2025-07-21 19:29:48 +03:00
Capybara-z 34618a3de1 fix: device limit transmission in admin key creation 2025-07-20 20:17:21 +03:00
Vladless 1b930460ca Animation of the CLI 2025-07-20 06:34:58 +03:00
Vladless fe439cd31d CLI 0.2.7/ fixed permissions 2025-07-20 03:18:51 +03:00
Vladless 81733b041a solobot command for different copies/fixes command installation errors 2025-07-20 03:05:04 +03:00
Vladless 7ac599aa18 importer 3x-ui/CLI update/upload file/clearing logs 2025-07-20 02:12:26 +03:00
Capybara-z 279409600b feat: add update time and refresh button to ads cards 2025-07-20 00:02:53 +03:00
Capybara-z d701ce31c8 fix: tariff selection in admin panel and key operation 2025-07-19 23:16:46 +03:00
Capybara-z 315459ed2e fix: key traffic display 2025-07-19 18:16:02 +03:00
Vladless 0dd3641151 bot version up 2025-07-19 01:29:09 +03:00
Vladless c610fb118e formatting/3x-ui db import/cosmetic fixes 2025-07-19 01:14:42 +03:00
Capybara-z 8f471a1e43 Fix statistics admin 2025-07-19 00:52:20 +03:00
Vladless c7b2f15131 fix state in middleware subs 2025-07-18 20:52:56 +03:00
Vladless 12079cac34 fix sub channel 2025-07-18 20:46:02 +03:00
Vladless 96b283c6cb api 0.2.0/ minor fixes 2025-07-18 20:13:39 +03:00
Capybara-z aeee2ec352 fix: broadcast callbacks and button parsing 2025-07-18 16:05:48 +03:00
Vladless 19c0db3690 stars bot link/ defolt hwid limit 0 2025-07-18 04:32:09 +03:00
Vladless ede74ef7af fix time and text/ cosmetic fixes/ description of the payment/ vat_code 2025-07-18 02:07:30 +03:00
Vladislav Lisitsyn 4607c81e7f Merge pull request #217 from TrackLine/dev
Add wata.pro payments
2025-07-17 20:45:20 +03:00
Max Shalenkov cc70ac8f14 remove comments 2025-07-16 22:49:58 +00:00
Max Shalenkov f7eb475292 final fix wata.pro 2025-07-16 22:22:40 +00:00
Max Shalenkov 38da3cca91 fix wata 2025-07-16 23:59:33 +03:00
Max Shalenkov bf1e454f0c Merge branch 'Vladless:dev' into dev 2025-07-16 23:17:46 +03:00
Vladless 36ea55a10d add change domain for remnawave link 2025-07-16 22:08:20 +03:00
Max Shalenkov 9ee2f0addf change payments 2025-07-16 19:49:49 +03:00
Capybara-z 52aa6f81e2 fix Improve server limits and disabled servers handling 2025-07-14 22:21:04 +03:00
Capybara-z b1c53d5fad Improve server limits and disabled servers handling 2025-07-13 00:52:49 +03:00
Capybara-z 52d5f784a2 Show all inbound nodes with country flags in Remnawave 2025-07-12 02:24:29 +03:00
Capybara-z cafe576478 Fix admin management 2025-07-11 19:27:36 +03:00
Capybara-z 9427cb20d9 Improve cluster tariff error handling / Fix hot leads filtering logic 2025-07-11 14:15:04 +03:00
Capybara-z 68b9092749 fix permanent ban functionality / fix statistics timezone consistency 2025-07-10 14:13:20 +03:00
Capybara-z e3887fa66a Add keyboard support for sending messages 2025-07-09 22:11:40 +03:00
Capybara-z ffd08e726d Fix Gifts API from JustYay
Fix Gifts API
2025-07-04 17:44:36 +03:00
Capybara-z 03444b65ba Improve bot version update message 2025-07-04 17:41:11 +03:00
Capybara-z b817b5f087 Add subscription purchase command/Add subscription renewal command/Improve bot version update message 2025-07-04 17:34:21 +03:00
Boris Kovalskii f93dc40f5f Fix Gifts API 2025-07-04 17:19:06 +10:00
Capybara-z 71c8014be1 update version 2025-07-03 19:57:29 +03:00
Capybara-z 33e44abef0 fix: server deletion when disabled / fix: multiple gifts receiving / add: tariff group info in subscription 2025-06-30 21:48:14 +03:00
Capybara-z e7f91a971b add temporary Remnawave API functions for user import / add UTM code validation in ads creation 2025-06-29 17:27:07 +03:00
Capybara-z 8ee133edc6 Fix SSL certificate validation / Fix tariff sorting for subscription renewals 2025-06-26 00:21:31 +03:00
Vladless 8954d3a756 expiry time for update key in api 2025-06-24 00:35:00 +03:00
Vladless 0640c4f1fd add update key for api 2025-06-23 23:18:09 +03:00
Vladless 2c9680cc8d add verify_admin_token 2025-06-23 22:10:05 +03:00
Capybara-z 9d3c0cb4b1 tariff subgroups use hash callbacks / only ungrouped tariffs when creating / add subgroup management 2025-06-23 20:57:07 +03:00
Capybara-z 6bfa94d9bb Add image for balance/payment menu 2025-06-23 00:52:31 +03:00
Vladless 58cda2e5ff add get_all_by_field method 2025-06-22 21:10:56 +03:00
Vladless 2d4cdb0e35 fix router for freekassa 2025-06-22 15:12:43 +03:00
Capybara-z 1331632bdc Improve trial period display in referral offers 2025-06-22 15:09:52 +03:00
Vladless eadcacdedc Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-22 08:36:02 +03:00
Vladless 855398d186 add freekassa_pay 2025-06-22 08:35:17 +03:00
Capybara-z 8bd09018ec fix: renew key flow with subgroups 2025-06-21 21:52:25 +03:00
Vladless 36b074b209 fix sub/captcha for gifts 2025-06-21 19:54:39 +03:00
Capybara-z 318598026c fix: butons trial profil 2025-06-20 22:38:20 +03:00
Capybara-z 755c85db2d fix: renew proper key selection 2025-06-20 16:54:13 +03:00
Capybara-z 9074eb277d fix: add error handling for HWID 2025-06-20 00:52:08 +03:00
Capybara-z 55cbe579f7 fix: trial menu logic / feat: admin cluster pagination 2025-06-20 00:48:06 +03:00
Vladless d9292840c5 subgroups for gifts in admin-panel/bug fix 2025-06-19 18:12:09 +03:00
Vladless f817110476 add requirements for setup bots 2025-06-19 06:58:21 +03:00
Vladless 4699c6a394 Sync Remnawave panel / import users & keys / restore DB / rename buttons / UI tweaks 2025-06-19 05:09:29 +03:00
Capybara-z 0abb83aae1 feat: add broadcast preview with confirmation / improve referral bonuses (percent and fixed RUB) / unify personal message formatting 2025-06-19 00:11:35 +03:00
Capybara-z d532f1211c Fix balance buttons 2025-06-18 19:03:25 +03:00
Capybara-z e4538ec0f2 Fix renewal buttons in notifications 2025-06-18 18:48:44 +03:00
Capybara-z 157f7454bd Fix 3x-ui key renewal / Add option to disable balance button / New subscription purchase/renewal menu / Add pagination for UTM tags 2025-06-18 18:27:34 +03:00
Vladless d9f6013aa1 add api routes 2025-06-16 17:20:49 +03:00
Vladless a3d8a6233a add subgroups for gifts 2025-06-16 00:09:59 +03:00
Capybara-z 5ecb5b66f0 fix sync, skip frozen subs / improve keys update / fix country switch no tariff / improve unfreeze sub / add back button in admin reset hwid 2025-06-15 23:48:33 +03:00
Vladless c9797f9b26 minor fixes 2025-06-15 22:58:44 +03:00
Vladless 19063b3592 add uvicorn for FastApi 2025-06-14 22:28:21 +03:00
Vladless f0c1f62d62 API/ remnawave 1.6.12/ admins in database and more 2025-06-14 21:16:53 +03:00
Vladless e7410ae776 minor improvements to UTM tracking accuracy 2025-06-14 07:01:42 +03:00
Vladless 606af6f52a make get_git_commit_number work under systemd 2025-06-14 06:30:09 +03:00
Vladless 3f22e20c24 CLI 0.2.0/git number for admins 2025-06-14 05:13:20 +03:00
Vladislav Lisitsyn c2110e7304 Update bot.py 2025-06-14 04:22:16 +03:00
Capybara-z c07e55a86e fix: traffic reset / coupon menu display 2025-06-14 03:45:47 +03:00
Vladless 48e15fbaad subgroups in tariffs 2025-06-14 01:58:29 +03:00
Capybara-z e280a4dc12 fix: tariff name display 2025-06-14 01:01:45 +03:00
Capybara-z 493f01d6aa fix: sync delays / tariff name display 2025-06-14 00:56:22 +03:00
Capybara-z a93855e1a6 fix: correct version check to compare against current branch 2025-06-13 22:54:25 +03:00
Capybara-z a9821aced7 add subscription count display for clusters and servers 2025-06-13 21:03:47 +03:00
Vladless 98055bc1ab add payment class for yookassa 2025-06-13 14:32:33 +03:00
Vladless 801dac4884 add payment class for yookassa 2025-06-13 14:31:10 +03:00
Capybara-z a315222df7 fix the back button in key reissue 2025-06-13 14:06:19 +03:00
Vladless 34feb87b0a fix custom amount for robokassa 2025-06-13 12:09:49 +03:00
Vladless ab04498488 id sorted for renew plans and rm cashback in statistic 2025-06-13 08:58:18 +03:00
Vladless 6f8bc8ad24 fix referrals and tariff name in key view 2025-06-13 08:05:18 +03:00
Vladless 1e6c901e43 add idempotency check to prevent duplicate payments 2025-06-13 07:55:12 +03:00
Vladless 2aade7acf9 add idempotency check to prevent duplicate payments 2025-06-13 04:00:05 +03:00
Capybara-z 9ade45347c add full server management with cluster transfer support 2025-06-12 23:47:18 +03:00
Vladless 1fb7f8d412 gifts for more users in admin-panel 2025-06-11 23:09:23 +03:00
Vladless aabfaf10ee CLI 0.1.9/SSL check for servers 2025-06-11 01:32:41 +03:00
Vladless bbc907ef93 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn e7e7de8115 Merge branch 'main' into dev 2025-06-09 22:45:13 +03:00
Vladislav Lisitsyn a65c38394f Update README.md 2025-06-09 22:43:02 +03:00
Vladless 82ad2ae8e9 daily reports and minor fixes 2025-06-09 21:41:30 +03:00
Vladless 3e251a46ea fix permissions for delete tables 2025-06-09 18:43:45 +03:00
Capybara-z c76c8d6544 fix: android tv connection for remnawave 2025-06-05 19:16:05 +03:00
Capybara-z d61c0dcb30 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-05 01:15:21 +03:00
Capybara-z e2d31525ea add: android tv connection for remnawave 2025-06-05 01:14:20 +03:00
Vladless 4dda834805 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-04 14:58:23 +03:00
Vladless 6a46f7e7ff fix custom amount input for stars pay 2025-06-04 14:58:00 +03:00
Capybara-z 05ee8ff1f4 fix remnawave sync logic
- Исправлена логика синхронизации Remnawave, если обновлять нечего пересоздаем.
2025-06-04 00:52:22 +03:00
Vladless 9f696fa9cd sort tariffs by ID 2025-06-03 03:37:25 +03:00
Vladless 9666947125 use update instead of recreate for Remnawave-only clusters 2025-06-03 02:40:56 +03:00
Vladless 0f6d09c06c fix UTC daily report and back button for bans 2025-06-03 00:35:20 +03:00
Vladless f1e3cb0c05 add cryptobot description 2025-06-02 20:41:58 +03:00
Vladless 20a4c0ab38 fix cashback in stats/back tariff/disable cluster 2025-06-02 20:11:02 +03:00
Capybara-z eef645fbcf hide device count for unlimited tariffs 2025-06-01 02:30:46 +03:00
Capybara-z e00588d468 format tariff duration in messages / format delete delay message for expired subscriptions 2025-06-01 01:30:55 +03:00
Vladless 5ab21c9e8d Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-31 20:44:16 +03:00
Vladless cb45a4d760 up bot version 2025-05-31 20:44:04 +03:00
Capybara-z 7d8d874390 group tariffs and sort by duration 2025-05-31 18:08:52 +03:00
Capybara-z 36107a32c7 group tariffs and sort by duration 2025-05-31 16:48:40 +03:00
Vladless c0dc3dcc18 delete cuopons stats for utm 2025-05-31 15:54:29 +03:00
Vladless df43ea2716 fix remna traffic for create_key 2025-05-30 22:12:16 +03:00
Capybara-z 43af9df078 fix: trial key display / show correct trial days / fix trial limits 2025-05-30 19:07:27 +03:00
Capybara-z 31b9d0abdf fix: admin balance buttons / show tariff in key / show tariff on buy/renew / backup send modes 2025-05-30 17:02:37 +03:00
Vladless 572aa88ee1 rebase subs for country_mode 2025-05-30 16:54:44 +03:00
Capybara-z 3dbec2196a fix: coupon renew uses tariff limits / fix autorenew / fix renew keyboard / show tariff info in key / key renew uses correct tariff / fix start menu display 2025-05-29 22:31:28 +03:00
Vladless 9a5cc083ca Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-29 14:38:53 +03:00
Vladless 2172aa02a8 Preserve images inside the img/ folder during updates 2025-05-29 14:37:22 +03:00
Capybara-z 4316bf2209 Fix auto-renewal logic to properly select tariff during renewal 2025-05-29 00:03:04 +03:00
Vladless 331d140b42 restore hot leads export and stats section 2025-05-28 22:49:59 +03:00
Vladless e67dce82d6 actual commit for admin-menu 2025-05-28 22:14:07 +03:00
Vladless b442e70657 CLI 0.1.8/update tariff menu 2025-05-28 20:48:11 +03:00
Vladless 4fb02063fd fix add time in admins 2025-05-28 19:10:46 +03:00
Capybara-z 96fb83f7f4 fix: key_utils.py 2025-05-28 01:18:58 +03:00
Capybara-z cc3b8a0571 Merge branch 'dev' of fix: key management in admin menu / fixed synchronization 2025-05-28 01:08:16 +03:00
Capybara-z 32fda4e8b5 fix: key management in admin menu / fixed synchronization 2025-05-28 00:44:26 +03:00
Capybara-z cd903944fa tariff lookup for country model
- Определение тарифа для режима стран при продлении ключей.
2025-05-27 00:15:36 +03:00
Vladless 3ab1007178 add commit number to bot version display 2025-05-26 23:11:04 +03:00
Vladless 0e820f62c9 add new payment flow for yoomoney/ add payment for refferals 2025-05-26 22:36:26 +03:00
Vladless 3e33513fe9 add support traffic for trial in cluster mode/country mode 2025-05-26 21:37:00 +03:00
Capybara-z daa9710579 exclude referral and coupon payments from statistics 2025-05-26 19:07:19 +03:00
Capybara-z 6b2f4f6ac5 delete accrual statistics 2025-05-26 12:24:43 +03:00
Vladless 8e44c9755b Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-25 23:56:02 +03:00
Vladless d55fbf0fed add trial limits for create clusters_mode 2025-05-25 23:54:43 +03:00
Capybara-z e05f8f8183 add to accrual statistics
- Добавлено логирование начислений купонов.
2025-05-25 23:38:31 +03:00
Capybara-z 1ae19cd351 add to accrual statistics
- Добавлено логирование начислений реферавлов.
2025-05-25 23:35:29 +03:00
Vladislav Lisitsyn d79deb307e Merge pull request #206 from Capybara-z/patch-8
Fix key expiration notification and auto-renewal handling
2025-05-25 20:38:28 +03:00
Vladless 8f494c0b98 add round for balance and less callback for tariffs 2025-05-25 20:34:16 +03:00
Capybara-z 71803174b0 Fix key expiration notification and auto-renewal handling
- Исправлена обработка уведомлений об истечении ключей.
- Добавлена корректная работа с задержкой удаления ключей и авто-продлением подписок.
2025-05-25 19:47:35 +03:00
Vladless 1c660320da cleanup_orphans_before_migrations 2025-05-25 13:13:18 +03:00
Vladless 014dbd7d73 cleanup_orphans_before_migrations 2025-05-25 12:59:42 +03:00
Vladless 22e0d610a7 cleanup_orphans_before_migrations 2025-05-25 12:49:38 +03:00
Vladless d4f77df197 cleanup_orphans_before_migrations 2025-05-25 12:36:40 +03:00
Vladless 0f7a9e58d8 fix back button in view tariff groups 2025-05-24 23:41:25 +03:00
Vladless 9ae2de2eb5 fix key create for user in admin 2025-05-24 23:25:49 +03:00
Vladless 1309c3ef43 minor fixes 2025-05-24 22:32:15 +03:00
Vladless 0edadd9592 fix gift tariff name 2025-05-24 22:01:32 +03:00
Vladless cc403fac67 session for renew_key 2025-05-24 21:53:15 +03:00
Vladless faecb7fb13 trial user in check_notification 2025-05-24 21:31:53 +03:00
Vladless 32cc047943 session in process_callback_view_profile 2025-05-24 20:49:19 +03:00
Vladless d4d2ff1867 ORM update and lots of improvements 2025-05-24 19:30:23 +03:00
Vladless d2d6ba1be9 key column null for table keys 2025-05-20 23:53:49 +03:00
Vladless 96905c0f51 sorted tariffs by id for renew key 2025-05-20 18:23:46 +03:00
Vladless fe2c70ab75 delete tarif_group for servers 2025-05-20 16:12:41 +03:00
Vladless 1174a3ff34 convert balance column 2025-05-20 13:21:31 +03:00
Vladless e66a465eee group by id 2025-05-20 13:04:37 +03:00
Vladless d6ec510055 fix add_notification for blocked users 2025-05-20 12:35:54 +03:00
Vladless 29bd962d49 balance numeric in database 2025-05-20 09:06:47 +03:00
Vladless 8fb8e203ae sudo python3 main.py 2025-05-20 08:55:18 +03:00
Vladless 78cfd35ad8 add tariff_group in get_servers 2025-05-20 08:45:17 +03:00
Vladless e6429fda9d fix delete tariff 2025-05-19 17:15:38 +03:00
Vladless d491178410 add group_code 2025-05-19 14:17:35 +03:00
Vladless b3b21d1f5f minor improvements 2025-05-19 11:08:51 +03:00
Vladless e8030c1db9 discounts tariffs/notify hot leads/change success payment 2025-05-19 09:07:32 +03:00
Vladless a1f0449e5c bot version 2025-05-19 06:03:36 +03:00
Vladless fd3e26dd47 tariff id in callback 2025-05-19 06:03:03 +03:00
Vladless ae39b6df9a statistic tariffs/database logs 2025-05-19 05:41:19 +03:00
Vladless cbd2f58499 ban middleware/ ban users 2025-05-19 03:59:26 +03:00
Vladless 8ea78943fe add daily project report 2025-05-18 00:44:17 +03:00
Vladless 8bd904af6c tariffs view in cluster menu 2025-05-18 00:27:39 +03:00
Vladless b3369d8708 tblocker router 2025-05-17 20:54:23 +03:00
Vladless 4af368549e tariff_id for payments 2025-05-17 20:44:43 +03:00
Vladless 07bbd9b177 data formatted in renew 2025-05-16 20:27:48 +03:00
Vladless 11898a5d13 fix gifts user_data 2025-05-16 18:37:41 +03:00
Vladless e662e0d06e tariffs order by id 2025-05-16 15:46:12 +03:00
Vladless dc2e1158fa ip limit for tariffs 2025-05-16 10:06:26 +03:00
Vladless 109df2354c add tariffs stats 2025-05-16 08:51:43 +03:00
Vladless 4d337e3df5 add device_limit in tariffs 2025-05-16 07:09:18 +03:00
Vladless 9a042c638a tariff customization/traffic for each tariff/ruff 2025-05-16 06:02:18 +03:00
Vladless d520f02c03 add alias for key menu in admin-panel 2025-05-13 06:45:56 +03:00
Vladless 0581327358 add more hwid stats 2025-05-13 04:36:06 +03:00
Vladless 0883cf2bf6 add more stats 2025-05-13 04:32:36 +03:00
Vladless 6067eca053 hwid management in admin-panel and key menu 2025-05-12 23:16:56 +03:00
Vladislav Lisitsyn 48d7e197a1 Merge pull request #205 from Capybara-z/patch-7
Add tblocker blocking notifications
2025-05-12 23:04:28 +03:00
Capybara-z 961514d712 Add tblocker blocking notifications
- Логика обработчки оповещений на вебхук от tblocker
2025-05-12 22:12:01 +03:00
Capybara-z 97bfc1bc43 add tblocker webhook handler
- Добавлен обработчик вебхука tblocker
2025-05-12 22:05:36 +03:00
Oleg Kraev b3b15db8c0 Merge pull request #204 from Capybara-z/patch-6
add sub info
2025-05-12 02:46:05 +03:00
Vladless ce275babc3 4.3-beta 2025-05-11 19:23:27 +03:00
Vladless 3aac6b9872 add support HWID 2025-05-11 16:55:19 +03:00
Oleg Kraev 8813664f3f Update README.md 2025-05-11 16:53:22 +03:00
Capybara-z ebd50f8b3d add sub info
- добавил хедер v2raytun для отображения травика и даты завершения подписки.
2025-05-11 16:14:35 +03:00
Vladless 62fccf2b10 add support remnawave api 1.6.x 2025-05-11 16:12:51 +03:00
Vladless 943027f17f back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless ea0a3dc962 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 2d2a11e5b6 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 55fe1342cf Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn 105f798c26 Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z 944295165d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 18be0049cc fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 26a32b6f07 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn f477094614 Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 367331d113 add state.clear 2025-05-09 20:35:50 +03:00
Vladless 51d40323e5 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 5ddf7f8242 ruff formatting 2025-05-09 01:34:11 +03:00
Vladless e4a73f9aaa set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless b99c090a84 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 15ad1a0f79 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 344e4757ef oops 2025-05-08 17:19:00 +03:00
hteppl 55461aa50b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 24928fe561 Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn f16084b941 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn caeb531445 Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn fefbf4da39 Update README.md 2025-05-08 02:36:49 +03:00
Vladless cd3c19b79d 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 6cd1dd8bc2 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn 3837f47bb6 Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z bff7350224 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless cd4c526da0 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn 38e931446a Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z 55af3bde3d fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn 6bca31f92c Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z f8858e8561 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z d3c729cdc2 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z 4c954409cd Update notification 2025-05-05 21:39:20 +03:00
Capybara-z b2dbdb60b6 Update notification 2025-05-05 21:38:11 +03:00
Oleg Kraev a26d334653 Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless ef1cf1fe26 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless 1356170fb0 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 998cf9a6e1 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 85a6be831e Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 9df5d8469d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn 3133cc7799 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 7b894300b7 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 2621d29ef0 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 76ad4e5821 fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 0056454375 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 3c15589b03 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 4849c899c2 fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless 44b9986a45 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless a9359e9dc0 update version 2025-04-26 23:30:24 +03:00
Vladless 76e730475d get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless 18d3b4246a add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 22121b9171 ruff formatting 2025-04-24 23:20:18 +03:00
Vladless ff498feb7e scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 15fcc0ac70 Update README.md 2025-04-23 22:29:54 +03:00
Vladless b1c73dc1a6 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless 888812babd fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless 023cd1728d update user middleware 2025-04-22 19:21:15 +03:00
Vladless abbd3474b4 update table for utm 2025-04-22 00:41:34 +03:00
Vladless fa17faa424 update version 2025-04-21 22:04:07 +03:00
Vladless 799c657605 refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless fa875fb3bc CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless cc628a08fa CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless fa85fb70f1 hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless 1d5980d725 rm old logic 2025-04-21 01:46:19 +03:00
Vladless 9474ccfb06 refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn c6e91c0543 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn 593206f6a0 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z ea6f5297c1 Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 471714b80d v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn a92a19b84d Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 289e0ae5b5 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless d08b410888 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless 455455d696 beta version 2025-04-18 05:01:47 +03:00
Vladless 57ad26711d fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 24428a030e add column in table servers 2025-04-18 01:34:00 +03:00
Vladless c42fee4298 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless aba9620c0f limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless dd8c87f887 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless eebc2ee1d5 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless 7bd5ecc55f remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 5d25e211df Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z 0887e28b2b Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z e04be209cb Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z d19eee8897 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 3efdeed00c add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless 9e89a2db10 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 59a23ea9bd Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z 216c8f8d81 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn d0f6511c5c Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z e1ecb6a1dc Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless 99c02874ec CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 0cd896d35e gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless ab35ce9178 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless f71e6eb226 add import renew 2025-04-10 15:02:46 +03:00
Vladless cd3b55271a add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl e71de5f3cf Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless 0fc2c535de Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
307 changed files with 42089 additions and 10597 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Issue
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
title: "[ISSUE] "
labels: triage
assignees: ''
---
### Тип
- [ ] Баг
- [ ] Фича
- [ ] Безопасность
### Кратко
Опишите суть задачи.
### Шаги воспроизведения (для багов/безопасности)
1.
2.
3.
### Ожидаемое / Фактическое
-
### Окружение
- Ветка: main/dev
- Коммит:
- ОС:
- Python:
### Дополнительно (для фич)
Опишите мотивацию, альтернативы.
### Примечание по безопасности
Не публикуйте PoC публично.
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
+21
View File
@@ -0,0 +1,21 @@
### Описание изменений
Опишите, что изменилось и зачем.
### Ветка назначения
- [ ] dev (основная ветка разработки)
- [ ] main (только для критических исправлений безопасности)
### Тип изменений
- [ ] Новая функциональность
- [ ] Исправление ошибки
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
### Чек-лист
- [ ] Изменения протестированы локально
- [ ] Документация обновлена (если требуется)
- [ ] Нет лишних изменений в коде
### Связанные задачи
Укажите номер issue, если PR его закрывает (например: Closes #123).
+7 -4
View File
@@ -7,6 +7,7 @@ __pycache__/
dist/
build/
*.egg-info/
alembic/
# Virtual environments
venv/
@@ -28,6 +29,7 @@ env/
# Sensitive configuration files
config.py
config.ini
alembic.ini
.env
# Backup files
@@ -41,7 +43,6 @@ database.db
bot_old.py
bot_old_2.py
backup_pg.sh
docker-compose.yml
config copy.py
handlers/texts.py
@@ -51,10 +52,12 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
/logs
setup.py
.ruff_cache
.github/workflows/
.github/workflows/
modules/
storage/
.license_state
-1
View File
@@ -1 +0,0 @@
3.12
+25
View File
@@ -0,0 +1,25 @@
# Code of Conduct
## Общие правила
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
- Обсуждения должны оставаться по теме и в конструктивной форме.
- Личные и конфиденциальные данные публиковать запрещено.
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
## Участники GitHub
- Могут предлагать изменения через **Issues** и **Pull Requests**.
- Их вклад рассматривается и принимается по решению мейнтейнеров.
- Дискуссии ограничены рамками кода и технических вопросов.
## Участники сообщества в Telegram
- Имеют приоритетное взаимодействие с разработчиком.
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
- Влияние на развитие кода выше, чем у участников извне.
- Общение остаётся в рамках уважения и конструктивности.
## Контакты
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
+29
View File
@@ -0,0 +1,29 @@
# Contributing Guidelines
Спасибо, что хотите внести вклад в развитие проекта!
## Как помочь
- Создавайте **Issues** для конкретных целей или описания найденных багов.
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
## Стиль кода
- Соблюдайте стандарт **PEP8**.
- Для автоматической проверки можно использовать **ruff**.
## Правила коммитов
- Сообщения должны быть осмысленными и отражать суть изменений.
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
## Лицензия
Creative Commons Attribution-NonCommercial 4.0 International Public License © Vladless, 2024.
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
---
Ваш вклад помогает проекту расти и становиться лучше.
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY . .
RUN rm -rf /app/venv \
&& python -m venv /app/venv \
&& /app/venv/bin/pip install --upgrade pip \
&& /app/venv/bin/pip install -r requirements.txt
RUN adduser --disabled-password --gecos "" appuser && chown -R appuser:appuser /app
USER appuser
CMD ["/app/venv/bin/python", "main.py"]
+6 -3
View File
@@ -1,7 +1,10 @@
formatting:
format:
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
lint:
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
format-payments:
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
+200 -95
View File
@@ -1,106 +1,211 @@
# 🚀 SoloBot
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
![IMG_1565](https://github.com/user-attachments/assets/71cfb259-9889-4066-8626-b804ae4cf7fb)
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
**SoloBot** реализует множество функций, включая:
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
<p style="margin:16px 0 0">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
</a>&nbsp;
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
</a>&nbsp;
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
</a>
</p>
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
<p style="margin:10px 0 0">
<a href="LICENSE" target="_blank">
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
</a>
</p>
### *Настраивайте как вам удобно!*
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
</div>
## Список версий
<h2 align="center">Основные возможности</h2>
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
<table align="center" style="max-width:980px;width:100%">
<tr>
<th align="left" style="width:30%">📌 Раздел</th>
<th align="left">💡 Возможности</th>
</tr>
<tr>
<td><b>Мультипанель</b></td>
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
</tr>
<tr>
<td><b>Подписки</b></td>
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
</tr>
<tr>
<td><b>Тарификация</b></td>
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
</tr>
<tr>
<td><b>Клиент</b></td>
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
</tr>
<tr>
<td><b>Поддержка роутеров</b></td>
<td>Выдача VLESS подписки за отдельную цену</td>
</tr>
<tr>
<td><b>Рефералы</b></td>
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
</tr>
<tr>
<td><b>UTM-аналитика</b></td>
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
</tr>
<tr>
<td><b>Оплата</b></td>
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
</tr>
<tr>
<td><b>Валютность</b></td>
<td>Оплата в разных валютах</td>
</tr>
<tr>
<td><b>Надёжность</b></td>
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
</tr>
<tr>
<td><b>Уведомления</b></td>
<td>О скором окончании подписки<br>О непотраченном трафике</td>
</tr>
<tr>
<td><b>Рассылки</b></td>
<td>По заданным группам<br>С картинками или видео</td>
</tr>
<tr>
<td><b>Серверы</b></td>
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
</tr>
<tr>
<td><b>Воронка продаж</b></td>
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
</tr>
<tr>
<td><b>Админка</b></td>
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
</tr>
<tr>
<td><b>Модульность</b></td>
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
</tr>
<tr>
<td><b>Кастомизация</b></td>
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
</tr>
</table>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Обновления</h2>
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Гайды и расширения</h2>
<p align="center" style="max-width:900px;margin:8px auto 14px">
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
</p>
<p align="center">
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
</p>
<p align="center">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Полная версия</h2>
<p align="center">
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
</p>
<p align="center" style="max-width:900px;margin:8px auto 14px">
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
</p>
<p align="center">
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
</a>&nbsp;
<a href="https://t.me/solonet_sup" target="_blank">
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
</a>
</p>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Лицензия</h2>
<table align="center" style="max-width:900px;width:100%">
<tr>
<td style="width:8%;text-align:center">❗</td>
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
</tr>
<tr>
<td style="text-align:center">⛔</td>
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
</tr>
<tr>
<td style="text-align:center">✅</td>
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
</tr>
</table>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Участники</h2>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
</a>
</p>
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
+32
View File
@@ -0,0 +1,32 @@
# Security Policy
## Поддерживаемые ветки
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
- Другие ветки и теги не поддерживаются.
## Как сообщить об уязвимости
- Не создавайте публичные Issues или Pull Requests.
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
## Процесс
1. Подтверждаем получение отчёта.
2. Анализируем и определяем критичность.
3. Исправления выпускаются в `dev`.
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
## Область действия
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
## Ответственное раскрытие
- Просим не публиковать детали до выхода исправления.
- Тесты проводите только на своих данных и аккаунтах.
- Атаки на инфраструктуру или пользователей строго запрещены.
## Лучшие практики при отчёте
- Минимальный PoC без риска для данных и доступности.
- Чёткие и воспроизводимые шаги.
- Возможные предложения по снижению риска до исправления.
+32
View File
@@ -0,0 +1,32 @@
import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+64
View File
@@ -0,0 +1,64 @@
from time import perf_counter
from fastapi import FastAPI, Request
from api.routes import (
users,
keys,
coupons,
servers,
tariffs,
gifts,
referrals,
misc,
partners,
modules,
management,
settings,
)
from config import API_LOGGING
from logger import logger
app = FastAPI(
title="SoloBot API (Alpha)",
version="0.5.2",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
if not API_LOGGING:
return await call_next(request)
started = perf_counter()
response = await call_next(request)
duration_ms = int((perf_counter() - started) * 1000)
client_ip = request.client.host if request.client else "-"
path_qs = request.url.path
if request.url.query:
path_qs = f"{path_qs}?{request.url.query}"
logger.info(
f'[API] {client_ip} "{request.method} {path_qs}" {response.status_code} {duration_ms}ms'
)
return response
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
app.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
app.include_router(misc.router, prefix="/api")
app.include_router(modules.router, prefix="/api")
app.include_router(management.router, prefix="/api/management", tags=["Management"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
@app.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
+164
View File
@@ -0,0 +1,164 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.models import Admin
def cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and getattr(obj, "vless") is None:
setattr(obj, "vless", False)
def to_schema(schema_response: type, obj: object):
normalize_outgoing_object(obj)
return schema_response.model_validate(obj, from_attributes=True)
def generate_crud_router(
*,
model: type,
schema_response: type,
schema_create: type,
schema_update: type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return to_schema(schema_response, obj)
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return to_schema(schema_response, obj)
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+65
View File
@@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
await session.commit()
return {"message": "Подарок и связанные использования удалены"}
+141
View File
@@ -0,0 +1,141 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.dict(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
await session.commit()
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+219
View File
@@ -0,0 +1,219 @@
import os
import re
import subprocess
import sys
import asyncio
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from config import API_TOKEN
from core.bootstrap import MANAGEMENT_CONFIG
from core.settings.management_config import update_management_config
from database.models import Key, User
from database.models import Server
from handlers.admin.sender.sender_service import BroadcastService
from handlers.admin.sender.sender_utils import get_recipients, parse_message_buttons
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
result = await session.execute(stmt)
await session.commit()
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.tg_id).where(Key.tg_id == User.tg_id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
await session.commit()
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
text_raw = (payload.text or "").strip()
if not text_raw:
raise HTTPException(status_code=400, detail="Broadcast text is required")
if payload.send_to == "cluster" and not (payload.cluster_name or "").strip():
raise HTTPException(status_code=400, detail="Cluster name is required for cluster broadcast")
clean_text, keyboard = parse_message_buttons(text_raw)
max_len = 1024 if payload.photo else 4096
if len(clean_text) > max_len:
raise HTTPException(status_code=400, detail=f"Message too long. Max {max_len} symbols")
tg_ids, total_users = await get_recipients(session, payload.send_to, (payload.cluster_name or None))
if not tg_ids:
return {"success": False, "message": "No recipients found", "stats": {"total_messages": 0}}
bot = _get_broadcast_bot()
messages = [
{
"tg_id": tg_id,
"text": clean_text,
"photo": payload.photo,
"keyboard": keyboard,
}
for tg_id in tg_ids
]
workers = max(1, min(int(payload.workers or 5), 30))
rate = max(1, min(int(payload.messages_per_second or 35), 60))
broadcast_service = BroadcastService(bot=bot, session=session, messages_per_second=rate)
stats = await broadcast_service.broadcast(messages, workers=workers)
return {
"success": True,
"message": "Broadcast completed",
"recipients": total_users,
"stats": stats,
}
+155
View File
@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.models import (
Admin,
BlockedUser,
ManualBan,
Notification,
Payment,
TemporaryData,
TrackingSource,
)
router = APIRouter()
router.include_router(
generate_crud_router(
model=Payment,
schema_response=PaymentResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_admin_token)],
)
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
async def get_payments_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
return payments
router.include_router(
generate_crud_router(
model=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TrackingSource,
schema_response=TrackingSourceResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
+124
View File
@@ -0,0 +1,124 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[2] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
refresh = getattr(manager, "refresh_state", None)
if callable(refresh):
refresh()
else:
legacy_refresh = getattr(manager, "_load_state", None)
if callable(legacy_refresh):
legacy_refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
local_version = _read_local_module_version(name)
item["local_version"] = local_version
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
+962
View File
@@ -0,0 +1,962 @@
from datetime import datetime
import csv
from io import StringIO
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
"""Normalize percent input to 0-100 range."""
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
def _row_dt_iso(value) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return None
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
partners_list = []
default_percent = _default_partner_percent()
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
if percent_custom and percent_value is not None:
percent = float(percent_value)
else:
percent = float(default_percent)
partners_list.append({
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
})
return JSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"partners_today": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(
SELECT COUNT(DISTINCT partner_tg_id)
FROM partners
WHERE partner_tg_id IS NOT NULL
AND DATE(created_at) = CURRENT_DATE
) AS partners_today,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(
SELECT COALESCE(SUM(u.partner_balance), 0.0)
FROM users u
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"partners_today": int(stats_row[1] or 0),
"total_referred": int(stats_row[2] or 0),
"total_balance": float(stats_row[3] or 0.0),
"top_partner_tg_id": int(stats_row[4] or 0),
"top_partner_refs": int(stats_row[5] or 0),
}
else:
stats = {
"total_partners": 0,
"partners_today": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return JSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
status_code=200,
)
else:
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(
content={"success": False, "message": str(e)},
status_code=500,
)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method
FROM users u
WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value = meta_row[1]
percent_custom = bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return JSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователю партнёра."""
if joined_tg_id == tg_id:
return JSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
status_code=400,
)
try:
partner_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
if not partner_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
if not invited_exists.scalar():
return JSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"},
status_code=404,
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return JSONResponse(
content={
"success": False,
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
},
status_code=409,
)
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
return JSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text(
"""
DELETE FROM partners
WHERE partner_tg_id = :partner_tg_id
AND joined_tg_id = :joined_tg_id
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return JSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
status_code=404,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return JSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
status_code=400,
)
try:
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = :percent, partner_percent_custom = true
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id, "percent": normalized},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized},
status_code=200,
)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return JSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
status_code=400,
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return JSONResponse(
content={"success": False, "message": "Неверная сумма"},
status_code=400,
)
if amount_val < 0:
return JSONResponse(
content={"success": False, "message": "Сумма не может быть отрицательной"},
status_code=400,
)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
current_balance = current_res.scalar()
if current_balance is None:
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return JSONResponse(
content={"success": False, "message": "Недостаточно средств"},
status_code=400,
)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
await session.commit()
return JSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
status_code=200,
)
except Exception as e:
await session.rollback()
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return JSONResponse(content=invited_list)
@router.get("/payouts/pending")
async def get_partner_payouts_pending(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список ожидающих заявок на вывод."""
where_clause = "WHERE pr.status = 'pending'"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at ASC, pr.id ASC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "pending",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return JSONResponse(content={"total": int(total), "items": items})
@router.get("/payouts/history")
async def get_partner_payouts_history(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает историю выплат (approved/rejected)."""
where_clause = "WHERE pr.status IN ('approved','rejected')"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at DESC, pr.id DESC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return JSONResponse(content={"total": int(total), "items": items})
@router.post("/payouts/{payout_id}/approve")
async def approve_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Одобряет заявку на вывод."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'approved', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
@router.post("/payouts/{payout_id}/reject")
async def reject_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return JSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'rejected', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
if user is not None:
current_balance = float(user[2] or 0.0)
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
@router.patch("/{tg_id}/percent/reset")
async def reset_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает персональный процент партнёра к дефолту."""
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = NULL, partner_percent_custom = false
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.patch("/{tg_id}/code")
async def update_partner_code(
tg_id: int = Path(..., description="Telegram ID партнёра"),
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет код партнёрской ссылки."""
raw = (code or "").strip().lower()
if not raw:
return JSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
import re
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
return JSONResponse(
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
status_code=400,
)
exists = await session.execute(
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if exists.first():
return JSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
result = await session.execute(
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
{"code": raw, "tg_id": tg_id},
)
await session.commit()
if result.rowcount > 0:
return JSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
return JSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.post("/reset-disabled-methods")
async def reset_disabled_payout_methods(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
from modules.partner_program import buttons as B
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
ENABLE_PAYOUT_TON = True
ENABLE_PAYOUT_SBP = True
B = None
disabled = []
if not ENABLE_PAYOUT_CARD and B:
disabled.append(B.METHOD_CARD)
if not ENABLE_PAYOUT_USDT and B:
disabled.append(B.METHOD_USDT)
if not ENABLE_PAYOUT_TON and B:
disabled.append(B.METHOD_TON)
if not ENABLE_PAYOUT_SBP and B:
disabled.append(B.METHOD_SBP)
if not disabled:
return JSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
await session.execute(
text(
"""
UPDATE users
SET card_number = NULL
WHERE payout_method = ANY(:methods)
"""
),
{"methods": disabled},
)
await session.commit()
return JSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
@router.get("/{tg_id}/export")
async def export_partner_invites_csv(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Экспортирует приглашённых партнёром в CSV."""
rows = await session.execute(
text(
"""
SELECT joined_tg_id, created_at
FROM partners
WHERE partner_tg_id = :tg_id
ORDER BY created_at ASC
"""
),
{"tg_id": tg_id},
)
data = rows.fetchall()
if not data:
return JSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
buffer = StringIO()
writer = csv.writer(buffer, delimiter=";")
writer.writerow(["joined_tg_id", "created_at"])
for joined_tg_id, created_at in data:
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
content = buffer.getvalue().encode("utf-8-sig")
filename = f"partner_invites_{tg_id}.csv"
return StreamingResponse(
iter([content]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+36
View File
@@ -0,0 +1,36 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import ReferralResponse
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+153
View File
@@ -0,0 +1,153 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.schemas.settings import SettingResponse, SettingUpsert
from database.models import Setting
from database.settings import set_setting
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from pydantic import BaseModel
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting))
return result.scalars().all()
@router.get("/configs")
async def get_configs(admin=Depends(verify_admin_token)):
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.commit()
await session.refresh(obj)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
await session.commit()
return {"detail": "Setting deleted"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
+16
View File
@@ -0,0 +1,16 @@
from .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from .keys import KeyDetailsResponse, KeyResponse
from .misc import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from .referrals import ReferralResponse
from .servers import ServerBase, ServerResponse, ServerUpdate
from .settings import SettingResponse, SettingUpsert
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class CouponBase(BaseModel):
code: str
amount: int | None = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: int | None = Field(default=None)
percent: int | None = Field(default=None)
max_discount_amount: int | None = Field(default=None)
min_order_amount: int | None = Field(default=None)
new_users_only: bool = False
@model_validator(mode="after")
def check_coupon_type(self) -> "CouponBase":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided != 1:
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
class CouponResponse(CouponBase):
id: int
class Config:
from_attributes = True
class CouponUpdate(BaseModel):
code: str | None = None
amount: int | None = None
usage_limit: int | None = None
usage_count: int | None = None
is_used: bool | None = None
days: int | None = Field(default=None)
percent: int | None = None
max_discount_amount: int | None = None
min_order_amount: int | None = None
new_users_only: bool | None = None
@model_validator(mode="after")
def validate_coupon_update(self) -> "CouponUpdate":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided > 1:
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class GiftUpdate(BaseModel):
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
tariff_id: int | None = None
class Config:
from_attributes = True
+97
View File
@@ -0,0 +1,97 @@
from pydantic import BaseModel, Field
class KeyBase(BaseModel):
tg_id: int
client_id: str
email: str | None = None
created_at: int | None = None
expiry_time: int
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = False
alias: str | None = None
notified: bool | None = False
notified_24h: bool | None = False
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class KeyResponse(KeyBase):
class Config:
from_attributes = True
class KeyDetailsResponse(BaseModel):
key: str | None
remnawave_link: str | None
server_id: str | None
created_at: int | None
expiry_time: int | None
client_id: str
tg_id: int
email: str | None
is_frozen: bool
balance: float
alias: str | None
expiry_date: str
days_left_message: str
link: str | None
cluster_name: str | None
location_name: str | None
tariff_id: int | None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
class KeyUpdate(BaseModel):
email: str | None = None
expiry_time: int | None = None
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = None
alias: str | None = None
notified: bool | None = None
notified_24h: bool | None = None
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
class Config:
from_attributes = True
class KeyCreateRequest(BaseModel):
tg_id: int = Field(..., description="Telegram ID пользователя")
cluster_id: str = Field(..., description="Имя кластера или сервера")
tariff_id: int = Field(..., description="ID тарифа из базы данных")
client_id: str = Field(..., description="UUID клиента (уникальный)")
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
email: str | None = Field(None, description="Условное имя подписки")
alias: str | None = Field(None, description="пользовательское имя")
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
is_trial: bool | None = Field(False, description="Флаг триального ключа")
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
+122
View File
@@ -0,0 +1,122 @@
from datetime import datetime
from pydantic import BaseModel
class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: str
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
tg_id: int
banned_at: datetime
reason: str
banned_by: int
until: datetime | None = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
tg_id: int
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
tg_id: int
class Config:
from_attributes = True
class MonthlyStats(BaseModel):
month: str
registrations: int
trials: int
new_purchases_count: int
new_purchases_amount: float
repeat_purchases_count: int
repeat_purchases_amount: float
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
registrations: int = 0
trials: int = 0
payments: int = 0
total_amount: float = 0.0
monthly: list[MonthlyStats] = []
class Config:
from_attributes = True
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
+35
View File
@@ -0,0 +1,35 @@
from pydantic import BaseModel
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: str | None = None
inbound_id: str
panel_type: str
max_keys: int | None = None
tariff_group: str | None = ""
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: str | None = None
server_name: str | None = None
api_url: str | None = None
subscription_url: str | None = None
inbound_id: str | None = None
panel_type: str | None = None
max_keys: int | None = None
tariff_group: str | None = None
enabled: bool | None = None
class Config:
from_attributes = True
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class SettingUpsert(BaseModel):
value: Any | None = None
description: str | None = None
class SettingResponse(BaseModel):
key: str
value: Any | None = None
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
+66
View File
@@ -0,0 +1,66 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool = False
external_squad: str | None = None
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class TariffResponse(TariffBase):
id: int
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: str | None = None
group_code: str | None = None
duration_days: int | None = None
price_rub: int | None = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
vless: bool | None = None
external_squad: str | None = None
configurable: bool | None = None
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, int] | None = None
class Config:
from_attributes = True
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime
from pydantic import BaseModel
class UserBase(BaseModel):
tg_id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float | None = 0.0
trial: int | None = 0
source_code: str | None = None
class UserResponse(UserBase):
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = None
balance: float | None = None
trial: int | None = None
source_code: str | None = None
class Config:
from_attributes = True
-143
View File
@@ -1,143 +0,0 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payments
(
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL,
amount REAL NOT NULL,
payment_system TEXT NOT NULL,
status TEXT DEFAULT 'success',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS keys
(
tg_id BIGINT NOT NULL,
client_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at BIGINT NOT NULL,
expiry_time BIGINT NOT NULL,
key TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT 'cluster1',
notified BOOLEAN NOT NULL DEFAULT FALSE,
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tg_id, client_id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'is_frozen'
) THEN
ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'alias'
) THEN
ALTER TABLE keys ADD COLUMN alias TEXT;
END IF;
END$$;
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
referrer_tg_id BIGINT NOT NULL,
reward_issued BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupons
(
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
days INTEGER CHECK (days > 0 OR days IS NULL),
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'coupons' AND column_name = 'days'
) THEN
ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (coupon_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications
(
tg_id BIGINT NOT NULL,
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
notification_type TEXT NOT NULL,
PRIMARY KEY (tg_id, notification_type)
);
CREATE TABLE IF NOT EXISTS servers
(
id SERIAL PRIMARY KEY,
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
inbound_id TEXT NOT NULL,
UNIQUE (cluster_name, server_name)
);
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
-158
View File
@@ -1,158 +0,0 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram.types import BufferedInputFile
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from logger import logger
async def backup_database() -> Exception | None:
"""
Создает резервную копию базы данных и отправляет ее администраторам.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
backup_file_path, exception = _create_database_backup()
if exception:
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
return exception
try:
await _send_backup_to_admins(backup_file_path)
exception = _cleanup_old_backups()
if exception:
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
return exception
return None
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
return e
def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
Создает резервную копию базы данных PostgreSQL.
Returns:
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
"""
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
try:
os.environ["PGPASSWORD"] = DB_PASSWORD
subprocess.run(
[
"pg_dump",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-F",
"c",
"-f",
str(filename),
DB_NAME,
],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Бэкап базы данных создан: {filename}")
return str(filename), None
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
return None, e
except Exception as e:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
def _cleanup_old_backups() -> Exception | None:
"""
Удаляет бэкапы старше 3 дней.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
try:
backup_dir = Path(BACK_DIR)
if not backup_dir.exists():
return None
cutoff_date = datetime.now() - timedelta(days=3)
for backup_file in backup_dir.glob("*.sql"):
if backup_file.is_file():
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
backup_file.unlink()
logger.info(f"Удален старый бэкап: {backup_file}")
logger.info("Очистка старых бэкапов завершена")
return None
except Exception as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
return e
async def create_backup_and_send_to_admins(client) -> None:
"""
Создает бэкап и отправляет администраторам через переданный клиент.
Args:
client: Клиент для работы с базой данных
"""
await client.login()
await client.database.export()
async def _send_backup_to_admins(backup_file_path: str) -> None:
"""
Отправляет файл бэкапа всем администраторам через Telegram.
Args:
backup_file_path: Путь к файлу бэкапа
Raises:
Exception: При ошибке отправки файла
"""
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
for admin_id in ADMIN_ID:
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
raise
+14 -75
View File
@@ -1,92 +1,31 @@
import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import ADMIN_ID, API_TOKEN
from config import API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from middlewares import register_middleware
from utils.button_icons import apply_button_icons_patch, set_button_icon_config
from utils.custom_emojis import initialize_custom_emojis
from utils.errors import setup_error_handlers
from utils.modules_loader import load_modules_from_folder, modules_hub
apply_button_icons_patch()
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.1-Release"
dp.include_router(modules_hub)
register_middleware(dp)
load_modules_from_folder()
from handlers.buttons import BUTTON_ICON_CONFIG
set_button_icon_config(BUTTON_ICON_CONFIG)
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(
event: ErrorEvent,
bot: Bot,
) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
return True
setup_error_handlers(dp)
initialize_custom_emojis()
Regular → Executable
+538 -101
View File
@@ -1,60 +1,352 @@
import locale
import os
import sys
import subprocess
import requests
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.table import Table
from rich.text import Text
import re
import shutil
import subprocess
import sys
from time import sleep
import requests
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm, Prompt
from rich.table import Table
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding='utf-8')
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
def ensure_utf8_locale():
try:
current_locale = locale.getlocale()
if current_locale and current_locale[1] == "UTF-8":
return
except Exception:
pass
console.print("[yellow]⏳ Проверка и установка локали UTF-8...[/yellow]")
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
BACK_DIR = os.path.expanduser("~/.solobot_backup")
result = subprocess.run(["locale", "-a"], capture_output=True, text=True)
if "en_US.utf8" not in result.stdout.lower():
console.print("[blue]Добавляю локаль en_US.UTF-8 в систему...[/blue]")
try:
subprocess.run(["sudo", "locale-gen", "en_US.UTF-8"], check=True)
subprocess.run(["sudo", "update-locale", "LANG=en_US.UTF-8"], check=True)
console.print("[green]Локаль успешно установлена.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке локали: {e}[/red]")
else:
console.print("[green]Локаль UTF-8 уже доступна в системе.[/green]")
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
console = Console()
ensure_utf8_locale()
BACK_DIR = os.path.expanduser("~/.solobot_backups")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
console = Console()
def is_ascii_only(value: str) -> bool:
"""Проверка, что строка содержит только ASCII."""
return all(ord(ch) < 128 for ch in value)
def warn_english_only():
"""Предупреждение о необходимости английской раскладки."""
console.print("[red]Обнаружен ввод с неанглийской раскладкой.[/red]")
console.print("[yellow]Пожалуйста, переключите раскладку на ENG и введите снова.[/yellow]")
def safe_confirm(message: str, **kwargs) -> bool:
"""Безопасный Confirm.ask с защитой от русской раскладки."""
while True:
try:
result = Confirm.ask(message, **kwargs)
return result
except UnicodeDecodeError:
warn_english_only()
def safe_prompt(message: str, **kwargs) -> str:
"""Безопасный Prompt.ask с защитой от русской раскладки."""
while True:
try:
value = Prompt.ask(message, **kwargs)
except UnicodeDecodeError:
warn_english_only()
continue
if isinstance(value, str) and not is_ascii_only(value):
warn_english_only()
continue
return value
if IS_ROOT_DIR:
console.print("[bold red]КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
def print_logo():
logo = Text("""
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
""", style="bold cyan")
console.print(logo)
logo_lines = [
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
]
with Live(refresh_per_second=10) as live:
display = []
for line in logo_lines:
display.append(f"[bold cyan]{line}[/bold cyan]")
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
live.update(panel)
sleep(0.07)
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def list_backups():
if not os.path.isdir(BACK_DIR):
return []
pairs = []
for name in os.listdir(BACK_DIR):
path = os.path.join(BACK_DIR, name)
if os.path.isdir(path):
try:
mtime = os.path.getmtime(path)
except Exception:
mtime = 0
pairs.append((mtime, path))
pairs.sort(reverse=True)
return [p for _, p in pairs]
def prune_old_backups():
backups = list_backups()
for path in backups[3:]:
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
subprocess.run(["sudo", "rm", "-rf", path])
def backup_project():
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
from datetime import datetime
os.makedirs(BACK_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACK_DIR, f"backup-{ts}")
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["rm", "-rf", BACK_DIR])
subprocess.run(["cp", "-r", PROJECT_DIR, BACK_DIR])
console.print(f"[green]✅ Бэкап сохранён в: {BACK_DIR}[/green]")
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
prune_old_backups()
def restore_from_backup():
from datetime import datetime
backups = list_backups()[:3]
if not backups:
console.print(f"[red]❌ Бэкапы не найдены: {BACK_DIR}[/red]")
return
console.print("\n[bold green]Доступные бэкапы:[/bold green]")
shown = []
for idx, path in enumerate(backups, 1):
try:
mtime = os.path.getmtime(path)
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
dt = "unknown"
console.print(f"[cyan]{idx}.[/cyan] {os.path.basename(path)} [dim]{dt}[/dim]")
shown.append((idx, path))
try:
choice = safe_prompt(
"[bold blue]Выберите номер бэкапа[/bold blue]",
choices=[str(i) for i, _ in shown],
)
except Exception:
return
sel_path = shown[int(choice) - 1][1]
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
if not safe_confirm("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
return
if is_service_exists(SERVICE_NAME):
console.print("[blue]Останавливаю службу перед восстановлением...[/blue]")
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
rc = subprocess.run(
["rsync", "-a", "--delete", f"{sel_path}/", f"{PROJECT_DIR}/"],
check=False,
).returncode
if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]✅ Восстановление из бэкапа завершено[/green]")
def auto_update_cli():
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code != 200:
console.print("[red]Не удалось получить обновление CLI[/red]")
return
latest_text = response.text
current_path = os.path.realpath(__file__)
with open(current_path, encoding="utf-8") as f:
current_text = f.read()
if current_text != latest_text:
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text)
os.chmod(current_path, 0o644)
console.print("[green]CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path])
else:
console.print("[green]CLI уже актуален[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
def fix_permissions():
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
try:
user = os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip()
console.log(f"[cyan]Используем пользователь: {user}[/cyan]")
for root, dirs, files in os.walk(PROJECT_DIR):
for dir in dirs:
if dir == "__pycache__":
pycache_path = os.path.join(root, dir)
subprocess.run(["sudo", "rm", "-rf", pycache_path], check=True)
for file in files:
if file.endswith(".pyc"):
pyc_path = os.path.join(root, file)
subprocess.run(["sudo", "rm", "-f", pyc_path], check=True)
console.log("[blue]Изменение владельца на весь проект...[/blue]")
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
console.log("[blue]Изменение прав доступа (u=rwX,go=rX)...[/blue]")
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
launcher_path = os.path.join(PROJECT_DIR, "cli_launcher.py")
if os.path.exists(launcher_path):
console.log("[blue]Установка флага +x для cli_launcher.py...[/blue]")
subprocess.run(["chmod", "+x", launcher_path], check=True)
console.print(f"[green]Все права восстановлены для пользователя [bold]{user}[/bold][/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
def install_rsync_if_needed():
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
console.print("[blue]📦 Установка rsync...[/blue]")
console.print("[blue]Установка rsync...[/blue]")
os.system("sudo apt update && sudo apt install -y rsync")
def clean_project_dir_safe(update_buttons=False, update_img=False):
console.print("[yellow]Очистка проекта перед обновлением...[/yellow]")
preserved_paths = set()
preserved_paths.update([
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, ".git"),
os.path.join(PROJECT_DIR, "modules"),
])
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "modules")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
if not update_img:
preserved_paths.add(os.path.join(PROJECT_DIR, "img"))
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
path = os.path.join(root, file)
if path in preserved_paths:
continue
try:
os.remove(path)
except PermissionError:
subprocess.run(["sudo", "rm", "-f", path])
except Exception as e:
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
for dir in dirs:
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) in [
os.path.join(PROJECT_DIR, "handlers"),
os.path.join(PROJECT_DIR, "img"),
os.path.join(PROJECT_DIR, "modules"),
]:
continue
if os.path.abspath(dir_path).startswith(os.path.join(PROJECT_DIR, "modules") + os.sep):
continue
try:
os.rmdir(dir_path)
except Exception:
subprocess.run(["sudo", "rm", "-rf", dir_path])
def install_git_if_needed():
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
console.print("[blue]Установка Git...[/blue]")
@@ -62,25 +354,55 @@ def install_git_if_needed():
def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
console.print("[blue]Установка зависимостей...[/blue]")
python312_path = shutil.which("python3.12")
if not python312_path:
console.print("[red]Не найден python3.12 в системе[/red]")
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
sys.exit(1)
with Progress(
SpinnerColumn(style="green"),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
try:
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
if os.path.exists("venv"):
shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]")
subprocess.run([python312_path, "-m", "venv", "venv"], check=True)
progress.update(task_id, description="Установка зависимостей...")
subprocess.run(
[os.path.join("venv", "bin", "pip"), "install", "-r", "requirements.txt"],
check=True,
cwd=PROJECT_DIR,
)
progress.update(task_id, description="Установка завершена")
except subprocess.CalledProcessError as e:
progress.update(task_id, description="❌ Ошибка при установке")
console.print(f"[red]❌ Ошибка: {e}[/red]")
def restart_service():
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
def get_local_version():
path = os.path.join(PROJECT_DIR, "bot.py")
if not os.path.isfile(path):
return None
with open(path, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
@@ -106,49 +428,104 @@ def update_from_beta():
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]❗ Версия актуальна. Обновить всё равно?[/yellow]"):
return
else:
console.print("[red]⚠️ Не удалось определить версии.[/red]")
console.print(
Panel(
"[bold red]Обновление на DEV / BETA-ветку[/bold red]\n\n"
"[white]"
"• Dev-ветка может содержать изменения, которые ещё находятся в доработке.\n"
"• Возможны ошибки и непредсказуемое поведение отдельных функций, особенно режима стран.\n\n"
"• BETA-версии бота в первую очередь ориентированы на опытных пользователей, "
"готовых протестировать новые возможности и осознанно работать с обновлённым функционалом.\n"
"[/white]\n\n"
"[yellow]Перед началом обновления CLI автоматически создаёт резервную копию проекта, "
"что позволит при необходимости безопасно восстановиться из бэкапа.[/yellow]",
border_style="red",
title="[bold red]Нестабильная ветка разработки[/bold red]",
padding=(1, 2),
)
)
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
if local_version and remote_version:
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not safe_confirm("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
return
if not safe_confirm(
"[bold red]Продолжить обновление на dev-ветку с учётом возможных особенностей работы?[/bold red]"
):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not safe_confirm("[red]Продолжить обновление?[/red]"):
return
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]📥 Клонируем репозиторий dev во временную папку...[/cyan]")
console.print("[cyan]Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
rsync_cmd = ["rsync", "-a"] + [x for x in exclude_options.split() if x] + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]Обновление с ветки dev завершено.[/green]")
console.print("[green]Обновление с ветки dev завершено.[/green]")
def update_from_release():
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
if not safe_confirm("[yellow]Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not safe_confirm("[red]Вы точно хотите продолжить?[/red]"):
return
update_buttons = safe_confirm("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = safe_confirm("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
try:
response = requests.get(
"https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10
)
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
@@ -159,37 +536,74 @@ def update_from_release():
for idx, tag in enumerate(tag_choices, 1):
console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask(
selected = safe_prompt(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
)
tag_name = tag_choices[int(selected) - 1]
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
if not safe_confirm(f"[yellow]Подтвердите установку релиза {tag_name}[/yellow]"):
return
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
["git", "clone", "--branch", tag_name, GITHUB_REPO, TEMP_DIR],
check=True,
)
console.print("[red]Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
rsync_cmd = ["rsync", "-a"] + exclude_options.split() + [f"{TEMP_DIR}/", f"{PROJECT_DIR}/"]
subprocess.run(rsync_cmd)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
table.add_row("1", "Обновить до BETA")
table.add_row("2", "Обновить до последнего релиза")
table.add_row("2", "Обновить/откатить до релиза")
table.add_row("3", "Назад в меню")
console.print(table)
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
choice = safe_prompt("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
if choice == "1":
update_from_beta()
@@ -198,11 +612,9 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.3.9", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
table.add_row("3", "Перезапустить бота (systemd)")
@@ -210,45 +622,70 @@ def show_menu():
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Выход")
table.add_row("8", "Восстановить из бэкапа")
table.add_row("9", "Выход")
console.print(table)
def main():
if os.geteuid() != 0:
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
sys.exit(1)
os.chdir(PROJECT_DIR)
os.chdir(PROJECT_DIR)
auto_update_cli()
print_logo()
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
try:
while True:
show_menu()
choice = safe_prompt(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 10)],
show_choices=False,
)
if choice == "1":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if safe_confirm("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if safe_confirm("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
subprocess.run(["sudo", "systemctl", "restart", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "4":
if is_service_exists(SERVICE_NAME):
if safe_confirm("[red]Вы уверены, что хотите остановить бота?[/red]"):
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "5":
if is_service_exists(SERVICE_NAME):
subprocess.run([
"sudo",
"journalctl",
"-u",
SERVICE_NAME,
"-n",
"80",
"--no-pager",
])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "6":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "status", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "7":
show_update_menu()
elif choice == "8":
restore_from_backup()
elif choice == "9":
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
View File
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
from database import async_session_maker
from database.db import warm_pool
from database.tariffs import initialize_all_tariff_weights
from .settings.buttons_config import BUTTONS_CONFIG, load_buttons_config, update_buttons_config
from .settings.management_config import MANAGEMENT_CONFIG, load_management_config, update_management_config
from .settings.modes_config import MODES_CONFIG, load_modes_config, update_modes_config
from .settings.money_config import MONEY_CONFIG, load_money_config, update_money_config
from .settings.notifications_config import NOTIFICATIONS_CONFIG, load_notifications_config, update_notifications_config
from .settings.payments_config import PAYMENTS_CONFIG, load_payments_config, update_payments_config
from .settings.providers_order_config import PROVIDERS_ORDER, load_providers_order, update_providers_order
from .settings.tariffs_config import TARIFFS_CONFIG, load_tariffs_config, update_tariffs_config
async def bootstrap() -> None:
await warm_pool()
async with async_session_maker() as session:
await initialize_all_tariff_weights(session)
await load_buttons_config(session)
await load_notifications_config(session)
await load_modes_config(session)
await load_payments_config(session)
await load_providers_order(session)
await load_money_config(session)
await load_management_config(session)
await load_tariffs_config(session)
await session.commit()
+1
View File
@@ -0,0 +1 @@
PAYMENT_SYSTEMS_EXCLUDED = ("referral", "coupon", "cashback", "admin")
Binary file not shown.
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_BUTTONS_CONFIG
BUTTONS_CONFIG: dict[str, bool] = DEFAULT_BUTTONS_CONFIG.copy()
BUTTONS_CONFIG.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
BUTTONS_CONFIG.setdefault("COUPON_BUTTON_ENABLE", True)
async def load_buttons_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
setting = Setting(
key="BUTTONS_CONFIG",
value=buttons_config,
description="Конфигурация кнопок бота",
)
session.add(setting)
else:
stored = setting.value or {}
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(stored)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
setting.value = buttons_config
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
await session.flush()
async def update_buttons_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "BUTTONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="BUTTONS_CONFIG",
value=new_values,
description="Конфигурация кнопок бота",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
buttons_config = DEFAULT_BUTTONS_CONFIG.copy()
buttons_config.update(new_values)
buttons_config.setdefault("ANDROID_TV_BUTTON_ENABLE", False)
buttons_config.setdefault("COUPON_BUTTON_ENABLE", True)
BUTTONS_CONFIG.clear()
BUTTONS_CONFIG.update(buttons_config)
+60
View File
@@ -0,0 +1,60 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MANAGEMENT_CONFIG
MANAGEMENT_CONFIG: dict[str, Any] = DEFAULT_MANAGEMENT_CONFIG.copy()
MANAGEMENT_SETTING_KEY = "MANAGEGENT_CONFIG"
async def load_management_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
setting = Setting(
key=MANAGEMENT_SETTING_KEY,
value=management_config,
description="Конфигурация управления ботом",
)
session.add(setting)
else:
stored = setting.value or {}
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(stored)
setting.value = management_config
MANAGEMENT_CONFIG.clear()
MANAGEMENT_CONFIG.update(management_config)
await session.flush()
async def update_management_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == MANAGEMENT_SETTING_KEY)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key=MANAGEMENT_SETTING_KEY,
value=new_values,
description="Конфигурация управления ботом",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
management_config = DEFAULT_MANAGEMENT_CONFIG.copy()
management_config.update(new_values)
MANAGEMENT_CONFIG.clear()
MANAGEMENT_CONFIG.update(management_config)
+57
View File
@@ -0,0 +1,57 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MODES_CONFIG
MODES_CONFIG: dict[str, bool] = DEFAULT_MODES_CONFIG.copy()
async def load_modes_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
modes_config = DEFAULT_MODES_CONFIG.copy()
setting = Setting(
key="MODES_CONFIG",
value=modes_config,
description="Конфигурация режимов работы бота",
)
session.add(setting)
else:
stored = setting.value or {}
modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(stored)
setting.value = modes_config
MODES_CONFIG.clear()
MODES_CONFIG.update(modes_config)
await session.flush()
async def update_modes_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "MODES_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="MODES_CONFIG",
value=new_values,
description="Конфигурация режимов работы бота",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
modes_config = DEFAULT_MODES_CONFIG.copy()
modes_config.update(new_values)
MODES_CONFIG.clear()
MODES_CONFIG.update(modes_config)
+75
View File
@@ -0,0 +1,75 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_MONEY_CONFIG
MONEY_CONFIG: dict[str, Any] = DEFAULT_MONEY_CONFIG.copy()
def get_currency_mode() -> tuple[str, bool]:
mode_cfg = MONEY_CONFIG.get("CURRENCY_MODE", "RUB")
raw = str(mode_cfg or "RUB").upper()
if raw not in ("RUB", "USD", "RUB+USD", "RUB+USD_ONE_SCREEN"):
raw = "RUB"
one_screen = raw == "RUB+USD_ONE_SCREEN"
if raw in ("RUB+USD", "RUB+USD_ONE_SCREEN"):
base_mode = "RUB+USD"
else:
base_mode = raw
return base_mode, one_screen
async def load_money_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
money_config = DEFAULT_MONEY_CONFIG.copy()
setting = Setting(
key="MONEY_CONFIG",
value=money_config,
description="Конфигурация валютных настроек",
)
session.add(setting)
else:
stored = setting.value or {}
money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(stored)
setting.value = money_config
MONEY_CONFIG.clear()
MONEY_CONFIG.update(money_config)
await session.flush()
async def update_money_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == "MONEY_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="MONEY_CONFIG",
value=new_values,
description="Конфигурация валютных настроек",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
money_config = DEFAULT_MONEY_CONFIG.copy()
money_config.update(new_values)
MONEY_CONFIG.clear()
MONEY_CONFIG.update(money_config)
+59
View File
@@ -0,0 +1,59 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_NOTIFICATIONS_CONFIG
NOTIFICATIONS_CONFIG: dict[str, Any] = DEFAULT_NOTIFICATIONS_CONFIG.copy()
async def load_notifications_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
setting = Setting(
key="NOTIFICATIONS_CONFIG",
value=notifications_config,
description="Конфигурация уведомлений",
)
session.add(setting)
else:
stored = setting.value or {}
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(stored)
setting.value = notifications_config
NOTIFICATIONS_CONFIG.clear()
NOTIFICATIONS_CONFIG.update(notifications_config)
await session.flush()
async def update_notifications_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
stmt = select(Setting).where(Setting.key == "NOTIFICATIONS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="NOTIFICATIONS_CONFIG",
value=new_values,
description="Конфигурация уведомлений",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
notifications_config = DEFAULT_NOTIFICATIONS_CONFIG.copy()
notifications_config.update(new_values)
NOTIFICATIONS_CONFIG.clear()
NOTIFICATIONS_CONFIG.update(notifications_config)
+57
View File
@@ -0,0 +1,57 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
from ..defaults import DEFAULT_PAYMENTS_CONFIG
PAYMENTS_CONFIG: dict[str, bool] = DEFAULT_PAYMENTS_CONFIG.copy()
async def load_payments_config(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
setting = Setting(
key="PAYMENTS_CONFIG",
value=payments_config,
description="Конфигурация платёжных провайдеров",
)
session.add(setting)
else:
stored = setting.value or {}
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(stored)
setting.value = payments_config
PAYMENTS_CONFIG.clear()
PAYMENTS_CONFIG.update(payments_config)
await session.flush()
async def update_payments_config(session: AsyncSession, new_values: dict[str, bool]) -> None:
stmt = select(Setting).where(Setting.key == "PAYMENTS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="PAYMENTS_CONFIG",
value=new_values,
description="Конфигурация платёжных провайдеров",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
payments_config = DEFAULT_PAYMENTS_CONFIG.copy()
payments_config.update(new_values)
PAYMENTS_CONFIG.clear()
PAYMENTS_CONFIG.update(payments_config)
+38
View File
@@ -0,0 +1,38 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
PROVIDERS_ORDER: dict[str, int] = {}
async def load_providers_order(session: AsyncSession) -> None:
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
PROVIDERS_ORDER.clear()
if setting and isinstance(setting.value, dict):
PROVIDERS_ORDER.update({k: int(v) for k, v in setting.value.items()})
await session.flush()
async def update_providers_order(session: AsyncSession, new_order: dict[str, int]) -> None:
stmt = select(Setting).where(Setting.key == "PROVIDERS_ORDER")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="PROVIDERS_ORDER",
value=new_order,
description="Порядок отображения платёжных провайдеров",
)
session.add(setting)
else:
setting.value = new_order
await session.commit()
PROVIDERS_ORDER.clear()
PROVIDERS_ORDER.update(new_order)
+193
View File
@@ -0,0 +1,193 @@
from math import ceil
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Setting
TARIFFS_CONFIG: dict[str, Any] = {
"ALLOW_DOWNGRADE": True,
"KEY_ADDONS_PACK_MODE": "all",
"KEY_ADDONS_PRICE_BASE_MODE": "current",
}
async def load_tariffs_config(session: AsyncSession) -> None:
"""Загружает конфиг тарифов из БД."""
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
tariffs_config = TARIFFS_CONFIG.copy()
setting = Setting(
key="TARIFFS_CONFIG",
value=tariffs_config,
description="Конфигурация тарифов",
)
session.add(setting)
else:
stored = setting.value or {}
tariffs_config = TARIFFS_CONFIG.copy()
tariffs_config.update(stored)
setting.value = tariffs_config
TARIFFS_CONFIG.clear()
TARIFFS_CONFIG.update(tariffs_config)
await session.flush()
async def update_tariffs_config(session: AsyncSession, new_values: dict[str, Any]) -> None:
"""Обновляет конфиг тарифов."""
stmt = select(Setting).where(Setting.key == "TARIFFS_CONFIG")
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(
key="TARIFFS_CONFIG",
value=new_values,
description="Конфигурация тарифов",
)
session.add(setting)
else:
setting.value = new_values
await session.commit()
tariffs_config = TARIFFS_CONFIG.copy()
tariffs_config.update(new_values)
TARIFFS_CONFIG.clear()
TARIFFS_CONFIG.update(tariffs_config)
def calc_extra_devices_price(tariff: dict[str, Any], device_limit: int) -> int:
base_device_limit = int(tariff.get("device_limit") or 1)
extra_devices = max(0, device_limit - base_device_limit)
if extra_devices <= 0:
return 0
step_price = int(tariff.get("device_step_rub") or 0)
overrides = tariff.get("device_overrides") or {}
override_total = overrides.get(str(device_limit))
if override_total is not None:
return int(ceil(float(override_total)))
return int(ceil(extra_devices * step_price))
def calc_extra_traffic_price(tariff: dict[str, Any], traffic_gb: int | None) -> int:
if traffic_gb is None:
return 0
traffic_limit_bytes = tariff.get("traffic_limit")
if traffic_limit_bytes:
base_traffic_gb = ceil(traffic_limit_bytes / 1024 / 1024 / 1024)
else:
base_traffic_gb = 0
step_price = int(tariff.get("traffic_step_rub") or 0)
overrides = tariff.get("traffic_overrides") or {}
override_total = overrides.get(str(traffic_gb))
if override_total is not None:
return int(ceil(float(override_total)))
if traffic_gb == 0:
return 0
extra_gb = max(0, traffic_gb - base_traffic_gb)
if extra_gb <= 0:
return 0
return int(ceil(extra_gb * step_price))
def calculate_config_price(
tariff: dict[str, Any],
duration_days: int,
device_limit: int,
traffic_gb: int | None,
) -> int:
base_duration = int(tariff.get("duration_days") or 0) or duration_days or 30
if base_duration <= 0:
base_duration = duration_days or 30
base_price = int(tariff.get("price_rub") or 0)
duration_multiplier = duration_days / base_duration
base_price_scaled = base_price * duration_multiplier
extra_devices_price = calc_extra_devices_price(tariff, device_limit)
extra_traffic_price = calc_extra_traffic_price(tariff, traffic_gb)
total = base_price_scaled + extra_devices_price + extra_traffic_price
return int(ceil(total))
def normalize_tariff_config(tariff: dict[str, Any]) -> dict[str, Any]:
raw_duration_options = tariff.get("duration_options") or []
duration_options: list[int] = []
for value in raw_duration_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v > 0:
duration_options.append(v)
if not duration_options:
base_duration = int(tariff.get("duration_days") or 0) or 30
duration_options = [base_duration]
duration_options = sorted(set(duration_options))
raw_device_options = tariff.get("device_options") or []
device_options: list[int] = []
for value in raw_device_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v > 0:
device_options.append(v)
if not device_options:
base_device_limit = int(tariff.get("device_limit") or 0)
if base_device_limit > 0:
device_options = [base_device_limit]
else:
device_options = []
device_options = sorted(set(device_options))
raw_traffic_options = tariff.get("traffic_options_gb")
traffic_options_gb: list[int] | None
if raw_traffic_options is None:
traffic_options_gb = None
else:
traffic_values: list[int] = []
has_unlimited = False
for value in raw_traffic_options:
try:
v = int(value)
except (TypeError, ValueError):
continue
if v == 0:
has_unlimited = True
elif v > 0:
traffic_values.append(v)
if not traffic_values and not has_unlimited:
traffic_options_gb = None
else:
unique_values = sorted(set(traffic_values))
if has_unlimited:
traffic_options_gb = [0] + unique_values
else:
traffic_options_gb = unique_values
return {
"duration_options": duration_options,
"device_options": device_options,
"traffic_options_gb": traffic_options_gb,
}
-1779
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
from .bans import *
from .coupons import *
from .db import async_session_maker
from .gifts import *
from .hot_leads import *
from .init_db import *
from .keys import *
from .notifications import *
from .payments import *
from .referrals import *
from .servers import *
from .statistics import *
from .tariffs import *
from .temporary_data import *
from .tracking_sources import *
from .users import *
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import BlockedUser
async def create_blocked_user(session: AsyncSession, tg_id: int):
stmt = insert(BlockedUser).values(tg_id=tg_id).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await session.execute(stmt)
await session.commit()
+160
View File
@@ -0,0 +1,160 @@
from datetime import datetime
from sqlalchemy import case, delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Coupon, CouponUsage
from logger import logger
async def create_coupon(
session: AsyncSession,
code: str,
amount: int | None,
usage_limit: int,
days: int | None = None,
new_users_only: bool = False,
percent: int | None = None,
max_discount_amount: int | None = None,
min_order_amount: int | None = None,
) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
return False
if percent is not None:
try:
percent_value = int(percent)
except (TypeError, ValueError):
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
return False
if percent_value <= 0 or percent_value > 100:
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
return False
if (amount or 0) > 0 or (days or 0) > 0:
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
return False
await session.execute(
insert(Coupon).values(
code=code,
amount=int(amount) if amount is not None else 0,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
new_users_only=new_users_only,
percent=percent,
max_discount_amount=max_discount_amount,
min_order_amount=min_order_amount,
)
)
await session.commit()
logger.info(f"[Coupon] ✅ Купон {code} успешно создан.")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[Coupon] ❌ Ошибка при создании купона {code}: {e}")
return False
async def get_coupon_by_code(session: AsyncSession, code: str) -> Coupon | None:
stmt = select(Coupon).where(Coupon.code == code)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_coupons(session: AsyncSession, page: int = 1, per_page: int = 10) -> dict:
offset = (page - 1) * per_page
stmt = select(Coupon).order_by(Coupon.id.desc()).offset(offset).limit(per_page)
result = await session.execute(stmt)
coupons = result.scalars().all()
count_stmt = select(func.count()).select_from(Coupon)
total = await session.scalar(count_stmt)
pages = -(-total // per_page)
return {
"coupons": [c.to_dict() for c in coupons],
"total": total,
"pages": pages,
"current_page": page,
}
async def delete_coupon(session: AsyncSession, code: str) -> bool:
result = await session.execute(select(Coupon).where(Coupon.code == code))
coupon = result.scalar_one_or_none()
if not coupon:
logger.info(f"❌ Купон {code} не найден")
return False
await session.execute(delete(CouponUsage).where(CouponUsage.coupon_id == coupon.id))
await session.delete(coupon)
await session.commit()
logger.info(f"🗑 Купон {code} удалён вместе с его использованиями")
return True
async def create_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int):
try:
stmt = insert(CouponUsage).values(coupon_id=coupon_id, user_id=user_id, used_at=datetime.utcnow())
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Купон {coupon_id} использован пользователем {user_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении использования купона: {e}")
await session.rollback()
raise
async def check_coupon_usage(session: AsyncSession, coupon_id: int, user_id: int) -> bool:
stmt = select(CouponUsage).where(CouponUsage.coupon_id == coupon_id, CouponUsage.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
try:
await session.execute(
update(Coupon)
.where(Coupon.id == coupon_id)
.values(
usage_count=Coupon.usage_count + 1,
is_used=case((Coupon.usage_count + 1 >= Coupon.usage_limit, True), else_=False),
)
)
await session.commit()
logger.info(f"🔁 Обновлён счётчик купона {coupon_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
await session.rollback()
raise
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
percent = coupon.percent
if percent is None:
return price_rub, 0
if coupon.min_order_amount is not None and price_rub < int(coupon.min_order_amount):
return price_rub, 0
discount = (price_rub * int(percent)) // 100
if coupon.max_discount_amount is not None:
discount = min(discount, int(coupon.max_discount_amount))
final_price = price_rub - discount
if final_price < 0:
final_price = 0
return final_price, discount
+47
View File
@@ -0,0 +1,47 @@
import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from config import DATABASE_URL, DB_MAX_OVERFLOW, DB_POOL_SIZE
CONCURRENT_UPDATES_LIMIT = DB_POOL_SIZE + DB_MAX_OVERFLOW
MAX_UPDATE_AGE_SEC = 28
engine = create_async_engine(
DATABASE_URL,
echo=False,
future=True,
pool_size=DB_POOL_SIZE,
max_overflow=DB_MAX_OVERFLOW,
pool_timeout=60,
pool_pre_ping=True,
pool_recycle=300,
)
async_session_maker = async_sessionmaker(
bind=engine,
expire_on_commit=False,
class_=AsyncSession,
)
Base = declarative_base()
WARM_POOL_COUNT = 10
async def warm_pool() -> None:
"""
Прогревает пул соединений при старте.
"""
async def _one() -> None:
async with async_session_maker() as session:
await session.execute(text("SELECT 1"))
count = min(WARM_POOL_COUNT, DB_POOL_SIZE)
if count <= 0:
return
await asyncio.gather(*[asyncio.create_task(_one()) for _ in range(count)])
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime
from sqlalchemy import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift
from logger import logger
async def store_gift_link(
session: AsyncSession,
gift_id: str,
sender_tg_id: int,
selected_months: int,
expiry_time: datetime,
gift_link: str,
tariff_id: int | None = None,
is_unlimited: bool = False,
max_usages: int | None = None,
selected_device_limit: int | None = None,
selected_traffic_gb: int | None = None,
selected_price_rub: int | None = None,
):
try:
stmt = insert(Gift).values(
gift_id=gift_id,
sender_tg_id=sender_tg_id,
recipient_tg_id=None,
selected_months=selected_months,
expiry_time=expiry_time,
gift_link=gift_link,
created_at=datetime.utcnow(),
is_used=False,
tariff_id=tariff_id,
is_unlimited=is_unlimited,
max_usages=max_usages,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=selected_price_rub,
)
await session.execute(stmt)
await session.commit()
logger.info(
f"🎁 Подарок {gift_id} сохранён "
f"(tariff_id={tariff_id}, max_usages={max_usages}, "
f"device={selected_device_limit}, traffic={selected_traffic_gb}, price={selected_price_rub})"
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
raise
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession):
now_ms = func.extract("epoch", func.now()) * 1000
sub_active = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
stmt = (
select(Payment.tg_id)
.join(User, User.tg_id == Payment.tg_id)
.distinct()
.where(User.trial == 1)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(~Payment.tg_id.in_(sub_active))
)
result = await session.execute(stmt)
return result.scalars().all()
+128
View File
@@ -0,0 +1,128 @@
import json
import sqlite3
import time
from datetime import datetime
from itertools import cycle
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import USE_COUNTRY_SELECTION
from core.bootstrap import MODES_CONFIG
from database.models import Key, Server, User
async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple[int, int]:
imported = 0
skipped = 0
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
result = await session.execute(
select(Server.server_name).where(Server.enabled.is_(True), Server.panel_type == "3x-ui")
)
else:
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled.is_(True), Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
server_ids = [row[0] for row in result.fetchall()]
if not server_ids:
raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui")
server_cycle = cycle(server_ids)
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT id, remark, settings FROM inbounds")
inbounds = cursor.fetchall()
except Exception as e:
raise RuntimeError(f"Не удалось прочитать SQLite: {e}")
finally:
conn.close()
parsed_clients = []
for inbound_id, _remark, settings_raw in inbounds:
try:
settings = json.loads(settings_raw)
clients = settings.get("clients", [])
for c in clients:
expiry = c.get("expiryTime")
c["expiryTime"] = int(float(expiry)) if expiry else 0
c["limitIp"] = int(c.get("limitIp", 0) or 0)
c["inbound_id"] = inbound_id
parsed_clients.append(c)
except Exception:
continue
now_ts = int(time.time() * 1000)
for c in parsed_clients:
tg_id = c.get("tgId")
client_id = str(c.get("id"))
email = c.get("email")
expiry_time = int(c.get("expiryTime") or now_ts)
created_at = now_ts
server_id = next(server_cycle)
if not tg_id or not client_id:
continue
user_exists = await session.execute(select(User).where(User.tg_id == tg_id))
if not user_exists.scalar():
try:
session.add(
User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте пользователя tg_id={tg_id}") from e
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
if key_exists.scalar():
skipped += 1
continue
try:
session.add(
Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_time,
key="",
server_id=server_id,
remnawave_link=None,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
)
imported += 1
except SQLAlchemyError as e:
await session.rollback()
raise RuntimeError(f"Ошибка при импорте ключа client_id={client_id}") from e
await session.commit()
return imported, skipped
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.tg_id == 0))
if not result.scalar_one_or_none():
session.add(
User(
tg_id=0,
username="system",
first_name="System",
is_bot=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
for tg_id in ADMIN_ID:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if not result.scalar_one_or_none():
session.add(
Admin(
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
)
)
await session.commit()
+289
View File
@@ -0,0 +1,289 @@
from datetime import datetime
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, User
from logger import logger
async def store_key(
session: AsyncSession,
tg_id: int,
client_id: str,
email: str,
expiry_time: int,
key: str,
server_id: str,
remnawave_link: str = None,
tariff_id: int | None = None,
alias: str | None = None,
selected_device_limit: int | None = None,
selected_traffic_limit: int | None = None,
selected_price_rub: int | None = None,
current_device_limit: int | None = None,
current_traffic_limit: int | None = None,
):
"""Сохраняет или обновляет ключ подписки."""
try:
exists = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
existing_key = exists.scalar_one_or_none()
if existing_key:
values: dict = {
"email": email,
"expiry_time": expiry_time,
"key": key,
"server_id": server_id,
"remnawave_link": remnawave_link,
"tariff_id": tariff_id,
"alias": alias,
}
if selected_device_limit is not None:
values["selected_device_limit"] = selected_device_limit
if selected_traffic_limit is not None:
values["selected_traffic_limit"] = selected_traffic_limit
if selected_price_rub is not None:
values["selected_price_rub"] = selected_price_rub
if current_device_limit is not None:
values["current_device_limit"] = current_device_limit
if current_traffic_limit is not None:
values["current_traffic_limit"] = current_traffic_limit
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(**values))
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
if current_device_limit is None:
current_device_limit = selected_device_limit
if current_traffic_limit is None:
current_traffic_limit = selected_traffic_limit
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=int(datetime.utcnow().timestamp() * 1000),
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
selected_device_limit=selected_device_limit,
selected_traffic_limit=selected_traffic_limit,
selected_price_rub=selected_price_rub,
current_device_limit=current_device_limit,
current_traffic_limit=current_traffic_limit,
)
session.add(new_key)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
await session.commit()
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
raise
async def get_keys(session: AsyncSession, tg_id: int):
result = await session.execute(select(Key).where(Key.tg_id == tg_id))
return result.scalars().all()
async def get_all_keys(session: AsyncSession):
result = await session.execute(select(Key))
return result.scalars().all()
async def get_key_by_server(session: AsyncSession, tg_id: int, client_id: str):
stmt = select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_key_details(session: AsyncSession, email: str) -> dict | None:
"""Возвращает подробную информацию о ключе по email."""
stmt = select(Key, User).join(User, Key.tg_id == User.tg_id).where(Key.email == email)
result = await session.execute(stmt)
row = result.first()
if not row:
return None
key, user = row
expiry_date = datetime.utcfromtimestamp(key.expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>Ключ истек.</b>"
elif time_left.days > 0:
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
return {
"key": key.key,
"remnawave_link": key.remnawave_link,
"server_id": key.server_id,
"created_at": key.created_at,
"expiry_time": key.expiry_time,
"client_id": key.client_id,
"tg_id": user.tg_id,
"email": key.email,
"is_frozen": key.is_frozen,
"balance": user.balance,
"alias": key.alias,
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
"days_left_message": days_left_message,
"link": key.key or key.remnawave_link,
"cluster_name": key.server_id,
"location_name": key.server_id,
"tariff_id": key.tariff_id,
"selected_device_limit": key.selected_device_limit,
"selected_traffic_limit": key.selected_traffic_limit,
"selected_price_rub": key.selected_price_rub,
"current_device_limit": key.current_device_limit,
"current_traffic_limit": key.current_traffic_limit,
}
async def get_key_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(func.count()).select_from(Key).where(Key.tg_id == tg_id))
return result.scalar() or 0
async def delete_key(session: AsyncSession, identifier: int | str, commit: bool = True):
stmt = delete(Key).where(Key.tg_id == identifier if str(identifier).isdigit() else Key.client_id == identifier)
await session.execute(stmt)
if commit:
await session.commit()
logger.info(f"Ключ с идентификатором {identifier} удалён")
async def update_key_expiry(session: AsyncSession, client_id: str, new_expiry_time: int):
await session.execute(update(Key).where(Key.client_id == client_id).values(expiry_time=new_expiry_time))
await session.commit()
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
async def get_client_id_by_email(session: AsyncSession, email: str):
result = await session.execute(select(Key.client_id).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(notified=True))
await session.commit()
async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str, time_left: int):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = TRUE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
)
async def mark_key_as_unfrozen(
session: AsyncSession,
tg_id: int,
client_id: str,
new_expiry_time: int,
):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = FALSE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
)
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await session.commit()
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
async def get_subscription_link(session: AsyncSession, email: str) -> str | None:
result = await session.execute(select(func.coalesce(Key.key, Key.remnawave_link)).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
await session.commit()
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
res = await session.execute(q)
await session.commit()
return res.scalar_one_or_none() is not None
async def save_key_config_with_mode(
session: AsyncSession,
email: str,
selected_devices: int | None,
selected_traffic_gb: int | None,
total_price: int,
has_device_choice: bool,
has_traffic_choice: bool,
config_mode: str,
) -> None:
values: dict = {}
if config_mode == "pack":
if has_device_choice and selected_devices is not None:
values["current_device_limit"] = int(selected_devices)
if has_traffic_choice and selected_traffic_gb is not None:
values["current_traffic_limit"] = int(selected_traffic_gb)
else:
device_val = int(selected_devices) if selected_devices is not None and has_device_choice else None
traffic_val = int(selected_traffic_gb) if selected_traffic_gb is not None and has_traffic_choice else None
values["selected_device_limit"] = device_val
values["selected_traffic_limit"] = traffic_val
values["selected_price_rub"] = int(total_price)
values["current_device_limit"] = device_val
values["current_traffic_limit"] = traffic_val
if not values:
return
await session.execute(update(Key).where(Key.email == email).values(**values))
async def reset_key_current_limits_to_selected(session: AsyncSession, client_id: str):
"""Сбрасывает текущие лимиты к выбранным для ключа."""
await session.execute(
text(
"""
UPDATE keys
SET current_device_limit = selected_device_limit,
current_traffic_limit = selected_traffic_limit
WHERE client_id = :client_id
"""
),
{"client_id": client_id},
)
await session.commit()
logger.info(f"Текущие лимиты ключа {client_id} сброшены к выбранным")
+303
View File
@@ -0,0 +1,303 @@
import secrets
import uuid
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
class DictLikeMixin:
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, default=None):
return getattr(self, key, default)
def to_dict(self):
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
class User(DictLikeMixin, Base):
__tablename__ = "users"
tg_id = Column(BigInteger, primary_key=True)
username = Column(String)
first_name = Column(String)
last_name = Column(String)
language_code = Column(String)
is_bot = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
trial = Column(Integer, default=0)
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
source_code = Column(
String,
ForeignKey(
"tracking_sources.code",
ondelete="SET NULL",
onupdate="CASCADE",
),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False)
client_id = Column(String, primary_key=True)
email = Column(String, unique=True)
created_at = Column(BigInteger)
expiry_time = Column(BigInteger)
key = Column(String)
server_id = Column(String)
remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_limit = Column(BigInteger, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
current_device_limit = Column(Integer, nullable=True)
current_traffic_limit = Column(BigInteger, nullable=True)
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True)
name = Column(String)
group_code = Column(String)
duration_days = Column(Integer)
price_rub = Column(Integer)
traffic_limit = Column(BigInteger, nullable=True)
device_limit = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
subgroup_title = Column(String, nullable=True)
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
external_squad: Mapped[str | None] = mapped_column(String(64), nullable=True)
configurable = Column(Boolean, nullable=False, server_default="false")
device_options = Column(JSONB, nullable=True)
traffic_options_gb = Column(JSONB, nullable=True)
device_step_rub = Column(Integer, nullable=True)
device_overrides = Column(JSONB, nullable=True)
traffic_step_rub = Column(Integer, nullable=True)
traffic_overrides = Column(JSONB, nullable=True)
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
class ServerSubgroup(DictLikeMixin, Base):
__tablename__ = "server_subgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
subgroup_title = Column(String, nullable=False)
server = relationship("Server", back_populates="subgroups")
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
class ServerSpecialgroup(DictLikeMixin, Base):
__tablename__ = "server_specialgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
server = relationship("Server")
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
amount = Column(Float)
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
original_amount = Column(Numeric(18, 8), nullable=True)
currency = Column(String(10), nullable=False, server_default="RUB")
payment_id = Column(String(128), nullable=True, index=True)
metadata_ = Column("metadata", JSONB, nullable=True)
class Coupon(DictLikeMixin, Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
amount = Column(Integer)
usage_limit = Column(Integer)
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
new_users_only = Column(Boolean, nullable=False, server_default=text("false"))
percent = Column(Integer, nullable=True)
max_discount_amount = Column(Integer, nullable=True)
min_order_amount = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class Referral(DictLikeMixin, Base):
__tablename__ = "referrals"
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
is_unlimited = Column(Boolean, default=False)
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
selected_device_limit = Column(Integer, nullable=True)
selected_traffic_gb = Column(Integer, nullable=True)
selected_price_rub = Column(Integer, nullable=True)
class GiftUsage(DictLikeMixin, Base):
__tablename__ = "gift_usages"
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
tg_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class ManualBan(DictLikeMixin, Base):
__tablename__ = "manual_bans"
tg_id = Column(BigInteger, primary_key=True)
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
reason = Column(Text)
banned_by = Column(BigInteger)
until = Column(DateTime(timezone=True), nullable=True)
class TemporaryData(DictLikeMixin, Base):
__tablename__ = "temporary_data"
tg_id = Column(BigInteger, primary_key=True)
state = Column(String)
data = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow)
class BlockedUser(DictLikeMixin, Base):
__tablename__ = "blocked_users"
tg_id = Column(BigInteger, primary_key=True)
class TrackingSource(DictLikeMixin, Base):
__tablename__ = "tracking_sources"
id = Column(Integer, primary_key=True)
name = Column(String)
code = Column(String, unique=True)
type = Column(String)
created_by = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
class Admin(Base):
__tablename__ = "admins"
tg_id = Column(BigInteger, primary_key=True)
token = Column(String, unique=True, nullable=True)
description = Column(String, nullable=True)
role = Column(String, nullable=False, default="admin")
added_at = Column(DateTime, default=datetime.utcnow)
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
class Setting(DictLikeMixin, Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(JSONB, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+179
View File
@@ -0,0 +1,179 @@
from datetime import datetime, timedelta
from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from core.bootstrap import NOTIFICATIONS_CONFIG
from database.models import Key, Notification, User
from logger import logger
async def add_notification(session: AsyncSession, tg_id: int, notification_type: str):
try:
stmt = (
insert(Notification)
.values(
tg_id=tg_id,
notification_type=notification_type,
last_notification_time=datetime.utcnow(),
)
.on_conflict_do_update(
index_elements=[Notification.tg_id, Notification.notification_type],
set_={"last_notification_time": datetime.utcnow()},
)
)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Добавлено уведомление {notification_type} для пользователя {tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении уведомления: {e}")
await session.rollback()
raise
async def delete_notification(session: AsyncSession, tg_id: int, notification_type: str):
await session.execute(
delete(Notification).where(
Notification.tg_id == tg_id,
Notification.notification_type == notification_type,
)
)
await session.commit()
logger.debug(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
async def check_notification_time(session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12) -> bool:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
last_time = result.scalar_one_or_none()
if not last_time:
return True
return datetime.utcnow() - last_time > timedelta(hours=hours)
async def get_last_notification_time(session: AsyncSession, tg_id: int, notification_type: str) -> int | None:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
ts = result.scalar_one_or_none()
if ts:
return int(ts.timestamp() * 1000)
return None
async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
try:
result = await session.execute(
select(Notification.notification_type, Notification.last_notification_time)
.where(Notification.tg_id == tg_id)
.where(Notification.notification_type.in_(["hot_lead_step_2", "hot_lead_step_3"]))
.order_by(Notification.last_notification_time.desc())
.limit(1)
)
row = result.first()
if not row:
return {"available": False}
notification_type, last_time = row
hours = int(NOTIFICATIONS_CONFIG.get("DISCOUNT_ACTIVE_HOURS", DISCOUNT_ACTIVE_HOURS))
expires_at = last_time + timedelta(hours=hours)
current_time = datetime.utcnow()
if current_time > expires_at:
return {"available": False}
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
return {
"available": True,
"type": notification_type,
"tariff_group": tariff_group,
"expires_at": expires_at,
}
except Exception as e:
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
await session.rollback()
return {"available": False}
async def check_notifications_bulk(
session: AsyncSession,
notification_type: str,
hours: int,
tg_ids: list[int] = None,
emails: list[str] = None,
) -> list[dict]:
from sqlalchemy import select
from database.models import BlockedUser, Notification
try:
now = datetime.utcnow()
subq_last_notification = (
select(Notification.tg_id, func.max(Notification.last_notification_time).label("last_notification_time"))
.where(Notification.notification_type == notification_type)
.group_by(Notification.tg_id)
.subquery()
)
stmt = (
select(
User.tg_id,
Key.email,
User.username,
User.first_name,
User.last_name,
subq_last_notification.c.last_notification_time,
)
.outerjoin(Key, Key.tg_id == User.tg_id)
.outerjoin(subq_last_notification, subq_last_notification.c.tg_id == User.tg_id)
)
if notification_type == "inactive_trial":
stmt = stmt.where(
and_(
User.trial.in_([0, -1]),
~User.tg_id.in_(select(BlockedUser.tg_id)),
~User.tg_id.in_(select(Key.tg_id.distinct())),
)
)
if tg_ids:
stmt = stmt.where(User.tg_id.in_(tg_ids))
if emails:
stmt = stmt.where(Key.email.in_(emails))
result = await session.execute(stmt)
users = []
for row in result:
last_time = row.last_notification_time
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
if can_notify:
users.append({
"tg_id": row.tg_id,
"email": row.email,
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": int(last_time.timestamp() * 1000) if last_time else None,
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
return users
except Exception as e:
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
await session.rollback()
return []
+214
View File
@@ -0,0 +1,214 @@
from datetime import datetime, timedelta
from pytz import timezone
from sqlalchemy import and_, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Payment
from logger import logger
MOSCOW_TZ = timezone("Europe/Moscow")
async def add_payment(
session: AsyncSession,
tg_id: int,
amount: float,
payment_system: str,
*,
status: str = "success",
currency: str = "RUB",
payment_id: str | None = None,
metadata: dict | None = None,
original_amount: float | None = None,
) -> int:
try:
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
stmt = (
insert(Payment)
.values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status=status,
created_at=now_moscow,
currency=currency,
payment_id=payment_id,
metadata_=metadata,
original_amount=original_amount,
)
.returning(Payment.id)
)
result = await session.execute(stmt)
internal_id = result.scalar_one()
logger.info(
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
)
return internal_id
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"Ошибка при добавлении платежа: {e}")
raise
async def get_last_payments(
session: AsyncSession,
tg_id: int,
limit: int = 3,
statuses: list[str] | None = None,
):
query = select(Payment).where(Payment.tg_id == tg_id)
if statuses:
query = query.where(Payment.status.in_(statuses))
query = query.order_by(Payment.created_at.desc()).limit(limit)
result = await session.execute(query)
payments = result.scalars().all()
return [
{
"id": p.id,
"tg_id": p.tg_id,
"amount": p.amount,
"currency": p.currency,
"status": p.status,
"payment_system": p.payment_system,
"payment_id": p.payment_id,
"created_at": p.created_at,
"metadata": p.metadata_,
"original_amount": p.original_amount,
}
for p in payments
]
async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | None:
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if not payment:
return None
return {
"id": payment.id,
"tg_id": payment.tg_id,
"amount": payment.amount,
"currency": payment.currency,
"status": payment.status,
"payment_system": payment.payment_system,
"payment_id": payment.payment_id,
"created_at": payment.created_at,
"metadata": payment.metadata_,
"original_amount": payment.original_amount,
}
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
await session.rollback()
return None
async def update_payment_status(
session: AsyncSession,
internal_id: int,
new_status: str,
*,
payment_id: str | None = None,
metadata_patch: dict | None = None,
) -> bool:
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if not payment:
logger.info(f"Не удалось сменить статус: платёж id={internal_id} не найден")
return False
payment.status = new_status
if payment_id is not None:
payment.payment_id = payment_id
if metadata_patch:
base = payment.metadata_ or {}
base.update(metadata_patch)
payment.metadata_ = base
await session.commit()
logger.info(f"Статус платежа id={internal_id} изменён на {new_status}")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"Ошибка при смене статуса платежа id={internal_id}: {e}")
return False
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
try:
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
payment = result.scalar_one_or_none()
if not payment:
return None
return {
"id": payment.id,
"tg_id": payment.tg_id,
"amount": payment.amount,
"currency": payment.currency,
"status": payment.status,
"payment_system": payment.payment_system,
"payment_id": payment.payment_id,
"created_at": payment.created_at,
"metadata": payment.metadata_,
"original_amount": payment.original_amount,
}
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
await session.rollback()
return None
async def cancel_expired_pending_payments(session: AsyncSession) -> int:
cutoff = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=60)
stmt = (
update(Payment)
.where(
and_(
Payment.status.in_(("pending", "issued", "processing", "awaiting_choice")),
Payment.created_at < cutoff,
)
)
.values(status="cancelled")
)
res = await session.execute(stmt)
await session.commit()
affected = res.rowcount or 0
return affected
async def get_all_payments(
session: AsyncSession,
tg_id: int,
statuses: list[str] | None = None,
) -> list[dict]:
query = select(Payment).where(Payment.tg_id == tg_id)
if statuses:
query = query.where(Payment.status.in_(statuses))
query = query.order_by(Payment.created_at.desc())
result = await session.execute(query)
payments = result.scalars().all()
return [
{
"id": p.id,
"tg_id": p.tg_id,
"amount": p.amount,
"currency": p.currency,
"status": p.status,
"payment_system": p.payment_system,
"payment_id": p.payment_id,
"created_at": p.created_at,
"metadata": p.metadata_,
"original_amount": p.original_amount,
}
for p in payments
]
+257
View File
@@ -0,0 +1,257 @@
from sqlalchemy import and_, desc, func, insert, select, text, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import CHECK_REFERRAL_REWARD_ISSUED, REFERRAL_BONUS_PERCENTAGES
from core.bootstrap import BUTTONS_CONFIG
from database.models import Referral
from logger import logger
async def add_referral(session: AsyncSession, referred_tg_id: int, referrer_tg_id: int):
try:
if referred_tg_id == referrer_tg_id:
logger.warning(f"⚠️ Попытка самореферала: {referred_tg_id}")
return
stmt = insert(Referral).values(referred_tg_id=referred_tg_id, referrer_tg_id=referrer_tg_id)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Добавлена реферальная связь: {referred_tg_id}{referrer_tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении реферала: {e}")
await session.rollback()
raise
async def get_referral_by_referred_id(session: AsyncSession, referred_tg_id: int) -> dict | None:
stmt = select(Referral).where(Referral.referred_tg_id == referred_tg_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return dict(row.__dict__) if row else None
async def get_total_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
stmt = select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == referrer_tg_id)
result = await session.execute(stmt)
return result.scalar()
async def get_active_referrals(session: AsyncSession, referrer_tg_id: int) -> int:
stmt = (
select(func.count())
.select_from(Referral)
.where(
and_(
Referral.referrer_tg_id == referrer_tg_id,
Referral.reward_issued is True,
)
)
)
result = await session.execute(stmt)
return result.scalar()
async def mark_referral_reward_issued(session: AsyncSession, referred_tg_id: int):
await session.execute(update(Referral).where(Referral.referred_tg_id == referred_tg_id).values(reward_issued=True))
await session.commit()
async def get_total_referral_bonus(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> float:
referral_enabled = bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", True))
if not referral_enabled:
logger.debug("Реферальная программа отключена, бонусы не начисляются")
return 0.0
if CHECK_REFERRAL_REWARD_ISSUED:
bonus_cte = """
WITH RECURSIVE
referral_levels AS (
SELECT
referred_tg_id,
referrer_tg_id,
1 AS level
FROM referrals
WHERE referrer_tg_id = :tg_id AND reward_issued = TRUE
UNION
SELECT
r.referred_tg_id,
r.referrer_tg_id,
rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels AND r.reward_issued = TRUE
),
earliest_payments AS (
SELECT DISTINCT ON (tg_id) tg_id, amount, created_at
FROM payments
WHERE status = 'success'
AND payment_system NOT IN ('coupon', 'admin', 'referral')
ORDER BY tg_id, created_at
)
"""
bonus_query = (
bonus_cte
+ f"""
SELECT
COALESCE(SUM(
CASE
{
" ".join([
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * ep.amount"
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
for level in REFERRAL_BONUS_PERCENTAGES
])
}
ELSE 0
END
), 0) AS total_bonus
FROM referral_levels rl
JOIN earliest_payments ep ON rl.referred_tg_id = ep.tg_id
WHERE rl.level <= :max_levels
"""
)
else:
bonus_cte = """
WITH RECURSIVE
referral_levels AS (
SELECT
referred_tg_id,
referrer_tg_id,
1 AS level
FROM referrals
WHERE referrer_tg_id = :tg_id
UNION
SELECT
r.referred_tg_id,
r.referrer_tg_id,
rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels
)
"""
bonus_query = (
bonus_cte
+ f"""
SELECT
COALESCE(SUM(
CASE
{
" ".join([
f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]} * p.amount"
if isinstance(REFERRAL_BONUS_PERCENTAGES[level], float)
else f"WHEN rl.level = {level} THEN {REFERRAL_BONUS_PERCENTAGES[level]}"
for level in REFERRAL_BONUS_PERCENTAGES
])
}
ELSE 0
END
), 0) AS total_bonus
FROM referral_levels rl
JOIN payments p ON rl.referred_tg_id = p.tg_id
WHERE p.status = 'success'
AND p.payment_system NOT IN ('coupon', 'admin', 'referral')
AND rl.level <= :max_levels
"""
)
result = await session.execute(
text(bonus_query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
{"tg_id": referrer_tg_id, "max_levels": max_levels},
)
total_bonus_raw = result.scalar()
total_bonus = round(float(total_bonus_raw or 0), 2)
logger.debug(f"Получена общая сумма бонусов от рефералов: {total_bonus}")
return total_bonus
async def get_referrals_by_level(session: AsyncSession, referrer_tg_id: int, max_levels: int) -> dict:
query = """
WITH RECURSIVE referral_levels AS (
SELECT referred_tg_id, referrer_tg_id, 1 AS level
FROM referrals
WHERE referrer_tg_id = :referrer_tg_id
UNION
SELECT r.referred_tg_id, r.referrer_tg_id, rl.level + 1
FROM referrals r
JOIN referral_levels rl ON r.referrer_tg_id = rl.referred_tg_id
WHERE rl.level < :max_levels
)
SELECT level,
COUNT(*) AS level_count,
COUNT(CASE WHEN reward_issued THEN 1 END) AS active_level_count
FROM referral_levels rl
JOIN referrals r ON rl.referred_tg_id = r.referred_tg_id
GROUP BY level
ORDER BY level
"""
result = await session.execute(
text(query), # nosemgrep: python.sqlalchemy.security.audit.avoid-sqlalchemy-text.avoid-sqlalchemy-text
{"referrer_tg_id": referrer_tg_id, "max_levels": max_levels},
)
return {
row["level"]: {
"total": row["level_count"],
"active": row["active_level_count"],
}
for row in result.mappings()
}
async def get_referral_stats(session: AsyncSession, referrer_tg_id: int):
try:
logger.info(f"[ReferralStats] Получение статистики для пользователя {referrer_tg_id}")
total_referrals = await get_total_referrals(session, referrer_tg_id)
active_referrals = await get_active_referrals(session, referrer_tg_id)
max_levels = len(REFERRAL_BONUS_PERCENTAGES)
referrals_by_level = await get_referrals_by_level(session, referrer_tg_id, max_levels)
total_referral_bonus = await get_total_referral_bonus(session, referrer_tg_id, max_levels)
return {
"total_referrals": total_referrals,
"active_referrals": active_referrals,
"referrals_by_level": referrals_by_level,
"total_referral_bonus": total_referral_bonus,
}
except Exception as e:
logger.error(f"[ReferralStats] Ошибка при получении статистики для пользователя {referrer_tg_id}: {e}")
await session.rollback()
raise
async def get_user_referral_count(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == tg_id))
return result.scalar_one() or 0
async def get_referral_position(session: AsyncSession, referral_count: int) -> int:
subq = (
select(Referral.referrer_tg_id)
.group_by(Referral.referrer_tg_id)
.having(func.count() > referral_count)
.subquery()
)
query = select(func.count()).select_from(subq)
result = await session.execute(query)
count = result.scalar() or 0
return count + 1
async def get_top_referrals(session: AsyncSession, limit: int = 5):
query = (
select(Referral.referrer_tg_id, func.count().label("referral_count"))
.group_by(Referral.referrer_tg_id)
.order_by(desc("referral_count"))
.limit(limit)
)
result = await session.execute(query)
return [{"referrer_tg_id": row.referrer_tg_id, "referral_count": row.referral_count} for row in result.all()]
+406
View File
@@ -0,0 +1,406 @@
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
from logger import logger
async def create_server(
session: AsyncSession,
cluster_name: str,
server_name: str,
api_url: str,
subscription_url: str,
inbound_id: str,
):
try:
stmt = insert(Server).values(
cluster_name=cluster_name,
server_name=server_name,
api_url=api_url,
subscription_url=subscription_url,
inbound_id=inbound_id,
)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Сервер {server_name} добавлен в кластер {cluster_name}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении сервера {server_name}: {e}")
await session.rollback()
raise
async def delete_server(session: AsyncSession, server_name: str):
try:
stmt = delete(Server).where(Server.server_name == server_name)
await session.execute(stmt)
await session.commit()
logger.info(f"🗑 Сервер {server_name} удалён")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при удалении сервера {server_name}: {e}")
await session.rollback()
raise
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
from handlers.utils import ALLOWED_GROUP_CODES
try:
stmt = select(Server)
result = await session.execute(stmt)
servers = result.scalars().all()
ids = [s.id for s in servers]
subs_map = {}
tariffs_map = {}
if ids:
r = await session.execute(
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
)
for sid, sg in r.all():
if sg and sg.isdigit():
tariffs_map.setdefault(sid, []).append(int(sg))
else:
subs_map.setdefault(sid, []).append(sg)
groups_map = {}
if ids:
r2 = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(ids)
)
)
for sid, gc in r2.all():
groups_map.setdefault(sid, []).append(gc)
allowed = set(ALLOWED_GROUP_CODES)
grouped = {}
for s in servers:
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
special = sorted({g for g in groups_map.get(s.id, []) if g in allowed})
grouped.setdefault(cluster, []).append({
"server_name": s.server_name,
"api_url": s.api_url,
"subscription_url": s.subscription_url,
"inbound_id": s.inbound_id,
"panel_type": s.panel_type,
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"tariff_subgroups": subs_map.get(s.id, []),
"tariff_ids": tariffs_map.get(s.id, []),
"special_groups": special,
"cluster_name": cluster,
"server_id": s.id,
})
return grouped
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении серверов: {e}")
await session.rollback()
return {}
async def get_clusters(session: AsyncSession) -> list[str]:
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
result = await session.execute(stmt)
return [r[0] for r in result.all()]
async def check_unique_server_name(session: AsyncSession, server_name: str, cluster_name: str | None = None) -> bool:
stmt = select(Server).where(Server.server_name == server_name)
if cluster_name:
stmt = stmt.where(Server.cluster_name == cluster_name)
result = await session.execute(stmt.limit(1))
return result.scalar_one_or_none() is None
async def check_server_name_by_cluster(session: AsyncSession, server_name: str) -> dict | None:
try:
result = await session.execute(select(Server.cluster_name).where(Server.server_name == server_name))
row = result.first()
return {"cluster_name": row[0]} if row else None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске кластера для сервера {server_name}: {e}")
await session.rollback()
return None
async def get_cluster_name_by_server(session: AsyncSession, server_id_or_name: str) -> str | None:
stmt = (
select(Server.cluster_name)
.where((Server.id == server_id_or_name) | (Server.server_name == server_id_or_name))
.limit(1)
)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return row
async def get_server_by_name(session: AsyncSession, server_name: str) -> dict | None:
try:
stmt = select(Server).where(Server.server_name == server_name)
result = await session.execute(stmt)
server = result.scalar_one_or_none()
if server:
return {
"id": server.id,
"cluster_name": server.cluster_name,
"server_name": server.server_name,
"api_url": server.api_url,
"subscription_url": server.subscription_url,
"inbound_id": server.inbound_id,
"panel_type": server.panel_type,
"enabled": server.enabled,
"max_keys": server.max_keys,
"tariff_group": server.tariff_group,
}
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
await session.rollback()
return None
async def update_server_field(session: AsyncSession, server_name: str, field: str, value: any) -> bool:
try:
stmt = update(Server).where(Server.server_name == server_name).values(**{field: value})
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Поле {field} сервера {server_name} обновлено на {value}")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении поля {field} сервера {server_name}: {e}")
await session.rollback()
return False
async def update_server_name_with_keys(session: AsyncSession, old_name: str, new_name: str) -> bool:
try:
from sqlalchemy import update
from database.models import Key
if not await check_unique_server_name(session, new_name):
logger.error(f"❌ Сервер с именем {new_name} уже существует")
return False
stmt_server = update(Server).where(Server.server_name == old_name).values(server_name=new_name)
await session.execute(stmt_server)
stmt_keys = update(Key).where(Key.server_id == old_name).values(server_id=new_name)
await session.execute(stmt_keys)
await session.commit()
logger.info(f"✅ Сервер переименован с {old_name} на {new_name}")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при переименовании сервера {old_name}: {e}")
await session.rollback()
return False
async def get_available_clusters(session: AsyncSession) -> list[str]:
try:
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
result = await session.execute(stmt)
return [row[0] for row in result.all()]
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении списка кластеров: {e}")
await session.rollback()
return []
async def update_server_cluster(session: AsyncSession, server_name: str, new_cluster: str) -> bool:
try:
server_data = await get_server_by_name(session, server_name)
if not server_data:
return False
old_cluster = server_data["cluster_name"]
stmt_remaining = select(func.count()).where(
(Server.cluster_name == old_cluster) & (Server.server_name != server_name)
)
result = await session.execute(stmt_remaining)
remaining_servers = result.scalar_one()
if remaining_servers == 0:
stmt_update_keys = update(Key).where(Key.server_id == old_cluster).values(server_id=new_cluster)
await session.execute(stmt_update_keys)
stmt_new_cluster = select(Server.tariff_group).where(Server.cluster_name == new_cluster).limit(1)
result = await session.execute(stmt_new_cluster)
new_tariff_group = result.scalar_one_or_none()
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
)
if server_data.get("id") is None:
rid = await session.execute(select(Server.id).where(Server.server_name == server_name).limit(1))
server_id = rid.scalar_one_or_none()
else:
server_id = server_data["id"]
if server_id is not None and new_tariff_group is not None:
await session.execute(
update(ServerSubgroup).where(ServerSubgroup.server_id == server_id).values(group_code=new_tariff_group)
)
await session.commit()
logger.info(
f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы и привязок подгрупп"
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
await session.rollback()
return False
async def resolve_device_limit_from_group(session: AsyncSession, server_id: str) -> int | None:
r = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
group = r.scalar_one_or_none()
if not group:
return None
q = await session.execute(
select(Tariff.device_limit)
.where(Tariff.group_code == group, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
dl = q.scalar_one_or_none()
return int(dl) if dl is not None else None
async def filter_cluster_by_subgroup(
session: AsyncSession,
cluster: list,
target_subgroup: str,
cluster_id: str,
tariff_id: int | None = None,
) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
if tariff_id:
tariff_id_str = str(tariff_id)
q_by_tariff = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == tariff_id_str,
)
)
allowed_by_tariff = {n for (n,) in q_by_tariff.all()}
if allowed_by_tariff:
logger.debug(f"Найдены серверы по tariff_id={tariff_id}: {allowed_by_tariff}")
return [s for s in cluster if s.get("server_name") in allowed_by_tariff]
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == target_subgroup,
)
)
allowed = {n for (n,) in q_allowed.all()}
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
check_values = [target_subgroup]
if tariff_id:
check_values.append(str(tariff_id))
total_bindings = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title.in_(check_values))
)
if not total_bindings:
logger.info(f"Для подгруппы/тарифа нет привязок. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
)
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}.")
return []
logger.info(f"В кластере {cluster_id} нет привязок. Используем весь кластер.")
return cluster
async def filter_cluster_by_tariff(session: AsyncSession, cluster: list, tariff_id: int, cluster_id: str) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
tariff_id_str = str(tariff_id)
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == tariff_id_str,
)
)
allowed = {n for (n,) in q_allowed.all()}
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
total_for_tariff = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == tariff_id_str)
)
if not total_for_tariff:
logger.info(f"Для тарифа {tariff_id} нет привязок серверов. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
)
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под тариф {tariff_id} в кластере {cluster_id}.")
return []
logger.info(f"В кластере {cluster_id} нет привязок тарифов. Используем весь кластер.")
return cluster
async def has_legacy_subgroup_bindings(session: AsyncSession, server_ids: list[int]) -> bool:
if not server_ids:
return False
result = await session.execute(
select(ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(server_ids))
)
for (title,) in result.all():
if title and not title.isdigit():
return True
return False
+37
View File
@@ -0,0 +1,37 @@
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from .models import Setting
async def get_setting(session: AsyncSession, key: str, default: Any = None) -> Any:
stmt = select(Setting).where(Setting.key == key)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None or setting.value is None:
return default
return setting.value
async def set_setting(
session: AsyncSession,
key: str,
value: Any,
description: str | None = None,
) -> Setting:
stmt = select(Setting).where(Setting.key == key)
result = await session.execute(stmt)
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(key=key, value=value, description=description)
session.add(setting)
else:
setting.value = value
if description is not None:
setting.description = description
await session.flush()
return setting
+168
View File
@@ -0,0 +1,168 @@
from datetime import date, datetime
from sqlalchemy import and_, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Key, Payment, Referral, Tariff, User
async def count_total_users(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(User))
async def count_users_updated_today(session: AsyncSession, today: date) -> int:
return await session.scalar(select(func.count()).select_from(User).where(User.updated_at >= today))
async def count_users_registered_since(session: AsyncSession, since: date) -> int:
return await session.scalar(select(func.count()).select_from(User).where(User.created_at >= since))
async def count_users_registered_between(session: AsyncSession, start: date, end: date) -> int:
return await session.scalar(
select(func.count()).select_from(User).where(User.created_at >= start, User.created_at < end)
)
async def count_total_keys(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Key))
async def count_active_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
return await session.scalar(select(func.count()).select_from(Key).where(Key.expiry_time > current_time_ms))
async def count_active_paid_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(
select(func.count())
.select_from(Key)
.where(Key.expiry_time > current_time_ms)
.where(~Key.tariff_id.in_(trial_tariffs_subquery))
)
async def count_active_trial_keys(session: AsyncSession) -> int:
current_time_ms = int(datetime.utcnow().timestamp() * 1000)
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(
select(func.count())
.select_from(Key)
.where(Key.expiry_time > current_time_ms)
.where(Key.tariff_id.in_(trial_tariffs_subquery))
)
async def get_tariff_distribution(
session: AsyncSession, include_unbound: bool = False
) -> tuple[list[tuple[int, int]], list[dict]]:
result = await session.execute(
select(Key.tariff_id, func.count(Key.client_id)).where(Key.tariff_id.isnot(None)).group_by(Key.tariff_id)
)
tariff_counts = result.all()
if not include_unbound:
return tariff_counts
result = await session.execute(select(Key.expiry_time).where(Key.tariff_id.is_(None)))
no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
return tariff_counts, no_tariff_keys
async def get_tariff_names(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_groups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_durations(session: AsyncSession, tariff_ids: list[int]) -> dict[int, int]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_subgroups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str | None]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.subgroup_title).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def count_total_referrals(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Referral))
async def sum_payments_since(session: AsyncSession, since: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= since,
Payment.status == "success",
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
return round(float(result), 2)
async def sum_payments_between(session: AsyncSession, start: date, end: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= start,
Payment.created_at < end,
Payment.status == "success",
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
return round(float(result), 2)
async def sum_total_payments(session: AsyncSession) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.status == "success",
Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED),
)
)
)
return round(float(result), 2)
async def count_hot_leads(session: AsyncSession) -> int:
subquery_active_keys = (
select(Key.tg_id).where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000)).distinct()
)
stmt = (
select(Payment.tg_id)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == Payment.tg_id))))
.distinct()
)
result = await session.execute(select(func.count()).select_from(stmt.subquery()))
return result.scalar()
+270
View File
@@ -0,0 +1,270 @@
import hashlib
from collections import defaultdict
from datetime import datetime
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server, Tariff
from logger import logger
def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
if not subgroup_title:
return ""
unique_key = f"{subgroup_title}:{group_code}"
hash_object = hashlib.md5(unique_key.encode("utf-8"))
return hash_object.hexdigest()[:8]
async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group_code: str) -> str | None:
result = await session.execute(
select(Tariff.subgroup_title)
.where(Tariff.group_code == group_code, Tariff.subgroup_title.isnot(None))
.distinct()
)
subgroups = [row[0] for row in result.fetchall()]
for subgroup_title in subgroups:
if create_subgroup_hash(subgroup_title, group_code) == subgroup_hash:
return subgroup_title
return None
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False
):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.sort_order, Tariff.id)
)
else:
result = await session.execute(select(Tariff).order_by(Tariff.sort_order, Tariff.id))
tariffs = [dict(r.__dict__) for r in result.scalars().all()]
if with_subgroup_weights and group_code:
tariffs_without_order = [t for t in tariffs if t.get("sort_order") is None]
if tariffs_without_order:
for tariff in tariffs_without_order:
tariff["sort_order"] = 1
await session.execute(update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1))
await session.commit()
grouped = defaultdict(list)
for t in tariffs:
grouped[t.get("subgroup_title")].append(t)
subgroup_weights = {}
for subgroup, tariffs_list in grouped.items():
if subgroup:
total_weight = sum(t.get("sort_order", 1) for t in tariffs_list)
subgroup_weights[subgroup] = total_weight
return {"tariffs": tariffs, "subgroup_weights": subgroup_weights}
return tariffs
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
await session.rollback()
return []
async def get_tariff_by_id(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
return dict(tariff.__dict__) if tariff else None
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифа по ID {tariff_id}: {e}")
await session.rollback()
return None
async def get_tariffs_for_cluster(session: AsyncSession, cluster_name: str):
try:
server_row = await session.execute(
select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1)
)
row = server_row.first()
if not row:
server_row = await session.execute(
select(Server.tariff_group).where(Server.server_name == cluster_name).limit(1)
)
row = server_row.first()
if not row or not row[0]:
return []
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.sort_order, Tariff.id)
)
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}")
return []
async def create_tariff(session: AsyncSession, data: dict):
try:
data["created_at"] = datetime.utcnow()
data["updated_at"] = datetime.utcnow()
if "sort_order" not in data:
group_code = data.get("group_code")
if group_code:
result = await session.execute(
select(func.max(Tariff.sort_order)).where(
Tariff.group_code == group_code, Tariff.sort_order.isnot(None)
)
)
max_order = result.scalar() or 0
else:
result = await session.execute(select(func.max(Tariff.sort_order)).where(Tariff.sort_order.isnot(None)))
max_order = result.scalar() or 0
data["sort_order"] = max_order + 1
stmt = insert(Tariff).values(**data).returning(Tariff)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one()
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при создании тарифа: {e}")
await session.rollback()
return None
async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
if not updates:
return False
try:
updates["updated_at"] = datetime.utcnow()
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(**updates))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при обновлении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def delete_tariff(session: AsyncSession, tariff_id: int):
try:
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при удалении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def check_tariff_exists(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
await session.rollback()
return False
async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
try:
result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id))
sort_order = result.scalar_one_or_none()
if sort_order is None:
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1))
await session.commit()
return 1
return sort_order
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
await session.rollback()
return None
async def move_tariff_up(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = max(1, current_order - 1)
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вверх: {e}")
await session.rollback()
return False
async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = current_order + 1
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вниз: {e}")
await session.rollback()
return False
async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
tariffs = result.scalars().all()
if not tariffs:
return True
for i, tariff in enumerate(tariffs):
new_sort_order = 1 + i
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации sort_order для группы {group_code}: {e}")
await session.rollback()
return False
async def initialize_all_tariff_weights(session: AsyncSession) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.sort_order.is_(None)))
tariffs_without_weight = result.scalars().all()
if not tariffs_without_weight:
return True
for tariff in tariffs_without_weight:
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации весов тарифов: {e}")
await session.rollback()
return False
+43
View File
@@ -0,0 +1,43 @@
from datetime import datetime
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import TemporaryData
from logger import logger
async def create_temporary_data(session: AsyncSession, tg_id: int, state: str, data: dict):
try:
stmt = (
insert(TemporaryData)
.values(tg_id=tg_id, state=state, data=data, updated_at=datetime.utcnow())
.on_conflict_do_update(
index_elements=[TemporaryData.tg_id],
set_={"state": state, "data": data, "updated_at": datetime.utcnow()},
)
)
await session.execute(stmt)
await session.commit()
logger.info(f"📝 Временные данные сохранены для {tg_id}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении временных данных для {tg_id}: {e}")
await session.rollback()
raise
async def get_temporary_data(session: AsyncSession, tg_id: int) -> dict | None:
stmt = select(TemporaryData).where(TemporaryData.tg_id == tg_id)
result = await session.execute(stmt)
row = result.scalar_one_or_none()
if row:
return {"state": row.state, "data": row.data}
return None
async def clear_temporary_data(session: AsyncSession, tg_id: int):
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.commit()
logger.info(f"🗑 Временные данные очищены для {tg_id}")
+255
View File
@@ -0,0 +1,255 @@
from sqlalchemy import and_, func, insert, not_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from core.constants import PAYMENT_SYSTEMS_EXCLUDED
from database.models import Payment, TrackingSource, User
from logger import logger
async def create_tracking_source(session: AsyncSession, name: str, code: str, type_: str, created_by: int):
try:
stmt = insert(TrackingSource).values(
name=name,
code=code,
type=type_,
created_by=created_by,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🆕 Источник трафика {code} создан")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при создании источника {code}: {e}")
await session.rollback()
raise
async def get_all_tracking_sources(session: AsyncSession) -> list[dict]:
registrations_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(User.source_code == TrackingSource.code)
.correlate(TrackingSource)
.scalar_subquery()
)
trials_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == TrackingSource.code) & (User.trial == 1))
.correlate(TrackingSource)
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == TrackingSource.code)
& (Payment.status == "success")
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
)
.correlate(TrackingSource)
.scalar_subquery()
)
query = select(
TrackingSource.code,
TrackingSource.name,
TrackingSource.created_at,
registrations_subq.label("registrations"),
trials_subq.label("trials"),
payments_subq.label("payments"),
).order_by(TrackingSource.created_at.desc())
result = await session.execute(query)
rows = result.all()
return [
{
"code": r.code,
"name": r.name,
"created_at": r.created_at,
"registrations": r.registrations or 0,
"trials": r.trials or 0,
"payments": r.payments or 0,
}
for r in rows
]
async def get_tracking_source_stats(session: AsyncSession, code: str) -> dict | None:
def _month_key(dt) -> str:
return dt.strftime("%Y-%m")
src_row = await session.execute(
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at).where(TrackingSource.code == code)
)
src = src_row.first()
if not src:
return None
_src_name, _src_code, created_at = src
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.created_at >= created_at))
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
header_q = select(
TrackingSource.name,
TrackingSource.code,
TrackingSource.created_at,
reg_subq.label("registrations"),
trial_subq.label("trials"),
payments_subq.label("payments"),
amount_subq.label("total_amount"),
).where(TrackingSource.code == code)
header_res = await session.execute(header_q)
row = header_res.first()
if not row:
return None
payments_base = (
select(
Payment.tg_id.label("tg_id"),
Payment.amount.label("amount"),
Payment.created_at.label("dt"),
)
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& Payment.payment_system.notin_(PAYMENT_SYSTEMS_EXCLUDED)
& (Payment.created_at >= created_at)
)
.subquery()
)
first_pay = (
select(
payments_base.c.tg_id.label("tg_id"),
func.min(payments_base.c.dt).label("first_dt"),
)
.group_by(payments_base.c.tg_id)
.subquery()
)
month_expr_new = func.date_trunc("month", payments_base.c.dt).label("month")
new_rows = await session.execute(
select(
month_expr_new,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(
first_pay,
and_(
payments_base.c.tg_id == first_pay.c.tg_id,
payments_base.c.dt == first_pay.c.first_dt,
),
)
.group_by(month_expr_new)
.order_by(month_expr_new)
)
new_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in new_rows.all()}
month_expr_rep = func.date_trunc("month", payments_base.c.dt).label("month")
repeat_rows = await session.execute(
select(
month_expr_rep,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(first_pay, payments_base.c.tg_id == first_pay.c.tg_id)
.where(payments_base.c.dt > first_pay.c.first_dt)
.group_by(month_expr_rep)
.order_by(month_expr_rep)
)
repeat_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in repeat_rows.all()}
month_expr_regs = func.date_trunc("month", User.created_at).label("month")
regs_rows = await session.execute(
select(
month_expr_regs,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.created_at >= created_at))
.group_by(month_expr_regs)
.order_by(month_expr_regs)
)
regs_by_month = {r.month: int(r.cnt) for r in regs_rows.all()}
month_expr_trials = func.date_trunc("month", User.created_at).label("month")
trials_rows = await session.execute(
select(
month_expr_trials,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.group_by(month_expr_trials)
.order_by(month_expr_trials)
)
trials_by_month = {r.month: int(r.cnt) for r in trials_rows.all()}
months = set()
months.update(regs_by_month.keys())
months.update(trials_by_month.keys())
months.update(new_by_month.keys())
months.update(repeat_by_month.keys())
monthly = []
for m in sorted(months):
regs = regs_by_month.get(m, 0)
trls = trials_by_month.get(m, 0)
new_cnt, new_amt = new_by_month.get(m, (0, 0.0))
rep_cnt, rep_amt = repeat_by_month.get(m, (0, 0.0))
monthly.append({
"month": _month_key(m),
"registrations": regs,
"trials": trls,
"new_purchases_count": new_cnt,
"new_purchases_amount": new_amt,
"repeat_purchases_count": rep_cnt,
"repeat_purchases_amount": rep_amt,
})
return {
"name": row.name,
"code": row.code,
"created_at": row.created_at,
"registrations": row.registrations or 0,
"trials": row.trials or 0,
"payments": row.payments or 0,
"total_amount": float(row.total_amount or 0),
"monthly": monthly,
}
+261
View File
@@ -0,0 +1,261 @@
from datetime import datetime
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.keys import delete_key
from database.models import (
BlockedUser,
CouponUsage,
Gift,
GiftUsage,
Key,
Notification,
Payment,
Referral,
TemporaryData,
User,
)
from logger import logger
async def add_user(
session: AsyncSession,
tg_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language_code: str = None,
is_bot: bool = False,
source_code: str = None,
commit: bool = True,
) -> bool:
try:
stmt = (
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
source_code=source_code,
)
.on_conflict_do_nothing(index_elements=["tg_id"])
.returning(User.tg_id)
)
res = await session.execute(stmt)
inserted_tg_id = res.scalar_one_or_none()
if inserted_tg_id is None:
return False
if commit:
await session.commit()
logger.info(f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})")
return True
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при добавлении пользователя {tg_id}: {e}")
await session.rollback()
raise
async def update_balance(session: AsyncSession, tg_id: int, amount: float) -> None:
try:
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(balance=func.coalesce(User.balance, 0) + amount)
.returning(User.balance)
)
new_balance = res.scalar_one_or_none()
await session.commit()
if new_balance is not None:
old_balance = new_balance - amount
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {old_balance}{new_balance}")
else:
logger.info(f"[DB] Баланс пользователя {tg_id} не изменён: пользователь не найден")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
await session.rollback()
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
stmt = select(exists().where(User.tg_id == tg_id))
result = await session.execute(stmt)
return result.scalar()
async def get_balance(session: AsyncSession, tg_id: int) -> float:
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
balance = result.scalar_one_or_none()
return round(float(balance or 0.0), 1)
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
try:
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=balance))
await session.commit()
except SQLAlchemyError as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
await session.rollback()
raise
async def update_trial(session: AsyncSession, tg_id: int, status: int):
try:
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=status))
await session.commit()
logger.info(f"[DB] Триал статус обновлён для пользователя {tg_id}: {status}")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении триала пользователя {tg_id}: {e}")
await session.rollback()
raise
async def get_trial(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
trial = result.scalar_one_or_none()
return int(trial or 0)
async def upsert_user(
session: AsyncSession,
tg_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language_code: str = None,
is_bot: bool = False,
only_if_exists: bool = False,
) -> dict | None:
"""Создаёт пользователя или обновляет поля профиля."""
try:
now = datetime.utcnow()
returning_cols = list(User.__table__.c)
if only_if_exists:
username_value = username if username else User.username
first_name_value = first_name if first_name else User.first_name
last_name_value = last_name if last_name else User.last_name
language_code_value = language_code if language_code else User.language_code
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(
username=username_value,
first_name=first_name_value,
last_name=last_name_value,
language_code=language_code_value,
is_bot=is_bot,
updated_at=now,
)
.returning(*returning_cols)
)
row = res.mappings().one_or_none()
if row is None:
return None
await session.commit()
return dict(row)
res = await session.execute(
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
created_at=now,
updated_at=now,
)
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={
"username": username,
"first_name": first_name,
"last_name": last_name,
"language_code": language_code,
"is_bot": is_bot,
"updated_at": now,
},
)
.returning(*returning_cols)
)
row = res.mappings().one()
await session.commit()
return dict(row)
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
await session.rollback()
raise
async def delete_user_data(session: AsyncSession, tg_id: int):
try:
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
await session.execute(
delete(GiftUsage).where(GiftUsage.gift_id.in_(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id)))
)
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
await session.execute(update(Gift).where(Gift.recipient_tg_id == tg_id).values(recipient_tg_id=None))
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
await session.execute(
delete(Referral).where(or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id))
)
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
await delete_key(session, tg_id, commit=False)
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
await session.execute(delete(User).where(User.tg_id == tg_id))
await session.commit()
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
raise
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
keys_count_sq = select(func.count(Key.client_id)).where(Key.tg_id == tg_id).scalar_subquery()
res = await session.execute(select(func.coalesce(User.trial, 0), keys_count_sq).where(User.tg_id == tg_id))
row = res.first()
if row is None:
return None
return int(row[0]), int(row[1])
async def upsert_source_if_empty(
session: AsyncSession,
tg_id: int,
source_code: str,
commit: bool = True,
) -> bool:
if not source_code:
return False
stmt = (
insert(User)
.values(tg_id=tg_id, source_code=source_code)
.on_conflict_do_update(
index_elements=["tg_id"],
set_={"source_code": insert(User).excluded.source_code},
where=(User.source_code.is_(None)),
)
.returning(User.tg_id)
)
res = await session.execute(stmt)
changed_tg_id = res.scalar_one_or_none()
if changed_tg_id is None:
return False
if commit:
await session.commit()
return True
+9
View File
@@ -0,0 +1,9 @@
services:
bot:
container_name: solobot
build: .
restart: unless-stopped
network_mode: host
volumes:
- /:/host:ro
+63 -5
View File
@@ -1,12 +1,70 @@
import time
from aiogram.filters import BaseFilter
from aiogram.types import Message
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker
from database.models import Admin
_ADMIN_CACHE: dict[int, tuple[float, bool, bool]] = {}
_ADMIN_CACHE_TTL = 60
def _get_cached_admin(user_id: int) -> tuple[bool, bool] | None:
now = time.time()
entry = _ADMIN_CACHE.get(user_id)
if entry and entry[0] > now:
return entry[1], entry[2]
return None
def _set_cached_admin(user_id: int, is_admin: bool, is_superadmin: bool) -> None:
_ADMIN_CACHE[user_id] = (time.time() + _ADMIN_CACHE_TTL, is_admin, is_superadmin)
class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool:
try:
return message.from_user.id in ADMIN_ID
except Exception:
async def __call__(self, event: Message | CallbackQuery) -> bool:
if not event.from_user:
return False
user_id = event.from_user.id
cached = _get_cached_admin(user_id)
if cached is not None:
return cached[0]
try:
async with async_session_maker() as session:
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
admin_ids = (ADMIN_ID,) if isinstance(ADMIN_ID, int) else ADMIN_ID
is_admin = admin is not None or user_id in admin_ids
is_super = admin.role != "moderator" if admin else (user_id in admin_ids)
_set_cached_admin(user_id, is_admin, is_super)
return is_admin
except (Exception,):
return False
class IsSuperAdminFilter(BaseFilter):
async def __call__(self, event: Message | CallbackQuery) -> bool:
if not event.from_user:
return False
user_id = event.from_user.id
cached = _get_cached_admin(user_id)
if cached is not None:
return cached[1]
try:
async with async_session_maker() as session:
admin = (await session.execute(select(Admin).where(Admin.tg_id == user_id))).scalar_one_or_none()
if not admin:
_set_cached_admin(user_id, False, False)
return False
is_super = admin.role != "moderator"
_set_cached_admin(user_id, True, is_super)
return is_super
except (Exception,):
return False
+4 -2
View File
@@ -9,10 +9,11 @@ from .donate import router as donate_router
from .instructions import router as instructions_router
from .keys import router as keys_router
from .notifications import router as notifications_router
from .pay import router as pay_router
from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
from .tariffs import router as tariff_router
router = Router(name="handlers_main_router")
@@ -21,7 +22,6 @@ router.include_routers(
start_router,
captcha_router,
profile_router,
pay_router,
donate_router,
coupons_router,
notifications_router,
@@ -29,4 +29,6 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
tariff_router,
)
+12
View File
@@ -2,22 +2,29 @@ __all__ = ("router",)
from aiogram import Router
from .ads import router as ads_router
from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .emoji import router as emoji_router
from .gifts import router as gifts_router
from .management import router as management_router
from .module import router as modules_router
from .panel import router as panel_router
from .restart import router as restart_router
from .sender import router as sender_router
from .servers import router as servers_router
from .settings import router as settings_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
modules_router,
panel_router,
management_router,
servers_router,
@@ -27,6 +34,11 @@ router.include_routers(
backups_router,
sender_router,
coupons_router,
emoji_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
gifts_router,
settings_router,
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .ads_handler import router
+194
View File
@@ -0,0 +1,194 @@
import re
from datetime import datetime
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import USERNAME_BOT
from database import create_tracking_source, get_tracking_source_stats
from database.models import TrackingSource, User
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminAdsCallback,
build_ads_delete_confirm_kb,
build_ads_kb,
build_ads_list_kb,
build_ads_stats_kb,
build_cancel_input_kb,
)
router = Router()
class AdminAdsState(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_code = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminAdsState.waiting_for_new_name)
await callback_query.message.edit_text(
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
async def handle_ads_name_input(message: Message, state: FSMContext):
name = message.text.strip()
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.\n\n💡 <b>Правила:</b> только латинские буквы и цифры",
reply_markup=build_cancel_input_kb(),
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(message: Message, state: FSMContext, session: AsyncSession):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
if not re.match(r"^[a-zA-Z0-9]+$", code):
await message.answer(
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
reply_markup=build_cancel_input_kb(),
)
return
code_with_prefix = f"utm_{code}"
try:
await create_tracking_source(
name=name,
code=code_with_prefix,
type_="utm",
created_by=message.from_user.id,
session=session,
)
stats = await get_tracking_source_stats(session, code_with_prefix)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await message.answer(
text=msg,
reply_markup=build_ads_stats_kb(code_with_prefix),
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
await message.answer("❌ Произошла ошибка при создании ссылки.")
finally:
await state.clear()
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession, callback_data: AdminAdsCallback):
try:
result = await session.execute(select(TrackingSource).order_by(TrackingSource.created_at.desc()))
ads = result.scalars().all()
items_per_page = 6
if callback_data.code and callback_data.code.isdigit():
current_page = int(callback_data.code)
else:
current_page = 1
total_pages = (len(ads) + items_per_page - 1) // items_per_page
reply_markup = build_ads_list_kb(ads, current_page, total_pages)
await callback_query.message.edit_text(
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_ads_view(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
stats = await get_tracking_source_stats(session, code)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(text=msg, reply_markup=build_ads_stats_kb(code))
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
reply_markup=build_ads_delete_confirm_kb(code),
)
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
await session.execute(update(User).where(User.source_code == code).values(source_code=None))
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
await session.commit()
await callback_query.message.edit_text(
f"🗑️ Ссылка <code>{code}</code> удалена.",
reply_markup=build_ads_kb(),
)
except Exception as e:
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
def format_ads_stats(stats: dict, username_bot: str) -> str:
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
update_time = now.strftime("%d.%m.%y %H:%M:%S")
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
f"📌 <b>Название:</b> {stats['name']}\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
f"💡 <b>Активность:</b>\n"
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
f"💰 <b>Финансовая информация:</b>\n"
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
+103
View File
@@ -0,0 +1,103 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
from handlers.buttons import BACK
class AdminAdsCallback(CallbackData, prefix="admin_ads"):
action: str
code: str | None = None
def build_ads_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Новая ссылка", callback_data=AdminAdsCallback(action="create").pack())
builder.button(text="📊 Список", callback_data=AdminAdsCallback(action="list").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_ads_list_kb(ads: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
items_per_page = 6
start = (current_page - 1) * items_per_page
end = start + items_per_page
page_ads = ads[start:end]
row = []
for i, ad in enumerate(page_ads, 1):
row.append(
InlineKeyboardButton(
text=f"📎 {ad['name']}",
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
)
)
if i % 2 == 0 or i == len(page_ads):
builder.row(*row)
row = []
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔄 Обновить",
callback_data=AdminAdsCallback(action="view", code=code).pack(),
)
builder.button(
text="🗑️ Удалить",
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
)
builder.row(build_admin_back_btn("ads"))
builder.adjust(1)
return builder.as_markup()
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, удалить",
callback_data=AdminAdsCallback(
action="delete",
code=code,
).pack(),
)
builder.button(
text="❌ Отмена",
callback_data=AdminAdsCallback(action="view", code=code).pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_cancel_input_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Отмена",
callback_data=AdminAdsCallback(action="cancel_input", code="none").pack(),
)
return builder.as_markup()
+12 -7
View File
@@ -1,8 +1,8 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from utils.backup import backup_database
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
@@ -18,14 +18,19 @@ async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
text="💾 Инициализация резервного копирования базы данных...",
reply_markup=kb,
)
exception = await backup_database()
try:
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии: {exception}"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
if exception:
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
except Exception as e:
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
await callback_query.message.edit_text(text=text, reply_markup=kb)
+311 -39
View File
@@ -1,57 +1,103 @@
from typing import Any
import csv
import io
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import BlockedUser, Key, ManualBan
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
build_bans_kb,
build_blocked_users_kb,
build_manual_bans_kb,
build_shadow_bans_kb,
)
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans"),
IsAdminFilter(),
)
async def handle_bans(callback_query: CallbackQuery):
text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
class PreemptiveBanStates(StatesGroup):
waiting_for_preemptive_ids = State()
await callback_query.message.edit_text(
text=text,
reply_markup=build_bans_kb(),
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
async def handle_bans(callback_query: CallbackQuery):
text_ = (
"🚫 <b>Управление банами</b>\n\n"
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
"👻 <b>Теневые баны</b> — пользователи, действия которых игнорируются.\n"
"🔒 <b>Ручные баны</b> — пользователи, которых вы забанили через админку.\n\n"
"⬇ Выберите нужный раздел:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_blocked_menu"), IsAdminFilter())
async def handle_blocked_users_menu(callback_query: CallbackQuery):
text_ = (
"📛 <b>Забанившие бота</b>\n\n"
"Пользователи, которые заблокировали бота вручную или удалили чат.\n"
"⬇ Выберите действие:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_blocked_users_kb())
def get_shadow_bans_menu_text() -> str:
return (
"👻 <b>Теневые баны</b>\n\n"
"Пользователи, действия которых игнорируются ботом.\n"
"Они не получают уведомлений о бане.\n\n"
"💡 <b>Можно добавить несколько пользователей за раз:</b>\n"
"Отправьте список Telegram ID (один на строке).\n"
"Пример:\n<code>123456789\n987654321\n555666777</code>\n\n"
"⬇ Выберите действие:"
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_export"),
IsAdminFilter(),
)
async def handle_bans_export(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("management")
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_shadow_menu"), IsAdminFilter())
async def handle_shadow_bans_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_manual_menu"), IsAdminFilter())
async def handle_manual_bans_menu(callback_query: CallbackQuery):
text_ = "🔒 <b>Ручные баны</b>\n\nПользователи, которых вы забанили через админку.\n⬇ Выберите действие:"
await callback_query.message.edit_text(text=text_, reply_markup=build_manual_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter())
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_blocked_users_kb()
try:
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
import csv
import io
result = await session.execute(select(BlockedUser.tg_id))
banned_users = result.scalars().all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "blocked_at"])
for user in banned_users:
writer.writerow([user["tg_id"], user["blocked_at"]])
writer.writerow(["tg_id"])
for tg_id in banned_users:
writer.writerow([tg_id])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт пользователей, заблокировавших бота в CSV",
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
)
except Exception as e:
await callback_query.message.answer(
@@ -60,16 +106,13 @@ async def handle_bans_export(callback_query: CallbackQuery, session: Any):
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"),
IsAdminFilter(),
)
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("bans")
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter())
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
kb = build_blocked_users_kb()
try:
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
blocked_ids = [record["tg_id"] for record in blocked_users]
stmt = select(BlockedUser.tg_id).outerjoin(Key, BlockedUser.tg_id == Key.tg_id).where(Key.tg_id.is_(None))
result = await session.execute(stmt)
blocked_ids = [row[0] for row in result.all()]
if not blocked_ids:
await callback_query.message.answer(
@@ -81,8 +124,6 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
reply_markup=kb,
@@ -92,3 +133,234 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
text=f"❗ Произошла ошибка при удалении записей: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "shadow_bans_export"), IsAdminFilter())
async def handle_shadow_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_shadow_bans_kb()
try:
result = await session.execute(
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.banned_by, ManualBan.until).where(
ManualBan.reason == "shadow"
)
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "banned_by", "until"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.banned_by, user.until])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="shadow_bans.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт теневых банов (CSV)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_manual_bans_kb()
try:
result = await session.execute(
select(ManualBan.tg_id, ManualBan.banned_at, ManualBan.reason, ManualBan.until, ManualBan.banned_by).where(
or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))
)
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until", "banned_by"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until, user.banned_by])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт вручную забаненных пользователей (CSV)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_blocked"), IsAdminFilter())
async def handle_clear_blocked_users(callback_query: CallbackQuery, session: AsyncSession):
kb = build_blocked_users_kb()
try:
count_result = await session.execute(select(func.count()).select_from(BlockedUser))
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет забанивших пользователей для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(BlockedUser))
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей забанивших пользователей из базы данных.",
reply_markup=kb,
)
logger.info(f"[BANS] Очищено {total_count} записей из blocked_users")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке blocked_users: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке забанивших пользователей: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_shadow"), IsAdminFilter())
async def handle_clear_shadow_bans(callback_query: CallbackQuery, session: AsyncSession):
kb = build_shadow_bans_kb()
try:
count_result = await session.execute(
select(func.count()).select_from(ManualBan).where(ManualBan.reason == "shadow")
)
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет теневых банов для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(ManualBan).where(ManualBan.reason == "shadow"))
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей теневых банов из базы данных.",
reply_markup=kb,
)
logger.info(f"[BANS] Очищено {total_count} записей теневых банов из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке теневых банов: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке теневых банов: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_clear_manual"), IsAdminFilter())
async def handle_clear_manual_bans(callback_query: CallbackQuery, session: AsyncSession):
kb = build_manual_bans_kb()
try:
count_result = await session.execute(
select(func.count())
.select_from(ManualBan)
.where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None)))
)
total_count = count_result.scalar() or 0
if total_count == 0:
await callback_query.message.answer(
text="📂 Нет ручных банов для очистки.",
reply_markup=kb,
)
return
await session.execute(delete(ManualBan).where(or_(ManualBan.reason != "shadow", ManualBan.reason.is_(None))))
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Очищено {total_count} записей ручных банов из базы данных.",
reply_markup=kb,
)
logger.info(f"[BANS] Очищено {total_count} записей ручных банов из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке ручных банов: {e}")
await callback_query.message.answer(
text=f"❗ Ошибка при очистке ручных банов: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Отмена",
callback_data=AdminPanelCallback(action="bans_cancel_preemptive").pack(),
)
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>",
reply_markup=builder.as_markup(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_cancel_preemptive"), IsAdminFilter())
async def handle_cancel_preemptive_ban(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(text=get_shadow_bans_menu_text(), reply_markup=build_shadow_bans_kb())
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession):
lines = message.text.strip().splitlines()
tg_ids = set()
for line in lines:
line = line.strip()
if line.isdigit():
tg_ids.add(int(line))
if not tg_ids:
await message.answer("❌ Не найдено ни одного корректного Telegram ID.")
return
now = datetime.now(timezone.utc)
stmt = (
pg_insert(ManualBan)
.values([
{
"tg_id": tg_id,
"reason": "shadow",
"banned_by": message.from_user.id,
"until": None,
"banned_at": now,
}
for tg_id in tg_ids
])
.on_conflict_do_update(
index_elements=[ManualBan.tg_id],
set_={
"reason": "shadow",
"until": None,
"banned_by": message.from_user.id,
"banned_at": now,
},
)
)
await session.execute(stmt)
await session.commit()
await message.answer(
f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.",
reply_markup=build_shadow_bans_kb(),
)
await state.clear()
+89 -6
View File
@@ -1,13 +1,96 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
def build_bans_kb() -> InlineKeyboardMarkup:
def build_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
builder.row(build_admin_back_btn("management"))
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_blocked_menu").pack(),
)
builder.button(
text="👻 Теневые баны",
callback_data=AdminPanelCallback(action="bans_shadow_menu").pack(),
)
builder.button(
text="🔒 Ручные баны",
callback_data=AdminPanelCallback(action="bans_manual_menu").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_blocked_users_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="bans_export").pack(),
)
builder.button(
text="🗑️ Удалить забанивших",
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
)
builder.button(
text="🗑️ Очистить забанивших",
callback_data=AdminPanelCallback(action="bans_clear_blocked").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_shadow_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="shadow_bans_export").pack(),
)
builder.button(
text=" Забанить заранее",
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
)
builder.button(
text="🗑️ Очистить теневые баны",
callback_data=AdminPanelCallback(action="bans_clear_shadow").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_manual_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📥 Экспорт",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="🗑️ Очистить ручные баны",
callback_data=AdminPanelCallback(action="bans_clear_manual").pack(),
)
builder.button(
text=BACK,
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.adjust(1)
return builder.as_markup()
+4 -2
View File
@@ -1,3 +1,5 @@
__all__ = ("router",)
from . import cluster_manage, cluster_sync, cluster_tariffs, cluster_transfers, cluster_wizard # noqa
from .base import AdminClusterStates, router
from .clusters_handler import router
__all__ = ["router", "AdminClusterStates"]
+18
View File
@@ -0,0 +1,18 @@
from aiogram import Router
from aiogram.fsm.state import State, StatesGroup
router = Router()
class AdminClusterStates(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
waiting_for_days_input = State()
waiting_for_new_cluster_name = State()
waiting_for_new_server_name = State()
waiting_for_server_transfer = State()
waiting_for_cluster_transfer = State()
+452
View File
@@ -0,0 +1,452 @@
from datetime import datetime
from aiogram import F, types
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers, update_key_expiry
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.operations import renew_key_in_cluster
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .base import AdminClusterStates, router
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_cluster_management_kb,
build_manage_cluster_kb,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
cluster_name = callback_data.data
result = await session.execute(
select(Server.tariff_group)
.where(
Server.cluster_name == cluster_name,
Server.tariff_group.isnot(None),
)
.limit(1)
)
row = result.first()
tariff_group = row[0] if row else ""
result = await session.execute(select(Server.server_name).where(Server.cluster_name == cluster_name))
server_names = [row[0] for row in result.all()]
result = await session.execute(
select(func.count(func.distinct(Key.tg_id))).where(
(Key.server_id == cluster_name) | (Key.server_id.in_(server_names))
)
)
user_count = result.scalar() or 0
result = await session.execute(
select(func.count()).where((Key.server_id == cluster_name) | (Key.server_id.in_(server_names)))
)
subscription_count = result.scalar() or 0
text = (
f"<b>🔧 Управление кластером <code>{cluster_name}</code></b>\n\n"
f"📁 <b>Тарифная группа:</b> <code>{tariff_group}</code>\n"
f"👥 <b>Пользователей на кластере:</b> <code>{user_count}</code>\n"
f"🔑 <b>Всего подписок:</b> <code>{subscription_count}</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(F.data.startswith("cluster_servers|"), IsAdminFilter())
async def handle_cluster_servers(callback: CallbackQuery, session: AsyncSession):
cluster_name = callback.data.split("|", 1)[1]
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
from handlers.utils import ALLOWED_GROUP_CODES
allowed = set(ALLOWED_GROUP_CODES)
lines = []
for s in cluster_servers:
tids = s.get("tariff_ids") or []
subs = s.get("tariff_subgroups") or []
if tids:
subs_str = f"{len(tids)} тариф(ов)"
elif subs:
subs_str = ", ".join(sorted(subs))
else:
subs_str = ""
grps = s.get("special_groups") or []
grps = [g for g in grps if g in allowed]
grps_str = ", ".join(sorted(grps)) if grps else ""
lines.append(f"{s.get('server_name', '?')}{subs_str} | {grps_str}")
details = "\n".join(lines) if lines else "нет серверов"
await callback.message.edit_text(
text=(
f"<b>📡 Серверы в кластере {cluster_name}</b>\n<i>подгруппы | спецгруппы:</i>\n"
f"<blockquote>{details}</blockquote>"
),
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
):
cluster_name = callback_data.data
await state.set_state(AdminClusterStates.waiting_for_days_input)
await state.update_data(cluster_name=cluster_name)
await callback_query.message.edit_text(
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
reply_markup=build_admin_back_kb("clusters"),
)
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
async def handle_days_input(message: Message, state: FSMContext, session: AsyncSession):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
add_ms = days * 86400 * 1000
logger.info(f"[Cluster Extend] Добавляем {days} дней для кластера: {cluster_name}")
server_stmt = select(Server.server_name).where(Server.cluster_name == cluster_name)
server_rows = await session.execute(server_stmt)
server_names = [row[0] for row in server_rows.all()]
server_names.append(cluster_name)
result = await session.execute(select(Key).where(Key.server_id.in_(server_names)))
keys = result.scalars().all()
if not keys:
await message.answer("❌ Нет подписок в этом кластере или сервере.")
await state.clear()
return
servers = await get_servers(session=session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await message.answer("❌ Не найдены серверы в кластере.")
await state.clear()
return
is_full_remnawave = all(str(s.get("panel_type", "")).lower() == "remnawave" for s in cluster_servers)
if is_full_remnawave:
uuids = [key.client_id for key in keys if key.client_id]
if not uuids:
await message.answer("❌ Нет валидных подписок для продления.")
await state.clear()
return
api_url = cluster_servers[0].get("api_url", "")
if not api_url:
await message.answer("❌ Не найден URL панели для кластера.")
await state.clear()
return
from panels.remnawave import RemnawaveAPI
remna = RemnawaveAPI(api_url)
try:
result_bulk = await remna.bulk_extend_expiration_date(uuids, days)
finally:
await remna.aclose()
if result_bulk is None:
await message.answer("❌ Ошибка при обращении к API панели.")
await state.clear()
return
affected = result_bulk.get("affectedRows", 0)
logger.info(f"[Cluster Extend] Bulk API: продлено {affected} подписок на {days} дней")
for key in keys:
new_expiry = key.expiry_time + add_ms
await update_key_expiry(session, key.client_id, new_expiry)
await session.commit()
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> для <b>{affected}</b> пользователей в кластере <b>{cluster_name}</b>."
)
else:
for key in keys:
new_expiry = key.expiry_time + add_ms
traffic_limit = 0
device_limit = 0
key_subgroup = None
if key.tariff_id:
tariff_result = await session.execute(
select(Tariff.traffic_limit, Tariff.device_limit, Tariff.subgroup_title).where(
Tariff.id == key.tariff_id,
Tariff.is_active.is_(True),
)
)
tariff = tariff_result.first()
if tariff:
traffic_limit = int(tariff[0]) if tariff[0] is not None else 0
device_limit = int(tariff[1]) if tariff[1] is not None else 0
key_subgroup = tariff[2]
await renew_key_in_cluster(
cluster_name,
email=key.email,
client_id=key.client_id,
new_expiry_time=new_expiry,
total_gb=traffic_limit,
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
target_subgroup=key_subgroup,
old_subgroup=key_subgroup,
plan=key.tariff_id,
)
await update_key_expiry(session, key.client_id, new_expiry)
logger.info(f"[Cluster Extend] {key.email} +{days}д → {datetime.utcfromtimestamp(new_expiry / 1000)}")
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
)
except ValueError:
await message.answer("❌ Введите корректное число дней.")
except Exception as e:
logger.error(f"[Cluster Extend] Ошибка при добавлении дней: {e}")
await message.answer("❌ Произошла ошибка при продлении времени.")
finally:
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
):
cluster_name = callback_data.data
await state.update_data(old_cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_cluster</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_cluster_name = message.text.strip()
if len(new_cluster_name) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_cluster_name = user_data.get("old_cluster_name")
try:
result = await session.execute(
select(Server.cluster_name).where(Server.cluster_name == new_cluster_name).limit(1)
)
existing_cluster = result.scalar()
if existing_cluster:
await message.answer(
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count_result = await session.execute(
select(func.count()).select_from(Key).where(Key.server_id == old_cluster_name)
)
keys_count = keys_count_result.scalar()
await session.execute(
update(Server).where(Server.cluster_name == old_cluster_name).values(cluster_name=new_cluster_name)
)
if keys_count > 0:
await session.execute(
update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name)
)
await session.commit()
await message.answer(
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_server(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
state: FSMContext,
session: AsyncSession,
):
old_server_name = callback_data.data
servers = await get_servers(session=session)
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == old_server_name:
cluster_name = c_name
break
if cluster_name:
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным в пределах кластера.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_server</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
async def handle_new_server_name_input(message: Message, state: FSMContext, session: AsyncSession):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_server_name = message.text.strip()
if len(new_server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_server_name = user_data.get("old_server_name")
cluster_name = user_data.get("cluster_name")
try:
result = await session.execute(
select(Server)
.where(
Server.cluster_name == cluster_name,
Server.server_name == new_server_name,
)
.limit(1)
)
existing_server = result.scalar()
if existing_server:
await message.answer(
text=(
f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. "
f"Введите другое имя."
),
reply_markup=build_admin_back_kb("clusters"),
)
return
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == old_server_name))
keys_count = result.scalar()
await session.execute(
update(Server)
.where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
.values(server_name=new_server_name)
)
if keys_count > 0:
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.commit()
await message.answer(
text=(
f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' "
f"в кластере '{cluster_name}'!"
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
+820
View File
@@ -0,0 +1,820 @@
import asyncio
from datetime import datetime, timezone
from typing import Any
from aiogram import F, types
from aiogram.types import CallbackQuery
from py3xui import AsyncApi
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import MODES_CONFIG
from database import get_servers
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from handlers.keys.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
)
from handlers.keys.operations.aggregated_links import make_aggregated_link
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from panels.remnawave import RemnawaveAPI
from utils.backup import create_backup_and_send_to_admins
from ..panel.keyboard import build_admin_back_kb
from .base import router
from .keyboard import AdminClusterCallback, build_availability_kb, build_sync_cluster_kb
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
async def handle_cluster_availability(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
await callback_query.message.edit_text(
text=(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
server_name = server["server_name"]
panel_type = server.get("panel_type", "3x-ui").lower()
prefix = "[3x]" if panel_type == "3x-ui" else "[Re]"
try:
if panel_type == "3x-ui":
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=None,
)
await xui.login()
inbound_id = int(server["inbound_id"])
online_clients = await xui.client.online()
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_inbound_users} онлайн\n"
elif panel_type == "remnawave":
server_inbound_id = server.get("inbound_id")
if not server_inbound_id:
raise Exception("Не указан inbound_id сервера")
remna = RemnawaveAPI(server["api_url"])
nodes_data = await remna.get_all_nodes_with_online(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
inbound_id=server_inbound_id,
)
if nodes_data.get("error"):
raise Exception(nodes_data["error"])
online_remna_users = nodes_data["total_online"]
total_online_users += online_remna_users
nodes_info = nodes_data["nodes"]
result_text += f"🌍 <b>{prefix} {server_name}</b> - {online_remna_users} онлайн\n"
seen = set()
for node_info in nodes_info:
node_name = node_info.get("name", "Unknown")
if node_name in seen:
continue
seen.add(node_name)
country_code = node_info.get("country_code", "Unknown")
online_users = node_info.get("online_users", 0)
flag = (
"".join(chr(ord(c) + 127397) for c in country_code.upper())
if country_code != "Unknown" and len(country_code) == 2
else country_code
)
result_text += f"{flag} ({node_name}): {online_users} онлайн\n"
except Exception as e:
error_text = str(e) or "Сервер недоступен"
result_text += f"❌ <b>{prefix} {server_name}</b> - ошибка: {error_text}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(
text=result_text,
reply_markup=build_availability_kb(cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
if server.get("panel_type") == "remnawave":
continue
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await create_backup_and_send_to_admins(xui)
text = (
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей (3x-ui).</i>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
async def handle_sync(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: Any,
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
async def handle_sync_server(
callback_query: types.CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
server_name = callback_data.data
try:
server_result = await session.execute(
select(Server.cluster_name).where(Server.server_name == server_name).limit(1)
)
cluster_name = server_result.scalar()
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Сервер {server_name} не найден.",
reply_markup=build_admin_back_kb("clusters"),
)
return
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
stmt = (
select(
Server.api_url,
Server.inbound_id,
Server.server_name,
Server.panel_type,
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.tariff_id,
Key.remnawave_link,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
)
.join(Key, Server.server_name == Key.server_id)
.where(Server.server_name == server_name)
)
else:
stmt = (
select(
Server.api_url,
Server.inbound_id,
Server.server_name,
Server.panel_type,
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.tariff_id,
Key.remnawave_link,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
)
.join(Key, Server.cluster_name == Key.server_id)
.where(Server.server_name == server_name)
)
result = await session.execute(stmt)
keys_to_sync = result.mappings().all()
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
all_servers = await get_servers(session)
cluster_servers = all_servers.get(cluster_name, [])
server_info = None
for s in cluster_servers:
if s.get("server_name") == server_name:
server_info = s
break
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
tariffs_cache = {}
if tariff_ids:
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
tariffs_list = tariffs_result.scalars().all()
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
semaphore = asyncio.Semaphore(2)
for key in keys_to_sync:
try:
if key["panel_type"] == "remnawave":
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
if tariff and server_info:
subgroup = tariff.get("subgroup_title")
tid = key["tariff_id"]
has_new_binding = tid and tid in (server_info.get("tariff_ids") or [])
has_old_binding = subgroup and subgroup in (server_info.get("tariff_subgroups") or [])
has_any_binding = bool(server_info.get("tariff_ids") or server_info.get("tariff_subgroups"))
if has_any_binding and subgroup and not has_new_binding and not has_old_binding:
continue
if tariff.get("group_code") and tariff.get("group_code").lower() in ALLOWED_GROUP_CODES:
if tariff.get("group_code").lower() not in server_info.get("special_groups", []):
continue
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000).replace(tzinfo=timezone.utc).isoformat()
)
remna = RemnawaveAPI(key["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
logger.error(f"Не удалось авторизоваться в Remnawave для сервера {server_name}")
continue
traffic_limit_bytes = 0
hwid_limit = 0
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
success = await remna.update_user(
uuid=key["client_id"],
expire_at=expire_iso,
telegram_id=key["tg_id"],
email=f"{key['email']}@fake.local",
active_user_inbounds=[key["inbound_id"]],
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_limit,
)
if success:
try:
sub = await remna.get_subscription_by_username(key["email"])
if sub:
new_remnawave_link = sub.get("subscriptionUrl")
if new_remnawave_link:
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=None,
plan=tariff,
)
await session.execute(
update(Key)
.where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
.values(remnawave_link=new_remnawave_link, key=key_value)
)
await session.commit()
logger.info(f"[Sync] Обновлена ссылка для {key['email']}: {new_remnawave_link}")
except Exception as e:
logger.warning(f"[Sync] Не удалось получить ссылку для {key['email']}: {e}")
if not success:
logger.warning("[Sync] ошибка обновления, пробуем пересоздать")
await delete_key_from_cluster(server_name, key["email"], key["client_id"], session)
await create_key_on_cluster(
cluster_id=server_name,
tg_id=key["tg_id"],
client_id=key["client_id"],
email=key["email"],
expiry_timestamp=key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=hwid_limit,
traffic_limit_bytes=traffic_limit_bytes,
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
else:
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
plan=key["tariff_id"],
session=session,
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при синхронизации ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
async def handle_sync_cluster(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
session: AsyncSession,
):
cluster_name = callback_data.data
try:
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
if use_country_selection:
server_names = [s.get("server_name") for s in cluster_servers if s.get("server_name")]
if not server_names:
await callback_query.message.edit_text(
text=f"В кластере {cluster_name} нет серверов.",
reply_markup=build_admin_back_kb("clusters"),
)
return
result = await session.execute(
select(
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.remnawave_link,
Key.tariff_id,
Key.server_id,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
).where(Key.server_id.in_(server_names), Key.is_frozen.is_(False))
)
else:
result = await session.execute(
select(
Key.tg_id,
Key.client_id,
Key.email,
Key.expiry_time,
Key.remnawave_link,
Key.tariff_id,
Key.server_id,
Key.selected_device_limit,
Key.selected_traffic_limit,
Key.current_device_limit,
Key.current_traffic_limit,
).where(Key.server_id == cluster_name, Key.is_frozen.is_(False))
)
keys_to_sync = result.mappings().all()
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
only_remnawave = all(s.get("panel_type") == "remnawave" for s in cluster_servers)
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
)
tariff_ids = {key["tariff_id"] for key in keys_to_sync if key["tariff_id"]}
tariffs_cache = {}
if tariff_ids:
tariffs_result = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
tariffs_list = tariffs_result.scalars().all()
tariffs_cache = {t.id: dict(t.__dict__) for t in tariffs_list}
if only_remnawave:
batch_size = 50
total_keys = len(keys_to_sync)
processed_count = 0
for batch_start in range(0, total_keys, batch_size):
batch = keys_to_sync[batch_start : batch_start + batch_size]
batch_end = batch_start + len(batch)
logger.info(f"[Sync] Обработка батча {batch_start}-{batch_end} из {total_keys}")
async def update_remnawave_api(key):
try:
traffic_limit_bytes = 0
hwid_limit = 0
subgroup_title = None
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
else:
traffic_limit_bytes = 0
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
subgroup_title = tariff.get("subgroup_title")
expire_iso = (
datetime.utcfromtimestamp(key["expiry_time"] / 1000)
.replace(tzinfo=timezone.utc)
.isoformat()
)
if use_country_selection:
user_server = None
for s in cluster_servers:
if s.get("server_name") == key["server_id"]:
user_server = s
break
if not user_server:
return {"key": key, "success": False, "error": "Server not found"}
remna = RemnawaveAPI(user_server["api_url"])
inbound_ids = [user_server["inbound_id"]] if user_server.get("inbound_id") else []
else:
remna = RemnawaveAPI(cluster_servers[0]["api_url"])
filtered_servers = cluster_servers
if subgroup_title or (tariff and tariff.get("id")):
tid = tariff.get("id") if tariff else None
filtered_servers = [
s
for s in cluster_servers
if (tid and tid in (s.get("tariff_ids") or []))
or (subgroup_title and subgroup_title in (s.get("tariff_subgroups") or []))
]
if not filtered_servers:
filtered_servers = cluster_servers
if tariff and tariff.get("group_code"):
group_code = tariff.get("group_code").lower()
if group_code in ALLOWED_GROUP_CODES:
special_filtered = [
s for s in filtered_servers if group_code in (s.get("special_groups") or [])
]
if special_filtered:
filtered_servers = special_filtered
inbound_ids = [s["inbound_id"] for s in filtered_servers if s.get("inbound_id")]
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
return {"key": key, "success": False, "error": "Login failed"}
success = await remna.update_user(
uuid=key["client_id"],
expire_at=expire_iso,
telegram_id=key["tg_id"],
email=f"{key['email']}@fake.local",
active_user_inbounds=inbound_ids,
traffic_limit_bytes=traffic_limit_bytes,
hwid_device_limit=hwid_limit,
)
if success:
sub = await remna.get_subscription_by_username(key["email"])
new_link = sub.get("subscriptionUrl") if sub else None
return {
"key": key,
"success": True,
"new_link": new_link,
"tariff": tariff,
"traffic_limit_bytes": traffic_limit_bytes,
"hwid_limit": hwid_limit,
}
else:
return {
"key": key,
"success": False,
"needs_recreate": True,
"tariff": tariff,
"traffic_limit_bytes": traffic_limit_bytes,
"hwid_limit": hwid_limit,
}
except Exception as e:
logger.error(f"[Sync] Ошибка API для {key.get('email')}: {e}")
return {"key": key, "success": False, "error": str(e)}
tasks = [update_remnawave_api(key) for key in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
bulk_updates = []
recreate_tasks = []
for result in results:
if isinstance(result, Exception):
logger.error(f"[Sync] Exception в батче: {result}")
continue
if not isinstance(result, dict):
continue
key = result.get("key")
if not key:
continue
try:
if result.get("success") and result.get("new_link"):
new_link = result["new_link"]
tariff = result.get("tariff")
key_value = await make_aggregated_link(
session=session,
cluster_all=cluster_servers,
cluster_id=cluster_name,
email=key["email"],
client_id=key["client_id"],
tg_id=key["tg_id"],
remna_link_override=None,
plan=tariff,
)
bulk_updates.append({
"client_id": key["client_id"],
"remnawave_link": new_link,
"key": key_value,
})
elif result.get("needs_recreate"):
recreate_tasks.append((key, result))
except Exception as e:
logger.error(f"[Sync] Ошибка подготовки для {key.get('email')}: {e}")
if bulk_updates:
try:
await session.run_sync(
lambda sync_session: sync_session.bulk_update_mappings(Key, bulk_updates)
)
await session.commit()
logger.info(f"[Sync] Bulk: обновлено {len(bulk_updates)} ключей")
except Exception as bulk_error:
logger.warning(f"[Sync] Bulk упал, fallback: {bulk_error}")
await session.rollback()
for upd in bulk_updates:
try:
await session.execute(
update(Key)
.where(Key.client_id == upd["client_id"])
.values(remnawave_link=upd["remnawave_link"], key=upd["key"])
)
await session.commit()
except Exception as e:
logger.error(f"[Sync] Fallback ошибка {upd['client_id']}: {e}")
await session.rollback()
for key, result in recreate_tasks:
try:
logger.warning(f"[Sync] Пересоздание {key['email']}")
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
await session.execute(
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
await session.commit()
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
await create_key_on_cluster(
cluster_id_for_recreate,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=result.get("hwid_limit"),
traffic_limit_bytes=result.get("traffic_limit_bytes"),
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
except Exception as e:
logger.error(f"[Sync] Пересоздание ошибка {key.get('email')}: {e}")
processed_count = batch_end
progress_percent = int((processed_count / total_keys) * 100)
progress_bar = "" * (progress_percent // 5) + "" * (20 - progress_percent // 5)
try:
await callback_query.message.edit_text(
text=(
f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n"
f"🔑 Количество ключей: <b>{total_keys}</b>\n\n"
f"Обработано: <b>{processed_count}/{total_keys}</b>\n"
f"<code>{progress_bar}</code>"
)
)
except Exception:
pass
else:
for key in keys_to_sync:
try:
traffic_limit_bytes = 0
hwid_limit = 0
tariff = tariffs_cache.get(key["tariff_id"]) if key["tariff_id"] else None
current_device_limit_from_key = key.get("current_device_limit")
current_traffic_limit_gb_from_key = key.get("current_traffic_limit")
selected_device_limit_from_key = key.get("selected_device_limit")
selected_traffic_limit_gb_from_key = key.get("selected_traffic_limit")
if tariff:
if current_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(current_traffic_limit_gb_from_key * 1024**3)
elif selected_traffic_limit_gb_from_key is not None:
traffic_limit_bytes = int(selected_traffic_limit_gb_from_key * 1024**3)
elif tariff.get("traffic_limit") is not None:
traffic_limit_bytes = int(tariff.get("traffic_limit") * 1024**3)
else:
traffic_limit_bytes = 0
if current_device_limit_from_key is not None:
hwid_limit = int(current_device_limit_from_key)
elif selected_device_limit_from_key is not None:
hwid_limit = int(selected_device_limit_from_key)
else:
hwid_limit = tariff.get("device_limit")
tariff.get("subgroup_title")
elif key["tariff_id"]:
logger.warning(
f"[Sync] Ключ {key['client_id']} с несуществующим тарифом ID={key['tariff_id']}"
f"обновим без лимитов"
)
await delete_key_from_cluster(cluster_name, key["email"], key["client_id"], session)
await session.execute(
delete(Key).where(Key.tg_id == key["tg_id"], Key.client_id == key["client_id"])
)
cluster_id_for_recreate = key["server_id"] if use_country_selection else cluster_name
await create_key_on_cluster(
cluster_id_for_recreate,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
plan=key["tariff_id"],
session=session,
remnawave_link=key["remnawave_link"],
hwid_limit=hwid_limit,
traffic_limit_bytes=traffic_limit_bytes,
selected_device_limit=key.get("selected_device_limit"),
selected_traffic_limit_gb=key.get("selected_traffic_limit"),
current_device_limit=key.get("current_device_limit"),
current_traffic_limit_gb=key.get("current_traffic_limit"),
selected_price_rub=key.get("selected_price_rub"),
)
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"[Sync] Ошибка при обработке ключа {key['client_id']} в {cluster_name}: {e}")
await callback_query.message.edit_text(
text=(
f"✅ <b>Синхронизация завершена</b>\n\n"
f"📊 Кластер: <b>{cluster_name}</b>\n"
f"🔑 Обработано ключей: <b>{len(keys_to_sync)}</b>"
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"[Sync] Ошибка синхронизации кластера {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
+628
View File
@@ -0,0 +1,628 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy import and_, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Server, ServerSpecialgroup, ServerSubgroup, Tariff
from database.servers import has_legacy_subgroup_bindings
from filters.admin import IsAdminFilter
from handlers.utils import ALLOWED_GROUP_CODES
from logger import logger
from .base import router
from .keyboard import (
AdminClusterCallback,
build_attach_tariff_kb,
build_legacy_reset_kb,
build_manage_cluster_kb,
build_select_group_servers_kb,
build_select_subgroup_servers_kb,
build_tariff_group_selection_for_servers_kb,
build_tariff_group_selection_kb,
build_tariff_selection_kb,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "set_tariff"), IsAdminFilter())
async def show_tariff_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession
):
cluster_name = callback_data.data
result = await session.execute(
select(Tariff.id, Tariff.group_code).where(Tariff.group_code.isnot(None)).distinct(Tariff.group_code)
)
rows = result.mappings().all()
groups = [(r["id"], r["group_code"]) for r in rows]
if not groups:
await callback.message.edit_text("❌ Нет доступных тарифных групп.")
return
await callback.message.edit_text(
f"<b>💸 Выберите тарифную группу для кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariff_group"), IsAdminFilter())
async def apply_tariff_group(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name, group_id = callback_data.data.split("|", 1)
group_id = int(group_id)
result = await session.execute(select(Tariff.group_code).where(Tariff.id == group_id))
row = result.mappings().first()
if not row:
await callback.message.edit_text("❌ Тарифная группа не найдена.")
return
group_code = row["group_code"]
await session.execute(update(Server).where(Server.cluster_name == cluster_name).values(tariff_group=group_code))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Для кластера <code>{cluster_name}</code> установлена тарифная группа: <b>{group_code}</b>",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при применении тарифной группы: {e}")
await callback.message.edit_text("❌ Произошла ошибка при установке тарифной группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "set_subgroup"), IsAdminFilter())
async def show_servers_for_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
server_ids = [s.get("server_id") for s in cluster_servers if s.get("server_id")]
if server_ids and await has_legacy_subgroup_bindings(session, server_ids):
await callback.message.edit_text(
f"<b>⚠️ Обнаружены привязки старого формата</b>\n\n"
f"Кластер <code>{cluster_name}</code> содержит привязки по названиям подгрупп.\n"
f"Для использования новой системы необходимо сбросить текущие привязки.\n\n"
f"<i>После сброса вы сможете привязать тарифы по ID.</i>",
reply_markup=build_legacy_reset_kb(cluster_name),
)
return
data = await state.get_data()
selected = set(data.get(f"subgrp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_subgroup"), IsAdminFilter())
async def toggle_server_for_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_subgroup_selection"), IsAdminFilter())
async def reset_tariff_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({
f"subgrp_sel:{cluster_name}": [],
f"tariff_sel:{cluster_name}": [],
})
await callback.message.edit_text(
f"<b>📋 Выберите серверы для привязки тарифов</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_select_subgroup_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_subgroup"), IsAdminFilter())
async def choose_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"subgrp_sel:{cluster_name}"
data = await state.get_data()
selected_servers = set(data.get(key, []))
if not selected_servers:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
await callback.answer("Сначала установите тарифную группу для этого кластера", show_alert=True)
return
group_code = group_codes[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
)
tariffs = result.scalars().all()
if not tariffs:
await callback.message.edit_text("❌ Для этой группы нет доступных тарифов.")
return
servers_q = await session.execute(select(Server.id).where(Server.server_name.in_(selected_servers)))
server_ids = [row[0] for row in servers_q.fetchall()]
current_bindings_q = await session.execute(
select(ServerSubgroup.subgroup_title)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
)
current_tariff_ids = {int(row[0]) for row in current_bindings_q.fetchall()}
await state.update_data({f"tariff_sel:{cluster_name}": list(current_tariff_ids)})
await callback.message.edit_text(
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, current_tariff_ids),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_tariff"), IsAdminFilter())
async def toggle_tariff_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, tariff_id_str = callback_data.data.split("|", 1)
tariff_id = int(tariff_id_str)
key = f"tariff_sel:{cluster_name}"
data = await state.get_data()
selected_tariffs = set(data.get(key, []))
if tariff_id in selected_tariffs:
selected_tariffs.remove(tariff_id)
else:
selected_tariffs.add(tariff_id)
await state.update_data({key: list(selected_tariffs)})
res = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).distinct())
group_codes = [r[0] for r in res.fetchall() if r[0]]
if not group_codes:
return
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_codes[0], Tariff.is_active.is_(True))
.order_by(Tariff.subgroup_title.nulls_last(), Tariff.sort_order, Tariff.id)
)
tariffs = result.scalars().all()
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>📋 Выберите тарифы для {len(selected_servers)} сервер(а/ов)</b>\n<i>Кластер: {cluster_name}</i>",
reply_markup=build_tariff_selection_kb(cluster_name, tariffs, selected_tariffs),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_tariffs"), IsAdminFilter())
async def apply_tariffs(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name = callback_data.data
data = await state.get_data()
selected_servers = set(data.get(f"subgrp_sel:{cluster_name}", []))
selected_tariffs = set(data.get(f"tariff_sel:{cluster_name}", []))
if not selected_servers:
await callback.answer("Не выбраны серверы", show_alert=True)
return
servers_q = await session.execute(
select(Server.id, Server.server_name, Server.tariff_group).where(Server.server_name.in_(selected_servers))
)
servers_data = servers_q.fetchall()
server_ids = [row[0] for row in servers_data]
group_code = servers_data[0][2] if servers_data else "standard"
if not server_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
selected_tariff_strs = {str(tid) for tid in selected_tariffs}
await session.execute(
delete(ServerSubgroup)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title.regexp_match(r"^\d+$"))
.where(ServerSubgroup.subgroup_title.notin_(selected_tariff_strs))
)
for tariff_id in selected_tariffs:
tariff_id_str = str(tariff_id)
existing_q = await session.execute(
select(ServerSubgroup.server_id)
.where(ServerSubgroup.server_id.in_(server_ids))
.where(ServerSubgroup.subgroup_title == tariff_id_str)
)
already = {r[0] for r in existing_q.fetchall()}
to_insert = [sid for sid in server_ids if sid not in already]
if to_insert:
session.add_all([
ServerSubgroup(server_id=sid, group_code=group_code, subgroup_title=tariff_id_str)
for sid in to_insert
])
await session.commit()
await state.update_data({
f"subgrp_sel:{cluster_name}": [],
f"tariff_sel:{cluster_name}": [],
})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
all_tariff_ids = set()
for s in cluster_servers:
all_tariff_ids.update(s.get("tariff_ids") or [])
tariffs_cache = {}
if all_tariff_ids:
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
for t in result.scalars().all():
tariffs_cache[t.id] = {
"id": t.id,
"name": t.name,
"subgroup_title": t.subgroup_title,
"group_code": t.group_code,
}
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при применении тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении тарифов.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_subgroups"), IsAdminFilter())
async def reset_cluster_subgroups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSubgroup).where(ServerSubgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все подгруппы тарифов сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе подгрупп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить подгруппы.")
def render_attach_tariff_menu_text(
cluster_name: str, cluster_servers: list[dict], tariffs_cache: dict[int, dict] | None = None
) -> str:
tariff_map: dict[int, list[str]] = {}
legacy_map: dict[str, list[str]] = {}
for s in cluster_servers:
server_name = s["server_name"]
for tid in s.get("tariff_ids") or []:
tariff_map.setdefault(tid, []).append(server_name)
for sg in s.get("tariff_subgroups") or []:
legacy_map.setdefault(sg, []).append(server_name)
allowed = tuple(ALLOWED_GROUP_CODES)
spec_map: dict[str, list[str]] = {k: [] for k in allowed}
for s in cluster_servers:
for g in s.get("special_groups") or []:
if g in spec_map:
spec_map[g].append(s["server_name"])
lines = [f"<b>🧩 Привязки тарифов • {cluster_name}</b>"]
lines.append("\n<b>📋 Тарифы:</b>")
if tariff_map and tariffs_cache:
grouped: dict[str | None, list[tuple[int, str, list[str]]]] = {}
for tid, servers in tariff_map.items():
tariff = tariffs_cache.get(tid, {})
subgroup = tariff.get("subgroup_title")
name = tariff.get("name", f"ID:{tid}")
grouped.setdefault(subgroup, []).append((tid, name, servers))
tariff_lines = []
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
for subgroup in subgroups_sorted:
tariffs_list = grouped[subgroup]
if subgroup:
tariff_lines.append(f"<b>{subgroup}</b>")
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"{name}: {servers_str}")
else:
for tid, name, servers in sorted(tariffs_list, key=lambda x: x[1]):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"{name}: {servers_str}")
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
elif tariff_map:
tariff_lines = []
for tid, servers in sorted(tariff_map.items()):
servers_str = ", ".join(sorted(set(servers)))
tariff_lines.append(f"• ID:{tid}: {servers_str}")
lines.append("<blockquote>" + "\n".join(tariff_lines) + "</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
if legacy_map:
lines.append("\n<b>⚠️ Старые привязки (по названию):</b>")
legacy_lines = []
for k in sorted(legacy_map):
servers_list = ", ".join(sorted(set(legacy_map[k])))
legacy_lines.append(f"• <b>{k}</b>: {servers_list}")
lines.append("<blockquote>" + "\n".join(legacy_lines) + "</blockquote>")
lines.append("<i>Рекомендуется сбросить и настроить заново</i>")
lines.append("\n<b>🎁 Спецгруппы:</b>")
has_spec = any(spec_map[k] for k in allowed)
if has_spec:
spec_lines = []
for k in allowed:
vals = sorted(set(spec_map[k]))
spec_lines.append(f"• <b>{k}</b>: {', '.join(vals) if vals else ''}")
lines.append("<blockquote>" + "\n".join(spec_lines) + "</blockquote>")
else:
lines.append("<blockquote>— нет привязок</blockquote>")
return "\n".join(lines)
@router.callback_query(AdminClusterCallback.filter(F.action == "attach_tariff_menu"), IsAdminFilter())
async def handle_attach_tariff_menu(callback: CallbackQuery, session: AsyncSession):
packed = AdminClusterCallback.unpack(callback.data)
cluster_name = packed.data
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
all_tariff_ids = set()
for s in cluster_servers:
all_tariff_ids.update(s.get("tariff_ids") or [])
tariffs_cache = {}
if all_tariff_ids:
result = await session.execute(select(Tariff).where(Tariff.id.in_(all_tariff_ids)))
for t in result.scalars().all():
tariffs_cache[t.id] = {
"id": t.id,
"name": t.name,
"subgroup_title": t.subgroup_title,
"group_code": t.group_code,
}
text = render_attach_tariff_menu_text(cluster_name, cluster_servers, tariffs_cache)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
@router.callback_query(AdminClusterCallback.filter(F.action == "set_group"), IsAdminFilter())
async def show_servers_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
data = await state.get_data()
selected = set(data.get(f"grp_sel:{cluster_name}", []))
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "toggle_server_group"), IsAdminFilter())
async def toggle_server_for_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
if i < 0 or i >= len(names):
await callback.answer("Сервер не найден", show_alert=True)
return
server_name = names[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if server_name in selected:
selected.remove(server_name)
else:
selected.add(server_name)
await state.update_data({key: list(selected)})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, selected),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_group_selection"), IsAdminFilter())
async def reset_group_selection(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await state.update_data({f"grp_sel:{cluster_name}": []})
await callback.message.edit_text(
f"<b>🗂 Выберите серверы в кластере <code>{cluster_name}</code> для назначения тарифной группы:</b>",
reply_markup=build_select_group_servers_kb(cluster_name, cluster_servers, set()),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "choose_group"), IsAdminFilter())
async def choose_group(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
cluster_name = callback_data.data
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.answer("Сначала выберите хотя бы один сервер", show_alert=True)
return
groups = [(i, code) for i, code in enumerate(ALLOWED_GROUP_CODES)]
await callback.message.edit_text(
f"<b>📚 Выберите группу для {len(selected)} сервер(а/ов) кластера <code>{cluster_name}</code>:</b>",
reply_markup=build_tariff_group_selection_for_servers_kb(cluster_name, groups),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "apply_group_to_servers"), IsAdminFilter())
async def apply_group_to_servers(
callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession, state: FSMContext
):
try:
cluster_name, idx_str = callback_data.data.split("|", 1)
i = int(idx_str)
groups = ALLOWED_GROUP_CODES
if i < 0 or i >= len(groups):
await callback.answer("Группа не найдена", show_alert=True)
return
group_code = groups[i]
key = f"grp_sel:{cluster_name}"
data = await state.get_data()
selected = set(data.get(key, []))
if not selected:
await callback.message.edit_text("❌ Не выбраны серверы для назначения группы.")
return
rows = await session.execute(select(Server.id, Server.server_name).where(Server.server_name.in_(selected)))
id_by_name = {name: sid for sid, name in rows.fetchall()}
server_ids = [id_by_name[n] for n in selected if n in id_by_name]
if not server_ids:
await callback.answer("Серверы не найдены", show_alert=True)
return
exist_rows = await session.execute(
select(ServerSpecialgroup.server_id).where(
and_(ServerSpecialgroup.server_id.in_(server_ids), ServerSpecialgroup.group_code == group_code)
)
)
already = {r[0] for r in exist_rows.fetchall()}
to_insert = [sid for sid in server_ids if sid not in already]
if to_insert:
session.add_all([ServerSpecialgroup(server_id=sid, group_code=group_code) for sid in to_insert])
await session.commit()
logger.debug(f"[apply_group_to_servers] group={group_code} server_ids={server_ids}")
await state.update_data({key: []})
servers = await get_servers(session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
text = render_attach_tariff_menu_text(cluster_name, cluster_servers)
await callback.message.edit_text(
text=text,
reply_markup=build_attach_tariff_kb(cluster_name),
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Ошибка при назначении группы тарифов: {e}")
await callback.message.edit_text("❌ Произошла ошибка при назначении группы.")
@router.callback_query(AdminClusterCallback.filter(F.action == "reset_cluster_groups"), IsAdminFilter())
async def reset_cluster_groups(callback: CallbackQuery, callback_data: AdminClusterCallback, session: AsyncSession):
try:
cluster_name = callback_data.data
res = await session.execute(select(Server.id).where(Server.cluster_name == cluster_name))
server_ids = [row[0] for row in res.fetchall()]
if not server_ids:
await callback.answer("В кластере нет серверов", show_alert=True)
return
await session.execute(delete(ServerSpecialgroup).where(ServerSpecialgroup.server_id.in_(server_ids)))
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_servers = servers.get(cluster_name, [])
await callback.message.edit_text(
f"✅ Все привязки групп сброшены для кластера <b>{cluster_name}</b>.",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
except Exception as e:
logger.error(f"Ошибка при сбросе групп для кластера {cluster_name}: {e}")
await callback.message.edit_text("❌ Не удалось сбросить привязки групп.")
@@ -0,0 +1,98 @@
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from config import USE_COUNTRY_SELECTION
from core.bootstrap import MODES_CONFIG
from database.models import Key, Server
from logger import logger
from ..panel.keyboard import build_admin_back_kb
from .base import router
@router.callback_query(F.data.startswith("transfer_to_server|"), IsAdminFilter())
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_server_name = data[1]
old_server_name = data[2]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_server_name))
await session.execute(
delete(Server).where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
)
await session.commit()
use_country_selection = bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION))
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
sync_reminder = '\n\n⚠️ Не забудьте сделать "Синхронизацию".'
final_text = base_text + (sync_reminder if use_country_selection else "")
await callback_query.message.edit_text(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_cluster|"), IsAdminFilter())
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
data = callback_query.data.split("|")
new_cluster_name = data[1]
old_cluster_name = data[2]
old_server_name = data[3]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await session.execute(update(Key).where(Key.server_id == old_server_name).values(server_id=new_cluster_name))
await session.execute(update(Key).where(Key.server_id == old_cluster_name).values(server_id=new_cluster_name))
await session.execute(
delete(Server).where(
Server.cluster_name == cluster_name,
Server.server_name == old_server_name,
)
)
await session.commit()
await callback_query.message.edit_text(
text=(
f"✅ Ключи успешно перенесены в кластер '<b>{new_cluster_name}</b>', "
f"сервер '<b>{old_server_name}</b>' и кластер '<b>{old_cluster_name}</b>' удалены!\n\n"
f'⚠️ Не забудьте сделать "Синхронизацию".'
),
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
await session.rollback()
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await state.clear()
+260
View File
@@ -0,0 +1,260 @@
from typing import Any
from aiogram import F
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import check_unique_server_name, get_servers
from database.models import Server
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .base import AdminClusterStates, router
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_clusters_editor_kb,
build_panel_type_kb,
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "clusters"),
IsAdminFilter(),
)
async def handle_servers(callback_query: CallbackQuery, session: AsyncSession):
servers = await get_servers(session, include_enabled=True)
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<blockquote>"
"🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер."
"</blockquote>\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
message = callback_query.message
markup = build_clusters_editor_kb(servers)
if message and message.text:
await message.edit_text(text=text, reply_markup=markup)
else:
try:
await message.delete()
except Exception:
pass
await message.answer(text=text, reply_markup=markup)
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя должно быть уникальным!</b>\n"
"<b>Имя не должно превышать 12 символов!</b>\n\n"
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: Message, state: FSMContext):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
if len(message.text) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
server_name = message.text.strip()
if len(server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
if not await check_unique_server_name(session, server_name, cluster_name):
await message.answer(
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"🔍 Ссылку можно найти в адресной строке браузера при входе в панель управления сервером.\n\n"
"️ <b>Формат для 3X-UI:</b>\n"
"<code>https://your-domain.com:port/panel_path/</code>\n\n"
"️ <b>Формат для Remnawave:</b>\n"
"<code>https://your-domain.com/api</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_api_url)
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Если вы используете Remnawave — введите <code>0</code>\n\n"
"<i>Формат:</i> <code>https://your_domain:port/sub_path</code>"
)
await message.answer(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
raw = message.text.strip()
subscription_url = None if raw == "0" else raw.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
await message.answer(
text=f"<b>Введите inbound_id/Squads для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
f"Для Remnawave это UUID Squads, для 3x-ui — просто ID (например, <code>1</code>).",
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
await state.update_data(inbound_id=inbound_id)
await message.answer(
text=(
"🧩 <b>Выберите тип панели для этого сервера:</b>\n\n"
"⚠️ <b>Внимание:</b> Некоторые функции <b>Remnawave</b> находятся в разработке.\n"
"Поддержка режима выбора стран — <b>ограничена</b>."
),
reply_markup=build_panel_type_kb(),
)
@router.callback_query(
AdminClusterCallback.filter(F.action.in_(["panel_3xui", "panel_remnawave"])),
IsAdminFilter(),
)
async def handle_panel_type_selection(
callback_query: CallbackQuery,
callback_data: AdminClusterCallback,
state: FSMContext,
session: AsyncSession,
):
panel_type = "3x-ui" if callback_data.action == "panel_3xui" else "remnawave"
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
inbound_id = user_data.get("inbound_id")
result = await session.execute(select(Server.tariff_group).where(Server.cluster_name == cluster_name).limit(1))
row = result.first()
tariff_group = row[0] if row else None
new_server = Server(
cluster_name=cluster_name,
server_name=server_name,
api_url=api_url,
subscription_url=subscription_url,
inbound_id=inbound_id,
panel_type=panel_type,
tariff_group=tariff_group,
)
session.add(new_server)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер <b>{server_name}</b> с панелью <b>{panel_type}</b> успешно добавлен в кластер <b>{cluster_name}</b>!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
-829
View File
@@ -1,829 +0,0 @@
import asyncio
import time
from typing import Any
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL, TOTAL_GB, USE_COUNTRY_SELECTION
from database import check_unique_server_name, get_servers, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster, renew_key_in_cluster
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
AdminClusterCallback,
AdminServerCallback,
build_cluster_management_kb,
build_clusters_editor_kb,
build_manage_cluster_kb,
build_sync_cluster_kb,
)
router = Router()
class AdminClusterStates(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
waiting_for_days_input = State()
waiting_for_new_cluster_name = State()
waiting_for_new_server_name = State()
waiting_for_server_transfer = State()
waiting_for_cluster_transfer = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "clusters"),
IsAdminFilter(),
)
async def handle_servers(callback_query: CallbackQuery):
servers = await get_servers()
text = (
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_clusters_editor_kb(servers),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя должно быть уникальным!</b>\n"
"<b>Имя не должно превышать 12 символов!</b>\n\n"
"<i>Пример:</i> <code>cluster1</code> или <code>us_east_1</code>"
)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb("clusters"))
await state.set_state(AdminClusterStates.waiting_for_cluster_name)
@router.message(AdminClusterStates.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: Message, state: FSMContext):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
)
return
if len(message.text) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
cluster_name = message.text.strip()
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.message(AdminClusterStates.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым. Попробуйте снова.", reply_markup=build_admin_back_kb("clusters")
)
return
server_name = message.text.strip()
if len(server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
if not await check_unique_server_name(server_name, session, cluster_name):
await message.answer(
text="❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в поисковой строке браузера, при входе в 3X-UI.\n\n"
"️ Формат API URL:\n"
"<code>https://your_domain:port/panel_path/</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_api_url)
@router.message(AdminClusterStates.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
api_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
text = (
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Ссылку можно найти в панели 3X-UI, в информации о клиенте.\n\n"
"️ Формат Subscription URL:\n"
"<code>https://your_domain:port_sub/sub_path/</code>"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_subscription_url)
@router.message(AdminClusterStates.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: Message, state: FSMContext):
if not message.text or not message.text.strip().startswith("https://"):
await message.answer(
text="❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
subscription_url = message.text.strip().rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
text = (
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_inbound_id)
@router.message(AdminClusterStates.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: Message, state: FSMContext):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
text="❌ inbound_id должен быть числовым значением. Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
VALUES ($1, $2, $3, $4, $5)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
)
await conn.close()
await message.answer(
text=f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
reply_markup=build_admin_back_kb("clusters"),
)
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔧 Управление кластером {cluster_name}</b>",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "availability"), IsAdminFilter())
async def handle_cluster_availability(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.message.edit_text(text=f"Кластер '{cluster_name}' не содержит серверов.")
return
text = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
await callback_query.message.edit_text(text=text)
total_online_users = 0
result_text = f"<b>🖥️ Проверка доступности серверов</b>\n\n⚙️ Кластер: <b>{cluster_name}</b>\n\n"
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD, logger=None)
try:
await xui.login()
online_clients = await xui.client.online()
inbound_id = int(server["inbound_id"])
online_inbound_users = 0
for client_email in online_clients:
client = await xui.client.get_by_email(client_email)
if client and client.inbound_id == inbound_id:
online_inbound_users += 1
total_online_users += online_inbound_users
result_text += f"🌍 <b>{server['server_name']}</b> - {online_inbound_users} онлайн\n"
except Exception as e:
result_text += f"❌ <b>{server['server_name']}</b> - ошибка: {str(e) if str(e).strip() else 'Сервер недоступен'}\n"
result_text += f"\n👥 Всего пользователей онлайн: {total_online_users}"
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("clusters"))
@router.callback_query(AdminClusterCallback.filter(F.action == "backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any
):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await create_backup_and_send_to_admins(xui)
text = (
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync"), IsAdminFilter())
async def handle_sync(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
cluster_name = callback_data.data
servers = await get_servers(session)
cluster_servers = servers.get(cluster_name, [])
await callback_query.message.edit_text(
text=f"<b>🔄 Синхронизация кластера {cluster_name}</b>",
reply_markup=build_sync_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-server"), IsAdminFilter())
async def handle_sync_server(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
server_name = callback_data.data
try:
query_keys = """
SELECT s.*, k.tg_id, k.client_id, k.email, k.expiry_time
FROM servers s
JOIN keys k ON s.cluster_name = k.server_id
WHERE s.server_name = $1;
"""
keys_to_sync = await session.fetch(query_keys, server_name)
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в сервере {server_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
text = f"<b>🔄 Синхронизация сервера {server_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
)
semaphore = asyncio.Semaphore(2)
for key in keys_to_sync:
try:
await create_client_on_server(
{
"api_url": key["api_url"],
"inbound_id": key["inbound_id"],
"server_name": key["server_name"],
},
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
semaphore,
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в сервер {server_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для сервера {server_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей для сервера {server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
)
@router.callback_query(AdminClusterCallback.filter(F.action == "sync-cluster"), IsAdminFilter())
async def handle_sync_cluster(callback_query: types.CallbackQuery, callback_data: AdminClusterCallback, session: Any):
cluster_name = callback_data.data
try:
query_keys = """
SELECT tg_id, client_id, email, expiry_time
FROM keys
WHERE server_id = $1
"""
keys_to_sync = await session.fetch(query_keys, cluster_name)
if not keys_to_sync:
await callback_query.message.edit_text(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("clusters"),
)
return
text = f"<b>🔄 Синхронизация кластера {cluster_name}</b>\n\n🔑 Количество ключей: <b>{len(keys_to_sync)}</b>"
await callback_query.message.edit_text(
text=text,
)
for key in keys_to_sync:
try:
await create_key_on_cluster(
cluster_name,
key["tg_id"],
key["client_id"],
key["email"],
key["expiry_time"],
)
await asyncio.sleep(0.6)
except Exception as e:
logger.error(f"Ошибка при добавлении ключа {key['client_id']} в кластер {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("clusters")
)
@router.callback_query(AdminServerCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_add_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(cluster_name=cluster_name)
text = (
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию и номер сервера в имени.\n\n"
"<i>Пример:</i> <code>de1</code>, <code>fra1</code>, <code>fi2</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_server_name)
@router.callback_query(AdminClusterCallback.filter(F.action == "manage_cluster"), IsAdminFilter())
async def handle_manage_cluster_menu(callback_query: CallbackQuery, callback_data: AdminClusterCallback):
cluster_name = callback_data.data
await callback_query.message.edit_text(
text=f"<b>🛠 Управление кластером {cluster_name}</b>\nВыберите действие:",
reply_markup=build_cluster_management_kb(cluster_name),
)
@router.callback_query(AdminClusterCallback.filter(F.action == "add_time"), IsAdminFilter())
async def handle_add_time(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
await state.set_state(AdminClusterStates.waiting_for_days_input)
await state.update_data(cluster_name=cluster_name)
await callback_query.message.edit_text(
f"⏳ Введите количество дней, на которое хотите продлить все подписки в кластере <b>{cluster_name}</b>:",
reply_markup=build_admin_back_kb("clusters"),
)
@router.message(AdminClusterStates.waiting_for_days_input, IsAdminFilter())
async def handle_days_input(message: Message, state: FSMContext, session: Any):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
now = int(time.time() * 1000)
add_ms = days * 86400 * 1000
keys = await session.fetch(
"SELECT tg_id, client_id, email, expiry_time FROM keys WHERE server_id = $1",
cluster_name,
)
if not keys:
await message.answer("❌ Нет подписок в этом кластере.")
await state.clear()
return
for key in keys:
new_expiry = (key["expiry_time"] or now) + add_ms
await renew_key_in_cluster(
cluster_name,
email=key["email"],
client_id=key["client_id"],
new_expiry_time=new_expiry,
total_gb=TOTAL_GB,
)
await update_key_expiry(key["client_id"], new_expiry, session)
await message.answer(
f"✅ Время подписки продлено на <b>{days} дней</b> всем пользователям в кластере <b>{cluster_name}</b>."
)
except ValueError:
await message.answer("❌ Введите корректное число дней.")
return
except Exception as e:
logger.error(f"Ошибка при добавлении дней: {e}")
await message.answer("❌ Произошла ошибка при продлении времени.")
finally:
await state.clear()
@router.callback_query(AdminClusterCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_cluster(callback_query: CallbackQuery, callback_data: AdminClusterCallback, state: FSMContext):
cluster_name = callback_data.data
await state.update_data(old_cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для кластера '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_cluster</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_cluster_name)
@router.message(AdminClusterStates.waiting_for_new_cluster_name, IsAdminFilter())
async def handle_new_cluster_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя кластера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_cluster_name = message.text.strip()
if len(new_cluster_name) > 12:
await message.answer(
text="❌ Имя кластера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_cluster_name = user_data.get("old_cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
existing_cluster = await conn.fetchval(
"SELECT cluster_name FROM servers WHERE cluster_name = $1 LIMIT 1",
new_cluster_name
)
if existing_cluster:
await message.answer(
text=f"❌ Кластер с именем '{new_cluster_name}' уже существует. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count = await conn.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
old_cluster_name
)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET cluster_name = $1 WHERE cluster_name = $2",
new_cluster_name,
old_cluster_name
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_cluster_name
)
await message.answer(
text=f"✅ Название кластера успешно изменено с '{old_cluster_name}' на '{new_cluster_name}'!",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при смене имени кластера {old_cluster_name} на {new_cluster_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени кластера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(AdminServerCallback.filter(F.action == "rename"), IsAdminFilter())
async def handle_rename_server(callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext):
old_server_name = callback_data.data
servers = await get_servers()
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == old_server_name:
cluster_name = c_name
break
if cluster_name:
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{old_server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
await state.update_data(old_server_name=old_server_name, cluster_name=cluster_name)
text = (
f"✏️ <b>Введите новое имя для сервера '{old_server_name}' в кластере '{cluster_name}':</b>\n\n"
"▸ Имя должно быть уникальным в пределах кластера.\n"
"▸ Имя не должно превышать 12 символов.\n\n"
"📌 <i>Пример:</i> <code>new_server</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("clusters"),
)
await state.set_state(AdminClusterStates.waiting_for_new_server_name)
@router.message(AdminClusterStates.waiting_for_new_server_name, IsAdminFilter())
async def handle_new_server_name_input(message: Message, state: FSMContext, session: Any):
if not message.text:
await message.answer(
text="❌ Имя сервера не может быть пустым! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
new_server_name = message.text.strip()
if len(new_server_name) > 12:
await message.answer(
text="❌ Имя сервера не должно превышать 12 символов! Попробуйте снова.",
reply_markup=build_admin_back_kb("clusters"),
)
return
user_data = await state.get_data()
old_server_name = user_data.get("old_server_name")
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
existing_server = await conn.fetchval(
"SELECT server_name FROM servers WHERE cluster_name = $1 AND server_name = $2 LIMIT 1",
cluster_name,
new_server_name
)
if existing_server:
await message.answer(
text=f"❌ Сервер с именем '{new_server_name}' уже существует в кластере '{cluster_name}'. Введите другое имя.",
reply_markup=build_admin_back_kb("clusters"),
)
return
keys_count = await conn.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
old_server_name
)
async with conn.transaction():
await conn.execute(
"UPDATE servers SET server_name = $1 WHERE cluster_name = $2 AND server_name = $3",
new_server_name,
cluster_name,
old_server_name
)
if keys_count > 0:
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_server_name,
old_server_name
)
final_text = f"✅ Название сервера успешно изменено с '{old_server_name}' на '{new_server_name}' в кластере '{cluster_name}'!"
await message.answer(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при смене имени сервера {old_server_name} на {new_server_name}: {e}")
await message.answer(
text=f"❌ Произошла ошибка при смене имени сервера: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_server|"))
async def handle_server_transfer(callback_query: CallbackQuery, state: FSMContext):
data = callback_query.data.split("|")
new_server_name = data[1]
old_server_name = data[2]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_server_name,
old_server_name
)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
old_server_name
)
base_text = f"✅ Ключи успешно перенесены на сервер '{new_server_name}', сервер '{old_server_name}' удален!"
sync_reminder = "\n\n⚠️ Не забудьте сделать \"Синхронизацию\"."
final_text = base_text + (sync_reminder if USE_COUNTRY_SELECTION else "")
await callback_query.message.edit_text(
text=final_text,
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при переносе ключей на сервер {new_server_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
@router.callback_query(F.data.startswith("transfer_to_cluster|"))
async def handle_cluster_transfer(callback_query: CallbackQuery, state: FSMContext):
data = callback_query.data.split("|")
new_cluster_name = data[1]
old_cluster_name = data[2]
old_server_name = data[3]
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
conn = await asyncpg.connect(DATABASE_URL)
try:
async with conn.transaction():
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_server_name
)
await conn.execute(
"UPDATE keys SET server_id = $1 WHERE server_id = $2",
new_cluster_name,
old_cluster_name
)
await conn.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
old_server_name
)
await callback_query.message.edit_text(
text=f"✅ Ключи успешно перенесены в кластер '{new_cluster_name}', сервер '{old_server_name}' и кластер '{old_cluster_name}' удалены!\n\n⚠️ Не забудьте сделать \"Синхронизацию\".",
reply_markup=build_admin_back_kb("clusters"),
)
except Exception as e:
logger.error(f"Ошибка при переносе ключей в кластер {new_cluster_name}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при переносе ключей: {e}",
reply_markup=build_admin_back_kb("clusters"),
)
finally:
await conn.close()
await state.clear()
+297 -19
View File
@@ -1,13 +1,10 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
from handlers.buttons import BACK
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
@@ -20,16 +17,23 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
cluster_names = list(servers.keys())
for i in range(0, len(cluster_names), 2):
builder.row(*[
InlineKeyboardButton(
text=f"⚙️ {name}",
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
row_buttons = []
for name in cluster_names[i : i + 2]:
servers_in_cluster = servers[name]
all_disabled = all(not s["enabled"] for s in servers_in_cluster)
label = f"{name} (отключен)" if all_disabled else f"⚙️ {name}"
row_buttons.append(
InlineKeyboardButton(
text=label,
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
)
)
for name in cluster_names[i : i + 2]
])
builder.row(*row_buttons)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
InlineKeyboardButton(
text=" Добавить кластер",
callback_data=AdminClusterCallback(action="add").pack(),
)
)
builder.row(build_admin_back_btn())
@@ -54,21 +58,186 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🛠 Управление",
callback_data=AdminClusterCallback(action="manage_cluster", data=cluster_name).pack(),
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📋 Привязать тарифы",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🗂 Спецгруппы",
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_groups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_legacy_reset_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🧹 Сбросить привязки",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_select_subgroup_servers_kb(
cluster_name: str, cluster_servers: list, selected: set[str]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_subgroup", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📋 Выбрать тарифы",
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_subgroup_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for i, title in enumerate(subgroups):
builder.button(
text=title,
callback_data=AdminClusterCallback(action="apply_tariff_subgroup", data=f"{cluster_name}|{i}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад к выбору серверов",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_tariff_selection_kb(cluster_name: str, tariffs: list, selected: set[int]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
grouped: dict[str | None, list] = {}
for t in tariffs:
subgroup = t.subgroup_title
grouped.setdefault(subgroup, []).append(t)
subgroups_sorted = sorted(grouped.keys(), key=lambda x: (x is None, x or ""))
for subgroup in subgroups_sorted:
tariffs_list = grouped[subgroup]
if subgroup:
builder.row(
InlineKeyboardButton(
text=f"━━ {subgroup} ━━",
callback_data="noop",
)
)
for t in tariffs_list:
mark = "" if t.id in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {t.name}",
callback_data=AdminClusterCallback(action="toggle_tariff", data=f"{cluster_name}|{t.id}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✅ Применить",
callback_data=AdminClusterCallback(action="apply_tariffs", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад к выбору серверов",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📡 Серверы",
callback_data=f"cluster_servers|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
@@ -101,10 +270,11 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
@@ -130,3 +300,111 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(
text="🌀 Remnawave",
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(action="apply_tariff_group", data=f"{cluster_name}|{group_id}").pack(),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, selected: set[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_group", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📚 Выбрать спецгруппу",
callback_data=AdminClusterCallback(action="choose_group", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_group_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_group_selection_for_servers_kb(
cluster_name: str, groups: list[tuple[int, str]]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(
action="apply_group_to_servers", data=f"{cluster_name}|{group_id}"
).pack(),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_availability_kb(cluster_name: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🔁 Обновить",
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
)
],
[
InlineKeyboardButton(
text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack()
)
],
]
)
+228 -229
View File
@@ -1,6 +1,3 @@
from datetime import datetime
import html
import pytz
from typing import Any
from aiogram import F, Router
@@ -17,35 +14,29 @@ from aiogram.types import (
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import (
add_connection,
check_connection_exists,
create_coupon,
create_coupon_usage,
delete_coupon,
get_all_coupons,
get_keys,
update_key_expiry,
)
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.utils import format_days
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
from .keyboard import (
AdminCouponDeleteCallback,
build_coupons_kb,
build_coupons_list_kb,
format_coupons_list,
)
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_coupon_audience = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
waiting_for_key_selection = State()
waiting_for_percent_data = State()
@router.callback_query(
@@ -65,6 +56,7 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
kb = InlineKeyboardBuilder()
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
kb.button(text="⏳ Время", callback_data="coupon_type_days")
kb.button(text="📉 Процент", callback_data="coupon_type_percent")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
@@ -72,34 +64,82 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
)
async def show_coupon_audience_step(callback_query: CallbackQuery, state: FSMContext):
text = "🎯 <b>Кому доступен купон?</b>"
kb = InlineKeyboardBuilder()
kb.button(text="👤 Всем", callback_data="coupon_audience_all")
kb.button(text="🆕 Только новым", callback_data="coupon_audience_new")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
await state.set_state(AdminCouponsState.waiting_for_coupon_audience)
@router.callback_query(F.data == "coupon_type_days")
@router.callback_query(F.data == "coupon_type_balance", IsAdminFilter())
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="balance")
await show_coupon_audience_step(callback_query, state)
@router.callback_query(F.data == "coupon_type_days", IsAdminFilter())
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="days", new_users_only=False)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
"Пример: <b>'DAYS10 10 50'</b>\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_days_data)
@router.callback_query(F.data == "coupon_type_percent", IsAdminFilter())
async def handle_percent_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
await state.update_data(coupon_type="percent", new_users_only=False)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 📉 <i>процент</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'SALE20 20 10'</b>\n"
"Где 20 — это скидка 20%\n\n"
)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_percent_data)
@router.callback_query(F.data.in_(("coupon_audience_all", "coupon_audience_new")), IsAdminFilter())
async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
coupon_type = data.get("coupon_type")
if coupon_type != "balance":
await callback_query.answer("Ошибка: режим доступен только для купонов на баланс", show_alert=True)
return
await state.update_data(new_users_only=callback_query.data == "coupon_audience_new")
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b>\n\n"
)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
@@ -107,12 +147,13 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"❌ <b>Некорректный формат!</b>\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
"Пример: <b>'COUPON1 50 5'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
@@ -122,20 +163,39 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
coupon_amount = int(parts[1])
usage_limit = int(parts[2])
if coupon_amount <= 0:
raise ValueError("Сумма должна быть больше 0")
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
text = "⚠️ <b>Проверьте данные!</b>\nСумма и лимит должны быть целыми числами больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
data = await state.get_data()
new_users_only = bool(data.get("new_users_only"))
ok = await create_coupon(
session,
coupon_code,
coupon_amount,
usage_limit,
days=None,
new_users_only=new_users_only,
percent=None,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"✅ Купон <b>{coupon_code}</b> создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
f"🎯 Доступ: <b>{audience_txt}</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
@@ -147,7 +207,6 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@@ -160,12 +219,11 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
"Пример: <b>'DAYS10 10 50'</b> 👈"
"❌ <b>Некорректный формат!</b>\n🏷️ <b>код</b> ⏳ <b>дни</b> 🔢 <b>лимит</b>\nПример: <b>'DAYS10 10 50'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
@@ -175,20 +233,34 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
days = int(parts[1])
usage_limit = int(parts[2])
if days <= 0:
raise ValueError("Количество дней должно быть больше 0")
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
text = "⚠️ <b>Проверьте данные!</b>\nДни и лимит должны быть целыми числами больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
ok = await create_coupon(
session,
coupon_code,
0,
usage_limit,
days=days,
new_users_only=False,
percent=None,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"✅ Купон <b>{coupon_code}</b> создан!\n"
f"⏳ <b>{format_days(days)}</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
@@ -200,7 +272,66 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.message(AdminCouponsState.waiting_for_percent_data, IsAdminFilter())
async def handle_percent_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b>\n"
"🏷️ <b>код</b> 📉 <b>процент</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'SALE20 20 10'</b>"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
percent = int(parts[1])
usage_limit = int(parts[2])
if percent <= 0 or percent > 100:
raise ValueError
if usage_limit <= 0:
raise ValueError
except ValueError:
text = "⚠️ <b>Проверьте данные!</b>\nПроцент должен быть 1..100, лимит — целое число больше 0."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
ok = await create_coupon(
session,
coupon_code,
0,
usage_limit,
days=None,
new_users_only=False,
percent=percent,
)
if not ok:
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
return
text = (
f"✅ Купон <b>{coupon_code}</b> создан!\n📉 Скидка: <b>{percent}%</b>\n🔢 Лимит: <b>{usage_limit} раз</b>\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@@ -221,44 +352,52 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
async def handle_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack(),
)
kb.button(
text="❌ Нет, отменить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack(),
)
kb.adjust(1)
await callback_query.message.edit_text(
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
reply_markup=kb.as_markup()
reply_markup=kb.as_markup(),
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
async def confirm_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
confirm = callback_data.confirm
if confirm:
try:
result = await delete_coupon(coupon_code, session)
result = await delete_coupon(session, coupon_code)
if not result:
await callback_query.message.edit_text(
f"❌ Купон с кодом {coupon_code} не найден.",
reply_markup=build_admin_back_kb("coupons")
reply_markup=build_admin_back_kb("coupons"),
)
return
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при удалении купона.",
reply_markup=build_admin_back_kb("coupons")
reply_markup=build_admin_back_kb("coupons"),
)
return
@@ -288,7 +427,6 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
return
coupon_code = inline_query.query.split("coupon_")[1]
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
@@ -302,13 +440,39 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
)
return
percent_value = coupon.get("percent")
if percent_value is not None and int(percent_value) > 0:
await inline_query.answer(
results=[],
switch_pm_text="Процентные купоны не публикуются ссылкой",
switch_pm_parameter="coupons",
cache_time=1,
)
return
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
title = f"Купон {coupon['code']}"
description = f"Получи {coupon['amount']} рублей!" if coupon["amount"] > 0 else f"Продли подписку на {format_days(coupon['days'])}!"
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
f"👇 Нажми, чтобы активировать!"
)
days_value = coupon.get("days")
amount_value = coupon.get("amount") or 0
if days_value is not None and int(days_value) > 0:
days_int = int(days_value)
description = f"Продли подписку на {format_days(days_int)}!"
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"⏳ <b>Продление:</b> {format_days(days_int)}\n"
f"👇 Нажми, чтобы активировать!"
)
elif int(amount_value) > 0:
amount_int = int(amount_value)
description = f"Получи {amount_int} рублей!"
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n💰 <b>Бонус:</b> {amount_int} рублей\n👇 Нажми, чтобы активировать!"
)
else:
description = "Купон"
message_text = f"🎫 <b>Купон:</b> {coupon['code']}\n👇 Нажми, чтобы активировать!"
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
@@ -317,173 +481,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
id=coupon_code,
title=title,
description=description,
input_message_content=InputTextMessageContent(
message_text=message_text,
parse_mode=ParseMode.HTML
),
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
await inline_query.answer(
results=[result],
cache_time=86400,
is_personal=True
)
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
async def handle_coupon_activation(
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
):
coupon_text = text if text is not None else message.text
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
coupon_code = coupon_text.split("coupons_")[1]
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await message.answer("❌ Купон не найден.")
return
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
return
effective_user_id = user_id if user_id is not None else message.from_user.id
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
effective_user_id
)
if usage:
await message.answer("❌ Вы уже активировали этот купон.")
return
connection_exists = await check_connection_exists(effective_user_id)
if not connection_exists:
await add_connection(tg_id=effective_user_id, session=session)
if coupon["amount"] > 0:
await session.execute(
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
coupon["amount"],
effective_user_id
)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], effective_user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
await process_callback_view_profile(message, state, admin)
return
if coupon["days"] is not None and coupon["days"] > 0:
keys = await get_keys(effective_user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(AdminCouponsState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon_id,
callback_query.from_user.id
)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
try:
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
await callback_query.message.answer(text)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.answer("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
+41 -15
View File
@@ -1,5 +1,3 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
@@ -12,14 +10,26 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: Optional[bool] = None
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
builder.row(
InlineKeyboardButton(
text=" Создать купон",
callback_data=AdminPanelCallback(action="coupons_create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="Купоны",
callback_data=AdminPanelCallback(action="coupons_list").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
@@ -57,14 +67,30 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = f"💰 <b>Сумма:</b> {coupon['amount']} рублей" if coupon["amount"] > 0 else f"⏳ <b>{format_days(coupon['days'])}</b>"
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"{value_text}\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
text = "📜 <b>Список купонов</b>\n\n"
for i, coupon in enumerate(coupons, start=1):
percent_value = coupon.get("percent")
days_value = coupon.get("days")
amount_value = coupon.get("amount") or 0
if percent_value is not None and int(percent_value) > 0:
value_line = f"📉 <b>Скидка:</b> {int(percent_value)}%"
elif days_value is not None and int(days_value) > 0:
value_line = f"⏳ <b>Продление:</b> {format_days(int(days_value))}"
elif int(amount_value) > 0:
value_line = f"💰 <b>Баланс:</b> {int(amount_value)}"
else:
value_line = ""
text += (
f"<blockquote>"
f"<b>{i}. {coupon['code']}</b>\n"
f"{value_line}\n"
f"🔢 <b>Лимит:</b> {coupon['usage_limit']} | "
f"✅ <b>Использовано:</b> {coupon['usage_count']}\n"
f"<code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>"
f"</blockquote>\n\n"
)
return coupon_list
return text
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .emoji_handler import router
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
from typing import Iterable
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.enums import MessageEntityType
from aiogram.types import CallbackQuery, Message, MessageEntity
from aiogram.utils.keyboard import InlineKeyboardBuilder
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
class AdminEmojiState(StatesGroup):
waiting_for_custom_emoji = State()
router = Router()
def _build_back_to_emoji_menu() -> InlineKeyboardBuilder:
builder = InlineKeyboardBuilder()
builder.button(text=BACK, callback_data=AdminPanelCallback(action="emoji").pack())
builder.adjust(1)
return builder
def _extract_custom_emoji_ids(entities: Iterable[MessageEntity]) -> list[str]:
ids: list[str] = []
for ent in entities:
if ent.type == MessageEntityType.CUSTOM_EMOJI and ent.custom_emoji_id:
ids.append(ent.custom_emoji_id)
return ids
def _utf16_len(text: str) -> int:
return len(text.encode("utf-16-le")) // 2
@router.callback_query(AdminPanelCallback.filter(F.action == "emoji"), IsAdminFilter())
async def show_emoji_menu(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminEmojiState.waiting_for_custom_emoji)
example_id = "5201769509345588200"
marker = f"{{{{emoji:{example_id}}}}}"
preview_placeholder = "😀"
text = (
"Отправьте любое кастомное эмоджи — я верну его ID и покажу пример для текстов.\n\n"
"Пример:\n"
f"{preview_placeholder}"
)
entities: list[MessageEntity] = []
start = 0
while True:
marker_pos = text.find(marker, start)
if marker_pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(text[:marker_pos]),
length=_utf16_len(marker),
)
)
start = marker_pos + len(marker)
preview_pos = text.find(preview_placeholder)
if preview_pos != -1:
entities.append(
MessageEntity(
type=MessageEntityType.CUSTOM_EMOJI,
offset=_utf16_len(text[:preview_pos]),
length=_utf16_len(preview_placeholder),
custom_emoji_id=example_id,
)
)
if hasattr(callback_query.message, "_original_edit_text"):
await callback_query.message._original_edit_text(
text=text,
entities=entities,
reply_markup=build_admin_back_kb("admin"),
parse_mode=None,
)
else:
await callback_query.message.edit_text(
text=text,
entities=entities,
reply_markup=build_admin_back_kb("admin"),
parse_mode=None,
)
@router.message(AdminEmojiState.waiting_for_custom_emoji, IsAdminFilter())
async def handle_custom_emoji_id(message: Message, state: FSMContext):
entities = list(message.entities or []) + list(message.caption_entities or [])
emoji_ids = _extract_custom_emoji_ids(entities)
if not emoji_ids:
await message.answer(
"❌ Не вижу кастомных эмоджи. Отправьте именно <b>кастомный эмоджи</b> из набора.",
reply_markup=_build_back_to_emoji_menu().as_markup(),
)
return
unique_ids: list[str] = []
for emoji_id in emoji_ids:
if emoji_id not in unique_ids:
unique_ids.append(emoji_id)
placeholder = "😀"
back_builder = _build_back_to_emoji_menu()
if len(unique_ids) == 1:
emoji_id = unique_ids[0]
marker = f"{{{{emoji:{emoji_id}}}}}"
example_send = f"Ты отправил: Привет, {marker} !"
example_recv = f"А получил: Привет, {placeholder} !"
instruction_text = (
"✅ ID кастомного эмоджи\n"
f"{emoji_id}\n\n"
"Вставляйте в файл текстов так:\n"
f"{marker}\n\n"
"Пример:\n"
f"{example_send}\n"
f"{example_recv}\n\n"
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
)
preview_text = example_recv
preview_ids = [emoji_id]
code_markers = [marker]
else:
ids_text = "\n".join(f"{emoji_id}" for emoji_id in unique_ids)
markers_text = "\n".join(f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
markers_inline = " ".join(f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids)
example_send = f"Ты отправил: Привет, {markers_inline} !"
example_recv = "А получил: Привет, " + " ".join(placeholder for _ in unique_ids) + " !"
instruction_text = (
"✅ ID кастомных эмоджи\n"
f"{ids_text}\n\n"
"Вставляйте в файл текстов так:\n"
f"{markers_text}\n\n"
"Пример:\n"
f"{example_send}\n"
f"{example_recv}\n\n"
"⚠️ Условие: отображение кастомных эмоджи работает, если у владельца бота есть Telegram Premium."
)
preview_text = example_recv
preview_ids = unique_ids
code_markers = [f"{{{{emoji:{emoji_id}}}}}" for emoji_id in unique_ids]
full_text = instruction_text
entities: list[MessageEntity] = []
for marker in code_markers:
start = 0
while True:
pos = full_text.find(marker, start)
if pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(full_text[:pos]),
length=_utf16_len(marker),
)
)
start = pos + len(marker)
for emoji_id in unique_ids:
start = 0
while True:
pos = full_text.find(emoji_id, start)
if pos == -1:
break
entities.append(
MessageEntity(
type=MessageEntityType.CODE,
offset=_utf16_len(full_text[:pos]),
length=_utf16_len(emoji_id),
)
)
start = pos + len(emoji_id)
preview_offset_base = _utf16_len(full_text[: full_text.index(preview_text)])
running_utf16 = 0
idx = 0
for ch in preview_text:
if ch == placeholder and idx < len(preview_ids):
entities.append(
MessageEntity(
type=MessageEntityType.CUSTOM_EMOJI,
offset=preview_offset_base + running_utf16,
length=_utf16_len(placeholder),
custom_emoji_id=str(preview_ids[idx]),
)
)
idx += 1
running_utf16 += _utf16_len(ch)
if hasattr(message, "_original_answer"):
await message._original_answer(
text=full_text,
entities=entities,
reply_markup=back_builder.as_markup(),
parse_mode=None,
)
else:
await message.answer(text=full_text, reply_markup=back_builder.as_markup(), parse_mode=None)
await state.clear()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .gifts_handler import router
+249
View File
@@ -0,0 +1,249 @@
from collections import defaultdict
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from database.models import Gift, GiftUsage
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
from handlers.buttons import BACK
router = Router()
class GiftCreationState(StatesGroup):
waiting_for_gift_limit = State()
waiting_for_limit_input_or_unlimited = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"), IsAdminFilter())
async def admin_gift_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:", reply_markup=build_admin_gifts_kb()
)
@router.callback_query(F.data == "admin_gift_create", IsAdminFilter())
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
builder = InlineKeyboardBuilder()
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.get("subgroup_title")].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.get("duration_days"))
builder.row(
types.InlineKeyboardButton(
text=f"{t.get('name')} {duration_text}", callback_data=f"admin_gift_select|{t.get('id')}"
)
)
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
builder.row(
types.InlineKeyboardButton(
text=subgroup,
callback_data=f"admin_gift_subgroup|{subgroup_hash}",
)
)
builder.row(types.InlineKeyboardButton(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("admin_gift_subgroup|"), IsAdminFilter())
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
try:
subgroup_hash = callback.data.split("|", 1)[1]
subgroup = await find_subgroup_by_hash(session, subgroup_hash, "gifts")
if not subgroup:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
tariffs = await get_tariffs(session, group_code="gifts")
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
if not filtered:
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
return
builder = InlineKeyboardBuilder()
for t in filtered:
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.get("duration_days"))
builder.row(
types.InlineKeyboardButton(
text=f"{t.get('name')} {duration_text}",
callback_data=f"admin_gift_select|{t.get('id')}",
)
)
builder.row(types.InlineKeyboardButton(text=BACK, callback_data="admin_gift_create"))
await edit_or_send_message(
target_message=callback.message,
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[ADMIN_GIFT_SUBGROUP] Ошибка при отображении подгруппы: {e}")
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
@router.callback_query(F.data.startswith("admin_gift_select|"), IsAdminFilter())
async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
tariff_id = int(callback.data.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(GiftCreationState.waiting_for_limit_input_or_unlimited)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data="admin_gift_create")
await callback.message.edit_text(
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
)
@router.callback_query(F.data == "gift_limit_unlimited", IsAdminFilter())
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
data = await state.get_data()
session: AsyncSession = callback.bot["session"]
await state.clear()
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited, IsAdminFilter())
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
try:
max_usages = int(message.text.strip())
if max_usages <= 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректное положительное число.")
return
data = await state.get_data()
data["max_usages"] = max_usages
await state.clear()
await finalize_gift(message, session, bot, data, is_unlimited=False)
@router.callback_query(F.data == "admin_gifts_all", IsAdminFilter())
async def show_gifts_page(callback: CallbackQuery, session: AsyncSession):
await show_gift_list(callback, session, page=1)
@router.callback_query(F.data.startswith("gifts_page|"), IsAdminFilter())
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
page = int(callback.data.split("|")[1])
await show_gift_list(callback, session, page)
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
limit = 10
offset = (page - 1) * limit
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(stmt)
gifts = result.scalars().all()
if not gifts:
builder = InlineKeyboardBuilder()
builder.button(text=BACK, callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
await callback.message.edit_text(
f"🎁 <b>Список подарков</b>\nСтраница {page}:",
reply_markup=keyboard,
)
@router.callback_query(F.data.startswith("gift_view|"), IsAdminFilter())
async def view_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
await callback.message.edit_text("❌ Подарок не найден.")
return
usage_result = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
)
used_count = usage_result.scalar_one()
usage_text = f"{used_count}/{gift.max_usages}" if gift.max_usages else ""
duration_days = (gift.expiry_time.date() - gift.created_at.date()).days
if duration_days % 30 == 0:
duration_text = format_months(duration_days // 30)
else:
duration_text = format_days(duration_days)
text = (
f"🎁 <b>Подарок</b>\n"
f"ID: <code>{gift.gift_id}</code>\n"
f"Срок: <b>{duration_text}</b>\n"
f"Активаций: <b>{usage_text}</b>\n"
f"<b>Ссылка для активации:</b>\n<blockquote>{gift.gift_link}</blockquote>"
)
builder = InlineKeyboardBuilder()
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
builder.button(text=BACK, callback_data="admin_gifts_all")
await callback.message.edit_text(text, reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("gift_delete|"), IsAdminFilter())
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.execute(delete(Gift).where(Gift.gift_id == gift_id))
await session.commit()
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад к списку", callback_data="admin_gifts_all")
await callback.message.edit_text("✅ Подарок удалён.", reply_markup=builder.as_markup())

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