331 Commits

Author SHA1 Message Date
Vladless a953479ebf make get_git_commit_number work under systemd 2025-06-14 06:23:31 +03:00
Vladislav Lisitsyn db21914afe Merge pull request #208 from Vladless/dev
fix alembic migrations
2025-06-10 16:06:25 +03:00
Vladless bbc907ef93 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn 084b8a3a08 Merge pull request #207 from Vladless/dev
version 4.3 update
2025-06-09 22:46:36 +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 aef1eb2861 total_gb fix 2025-04-09 23:13:43 +03:00
Vladislav Lisitsyn b57d79d65c Update version 2025-04-09 20:03:46 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
Vladless 3e452c4c1b exclude img 2025-04-09 16:08:59 +03:00
Vladless 31582080c6 bash -c in cli-interface 2025-04-08 14:43:46 +03:00
Vladless d85dad2a9e version in cli-interface 2025-04-08 14:34:16 +03:00
Vladislav Lisitsyn 7f15e208e1 Merge pull request #184 from Capybara-z/patch-11
Fix for coupon activation
2025-04-07 02:17:43 +03:00
Capybara-z ae291e2ae7 Fix for coupon activation
- Корректная обработка активации купонов.
2025-04-07 02:04:01 +03:00
Capybara-z 7fd15da4a6 Fix for coupon activation
- Корректная передача состояний и текста в купоны.
2025-04-07 02:03:11 +03:00
Vladless 4fdbaca7e8 yoomoney webapp 2025-04-07 00:22:15 +03:00
Vladless 54180c6b28 change callback in add_key 2025-04-06 21:44:09 +03:00
Vladless b7db9be638 cli-interface 2025-04-06 21:19:33 +03:00
Vladless 76d6476ea7 cli-interface 2025-04-06 21:15:59 +03:00
Vladislav Lisitsyn 70867dccec Merge pull request #183 from Capybara-z/patch-10
Add server availability check and cluster-based country selection
2025-04-06 01:59:25 +03:00
Capybara-z dd7144cd5b Add server availability check and cluster-based country selection
- Добавил проверку доступности серверов перед отображением списка стран для выбора.
- Сделал пропуск удаления ключа при недоступности сервера, чтобы смена страны не останавливалась.
- Обеспечил вывод только рабочих серверов при создании ключа.
- Улучшил выбор стран, сделав его корректным согласно кластерам пользователя.
- Исправил пересоздание клиента, обновляя только server_id при смене страны.
2025-04-06 00:30:20 +03:00
Vladless 29f54a408d add data_state 2025-04-05 20:48:13 +03:00
Vladless bff7d3ede0 on/off manual_instructions/fix send_photo 2025-04-05 02:16:24 +03:00
Vladislav Lisitsyn 2a5a254cae Merge pull request #180 from Capybara-z/patch-9
Фикс ошибок и создание ключей
2025-04-05 02:03:28 +03:00
Vladislav Lisitsyn f7db7f445b Merge pull request #179 from Capybara-z/patch-8
Исправление.
2025-04-05 02:02:43 +03:00
Capybara-z ec9016f958 Перенос обратобтки состояний после создания ключа
- Перенос оплат и списания триала после создания ключа
2025-04-05 00:46:24 +03:00
Capybara-z d73a765866 Фикс ошибок сервера
- Исправлен сбор ошибок сервера
2025-04-05 00:44:45 +03:00
Capybara-z eb70e38a02 Исправление.
- Убрал лишнию проверку.
2025-04-04 23:38:14 +03:00
Vladislav Lisitsyn b587cf527e Merge pull request #177 from Capybara-z/patch-6
Добавление активности юзера, мелкие правки
2025-04-03 22:31:54 +03:00
Capybara-z a8ce3f65ee Добавление формулы склонения.
- Добалвение формулы склонения
- Адаптация клонение дней к новой формуле.
2025-04-03 22:17:19 +03:00
Capybara-z 5200e25e88 Корректировка текста
- Скорректировал текст, при смене названия синхронизация не нужна.
2025-04-03 11:32:05 +03:00
Capybara-z e676213102 Функция склонения дней.
- Добавлена функция склонения дней.
2025-04-03 10:26:47 +03:00
Capybara-z 1f56820ce5 Склонение дней
- Склонение дней
2025-04-03 10:25:58 +03:00
Capybara-z 58937a405d Склонение дней
- Склонение дней
2025-04-03 10:25:39 +03:00
Capybara-z b7d13576b8 Добавлено подверждение удаления
- Добавил подверждение удаления
- Поправил удаление купонов.
2025-04-03 09:58:23 +03:00
Capybara-z 0828bca2ed Правка под дни 2025-04-03 09:57:39 +03:00
Capybara-z c1dc10e5ed Купоны по дням
- Купоны теперь можно выдать днями
- Добавил поделиться днями через инлайн мод.
2025-04-03 02:31:54 +03:00
Capybara-z 50eba2d093 Перенесана логика купонов
- Логика купонов перенесена в свое место.
2025-04-03 02:06:28 +03:00
Capybara-z ea6d9d300f Корректировка работы с купонами.
- Скорректировал работу с купонами так как ранее работа была напрямую из start
- Скорретировал работу с купонам под новый столбец days
2025-04-03 02:05:36 +03:00
Capybara-z a06ee6f395 Добавлено создание столбца
- Добавил создание столбца в купонах days
2025-04-03 02:04:07 +03:00
Capybara-z 86e678de08 Добавлена возможность делиться купоном
- Добалена возможность делиться купоном при инлайн режиме.
2025-04-02 23:07:05 +03:00
Capybara-z 772a677424 Отображение пробной подписки
- Добавлено отображение пробной подписки если доступна в профиле.
2025-04-02 21:24:49 +03:00
Capybara-z cccb963da2 Дата регистрации/активности
- Добавлена стата даты регистрации/активности
2025-04-02 21:12:23 +03:00
Vladislav Lisitsyn 2b3ce6ce55 Merge pull request #176 from Capybara-z/patch-5
Смена версии
2025-04-02 03:01:10 +03:00
Capybara-z 1c119b1d7a Смена версии
- Смена бета версии
2025-04-02 02:50:41 +03:00
Vladislav Lisitsyn 4043380411 Merge pull request #175 from Capybara-z/patch-4
Фикс активных юзеров и улучшения управления кластерами/серверами
2025-04-02 02:29:48 +03:00
Capybara-z c3d71bdc28 Доработка сообщений
- Добавлены напоминание синхронизации.
2025-04-02 02:08:09 +03:00
Capybara-z dc6f73c539 Логика переноса ключей
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:43:04 +03:00
Capybara-z 476a8cb842 Добавление логики переноса
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:42:12 +03:00
Capybara-z bc81c64cbe Корректировка сообщений
- Удалил ненужное
2025-04-02 01:41:11 +03:00
Capybara-z 812264722a Добавление QR-кода
- Добавлен QR-код для реф ссылки.
2025-04-02 00:13:13 +03:00
Capybara-z 0d06f61d40 Доп стата
- Стата за прошлый месяц
2025-04-01 23:55:27 +03:00
Capybara-z 2634facd51 Добавление логики смены названий
- Добавлена логика смены названия кластера/сервера
- Сделана проверка на наличие ключей с названием кластера/сервера и смены на новое название
2025-04-01 23:40:35 +03:00
Capybara-z a92b6d1e86 Добавление кнопки
- Добавлена кнопка смены названия кластера
2025-04-01 23:35:19 +03:00
Capybara-z 4cfbcd8590 добавление кнопки
- добавление кнопки смены названия сервера
2025-04-01 23:34:12 +03:00
Capybara-z 73ba081779 фикс активации купона
- Корректировка состояния активации купона
2025-04-01 22:10:40 +03:00
Vladislav Lisitsyn 3aa6c26940 Merge pull request #174 from Capybara-z/patch-3
Фикс имени подписки и экранирование в подписках
2025-04-01 15:46:35 +03:00
Capybara-z dc8ff4a007 Фикс html разметки
- Добавил экранирование
2025-04-01 15:45:37 +03:00
Capybara-z 6390a18327 Фикс имени подписки
- Поставлены ограничения на символа ввода
- Поравлен текст.
2025-04-01 15:25:07 +03:00
Vladless a5d9dad826 fixing bugs 2025-04-01 02:37:52 +03:00
Vladislav Lisitsyn 06e6b30d31 Merge pull request #173 from Capybara-z/patch-2
Добавление отдельного меню Windows/macOS и функции личного рейтинга.
2025-04-01 00:11:35 +03:00
Capybara-z f9e404a7c2 правка текста
-правка текста
2025-04-01 00:01:44 +03:00
Capybara-z 3920992cee Выбор win/macOS
- Сделан выбор win/macOS
- Сделаны текста для них отдельные.
2025-03-31 22:08:27 +03:00
Capybara-z fcb6dd9773 Часть текста перенесана
- Часть текста перенесена CHOOSE_DEVICE_TEXT
2025-03-31 22:04:33 +03:00
Capybara-z 6d42647bf6 Добавление новых кнопок
-  Добавлены кнопки скачать win/macos
- Добавлены кнопки выбора устройств win/macos
2025-03-31 22:01:59 +03:00
Capybara-z 6e3b33e335 Добавление личного рейтинга
-Добавление личного рейтинга вне зависимости от топа.
2025-03-31 00:57:13 +03:00
Vladless 063ade1a2a add user in profile 2025-03-30 00:29:44 +03:00
Vladless 56ffbd654e add new menu in country mode 2025-03-29 22:27:26 +03:00
Vladless 47777c7ee8 fix QRCode, False DISCOUNTS and name profile 2025-03-29 21:57:36 +03:00
Vladless 27d021171e add back button 2025-03-29 12:03:56 +03:00
Vladless d4b00fb494 format text 2025-03-29 02:15:52 +03:00
Vladless 7816fa535c add pillow in requirements 2025-03-29 01:18:59 +03:00
Vladless 0208c48212 add qrcode in requirements 2025-03-28 22:19:25 +03:00
Vladless 4295e4b5ec ruff, button file, castomize ping and backup 2025-03-28 20:39:06 +03:00
Vladless 4fb13a089a reorganizing the menu, reissuing the key, improving statistics, and more 2025-03-28 17:44:14 +03:00
Vladless f1841caea7 rebase api file 2025-03-27 12:52:23 +03:00
Vladless 4a67f9233d customizing the menu, resetting traffic, and creating a key from the admin panel 2025-03-27 04:39:05 +03:00
Vladless e55a89af8e fix admin and add time in cluster 2025-03-27 03:04:21 +03:00
Vladless 1f172f1a21 rename sub and reorganization menu 2025-03-27 02:01:39 +03:00
Vladless 3f7bcb090a notify keyboards in folder 2025-03-26 23:54:36 +03:00
Vladless a3b049dc1f fix after merge 2025-03-26 16:41:43 +03:00
hteppl bf2d225c55 Fix keyboards for users_handler and make data optional for AdminClusterCallback 2025-03-26 02:49:04 +03:00
Vladislav Lisitsyn 33b15b8486 Merge pull request #172 from Vladless/feature/admin-optimize
Admin optimize
2025-03-26 02:37:00 +03:00
Oleg Kraev 19a273baf4 Merge pull request #171 from Vladless/upstream-170
Upstream
2025-03-26 02:06:19 +03:00
hteppl 1c205f4a24 Upstream 2025-03-26 02:05:24 +03:00
hteppl f2a47de5da Fix packages imports 2025-03-26 02:00:28 +03:00
hteppl 372933e230 Implement untrial type for sender 2025-03-26 01:59:52 +03:00
hteppl 11236ac64e Implement separate sync logic 2025-03-26 01:58:59 +03:00
hteppl 46f71a7a6e Update init routing for handlers 2025-03-26 01:11:54 +03:00
hteppl 70293fe387 Fix back button in sender + add broadcast start text 2025-03-26 00:19:24 +03:00
hteppl c7915a45b8 Fix some back buttons 2025-03-26 00:16:14 +03:00
Vladislav Lisitsyn 8b016c7010 Merge pull request #169 from izzzzzi/main
Исправление сравнения tg_id в обработчике подписок.
2025-03-25 20:21:01 +03:00
izzzzzi 17032811a4 Исправление сравнения tg_id в обработчике подписок. Приведение типов к int для корректной проверки идентификаторов. 2025-03-25 22:00:54 +05:00
hteppl ff591fc387 Optimize and fix imports 2025-03-25 17:42:53 +03:00
Oleg Kraev ece35bb90a Merge pull request #168 from Vladless/upstream-168
Changes from upstream
2025-03-25 17:34:54 +03:00
Oleg Kraev dfec3c5748 Merge branch 'feature/admin-optimize' into upstream-168 2025-03-25 17:34:46 +03:00
hteppl 29ecb61ad2 Make clusters 2 in a row 2025-03-25 17:27:25 +03:00
hteppl 79fd3cde5d Do not use clusters_handler for handling management actions 2025-03-25 17:21:05 +03:00
hteppl 5f4a23e808 Trying to split cluster and server manage logic 2025-03-25 17:12:47 +03:00
hteppl 3d10e2c1fe Code optimize by ruff 2025-03-25 16:54:02 +03:00
hteppl 6b8507253e Improve clusters and servers manage texts + fix input len checks 2025-03-25 16:52:38 +03:00
Vladislav Lisitsyn 90d9c0bc54 Merge pull request #167 from Capybara-z/patch-1
Standardization of texts in a separate file.
2025-03-25 16:39:07 +03:00
hteppl 71301f90ae Update AdminPanelCallback __init__ as in super().__init__ 2025-03-25 16:32:31 +03:00
hteppl 472a3ad69d Use ruff formatting 2025-03-25 16:30:44 +03:00
hteppl 5cd63bc89d Move admin keyboards to their own packages 2025-03-25 16:28:08 +03:00
hteppl 77bd87a2b5 Rename classes + implement own packages for all handlers 2025-03-25 16:22:52 +03:00
Capybara-z 53483ce6aa Вынос текста
Вынесенно:
- NO_SUBSCRIPTIONS_MSG
- FROZEN_SUBSCRIPTION_MSG
- UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_UNFROZEN_MSG
- FREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_FROZEN_MSG
- DELETE_KEY_CONFIRM_MSG
- KEY_DELETED_MSG_SIMPLE
- INSUFFICIENT_FUNDS_RENEWAL_MSG
2025-03-25 15:24:39 +03:00
Capybara-z a9e546091a Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:24:07 +03:00
Capybara-z 924b5e15c5 Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:16:33 +03:00
Capybara-z ae9b5b1bf7 Вынос текста
Вынесенно:
- TRIAL_INACTIVE_FIRST_MSG
- TRIAL_INACTIVE_BONUS_MSG
- ZERO_TRAFFIC_MSG
2025-03-25 15:15:25 +03:00
Capybara-z adb669fa9e Вынос текста
Вынесенно:
- KEY_DELETED_MSG
- KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG
- KEY_EXPIRED_DELAY_HOURS_MSG
- KEY_EXPIRED_DELAY_MINUTES_MSG
- KEY_EXPIRED_NO_DELAY_MSG
- KEY_RENEWED_TEMP_MSG
2025-03-25 15:11:42 +03:00
Capybara-z 2daf2fadc8 Вынос текста
Вынесенно:
- CAPTCHA_PROMPT_MSG
2025-03-25 15:11:10 +03:00
Capybara-z c9b0cd2db1 Вынос текст
Вынесенно:
- COUPON_INPUT_PROMPT
- COUPON_NOT_FOUND_MSG
- COUPON_ALREADY_USED_MSG
- COUPON_ACTIVATED_SUCCESS_MSG
2025-03-25 15:10:46 +03:00
Capybara-z 098459f3ce Вынос текста
Вынесенно:
- PAYMENT_METHODS_MSG
2025-03-25 15:10:23 +03:00
Capybara-z 64d25967c9 Вынос текста
Вынесенно:
- BALANCE_MANAGEMENT_TEXT
- BALANCE_HISTORY_HEADER
- INVITE_TEXT_NON_INLINE
2025-03-25 15:10:04 +03:00
Capybara-z 5804a8e87a Вынос текста
Вынесенно: 
- COUPON_SUCCESS_MSG
- SUBSCRIPTION_REQUIRED_MSG
- NOT_SUBSCRIBED_YET_MSG
- SUBSCRIPTION_CONFIRMED_MSG 
- SUBSCRIPTION_CHECK_ERROR_MSG
- GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG
2025-03-25 15:08:56 +03:00
Vladless 388fbb5afc add_refferal_stats 2025-03-23 19:44:11 +03:00
Vladless d3e502ce43 minor bug fixes and improvements 2025-03-21 09:26:30 +03:00
Vladless af497f644a frozen_subs/restore_all_trial and more 2025-03-12 18:12:29 +03:00
Vladislav Lisitsyn e57680f3a7 Merge pull request #166 from Vladless/revert-165-main 2025-03-12 10:24:09 +03:00
Vladislav Lisitsyn c7eff5cc3e Revert "Добавление нового middleware и рефакторинг" 2025-03-12 10:23:51 +03:00
Vladislav Lisitsyn 3ccbf00259 Merge pull request #165 from izzzzzi/main
Добавление нового middleware и рефакторинг
2025-03-11 23:44:51 +03:00
izzzzzi 6066775cf4 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-03-08 04:59:32 +05:00
izzzzzi 92ec071e6b Добавление нового middleware и рефакторинг обработчиков с поддержкой target_message 2025-03-08 04:59:23 +05:00
Oleg Kraev 8e070349ba Merge pull request #163 from izzzzzi/main
Рефакторинг и оптимизация кода
2025-03-07 16:13:02 +03:00
Oleg Kraev 80360691f2 Merge pull request #164 from Vladless/fix/readme
Move preview gifs into .github folder
2025-03-07 15:46:08 +03:00
hteppl 2f0c22abe8 Move preview gifs into .github folder 2025-03-07 15:44:57 +03:00
izzzzzi 9c7cbe403b Рефакторинг и оптимизация кода: улучшение импортов, форматирования и обработки уведомлений 2025-03-07 06:16:24 +05:00
Vladless fcdfd3a484 fix refferal system 2025-03-07 00:51:02 +03:00
Vladislav Lisitsyn 8f1ceb4e66 Merge pull request #162 from izzzzzi/main
Добавлена обработка исключений в асинхронных задачах
2025-03-04 16:09:25 +03:00
izzzzzi 2db29fba9d Добавлена обработка таймаутов с использованием httpx в клиентских операциях 2025-03-04 01:23:54 +05:00
izzzzzi d0125c1f33 Добавлена обработка исключений в асинхронных задачах с помощью return_exceptions=True 2025-03-04 00:57:41 +05:00
Vladislav Lisitsyn 0dba6a6e42 Merge pull request #161 from izzzzzi/main
Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
2025-03-03 19:27:02 +03:00
izzzzzi 1d6ae5fa07 Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
- Значительное упрощение middleware для ограничения частоты запросов
- Унифицирован механизм троттлинга с единым кэшем
- Добавлена поддержка уведомлений о превышении лимита запросов для CallbackQuery
- Удалены избыточные проверки и сложная логика
- Сокращено количество параметров инициализации
2025-02-26 02:33:26 +05:00
166 changed files with 16151 additions and 118982 deletions
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Сохраняем текущие изменения
echo "Сохранение текущих изменений..."
git stash -q --keep-index
# Запускаем форматирование кода
echo "Запуск Ruff format..."
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
# Запускаем проверку и исправление кода
echo "Запуск Ruff check с автоисправлением..."
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
# Добавляем изменения, внесенные форматированием
git add -u
# Восстанавливаем сохраненные изменения
echo "Восстановление сохраненных изменений..."
git stash pop -q
# Выход с кодом 0 (успешно)
exit 0

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Before

Width:  |  Height:  |  Size: 867 KiB

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

+2 -1
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
@@ -51,7 +53,6 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
/logs
-1
View File
@@ -1 +0,0 @@
3.12
+59 -78
View File
@@ -1,106 +1,87 @@
# 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src="./img/preview.gif" alt="SoloBot" width="1600">
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
**SoloBot** реализует множество функций, включая:
## ⚙️ Основные возможности SoloBot
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
| 📌 Раздел | 💡 Возможности |
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui) <br> • Работа с панелями [Remnawave](https://github.com/remnawave/panel) <br> • Работа в режиме совместимости (различные панели одновременно) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2RayTun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars |
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
</a>
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
## 🛠 Обновления
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### SoloBot регулярно получает новые функции и улучшения.
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
# Наш сайт и полная версия
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
### Переходи на наш сайт!
<h3>
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ffa726?style=for-the-badge" alt="Сайт SoloNetBot">
</a>
</h3>
<img src="./img/preview_2.gif" alt="SoloBot" width="1600">
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
![image](https://github.com/user-attachments/assets/fb0028cd-c588-4548-b290-a4bfcc96a3e1)
### *Настраивайте как вам удобно!*
<h3>
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img src="https://img.shields.io/badge/Попробовать_SoloBot_в_Telegram-жми-ffa726?style=for-the-badge&logo=telegram" alt="Попробовать SoloBot в Telegram">
</a>
</h3>
## Отзывы пользователей:
## Список версий
#### SoloBot уже помог сотням пользователей в нашем сообществе:
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
## 🔗 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/)
<h3>
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img src="https://img.shields.io/badge/Читать_отзывы-на_сайте-ffa726?style=for-the-badge&logo=discourse" alt="Читать отзывы">
</a>
</h3>
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
## 🚨 Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
| ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|----------------------------------|----------------------------------------------------------------------|
| ⛔ **Перепродажа кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
## Участники проекта
Благодарим всех, кто помогает развивать SoloBot! 💖
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
</a>
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
-114
View File
@@ -1,114 +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)
);
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,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
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()
);
+59 -14
View File
@@ -2,13 +2,16 @@ import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, Tuple, Union
import aiofiles
from aiogram.types import BufferedInputFile
from aiogram import Bot
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from config import (
ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT,
BACKUP_SEND_MODE, BACKUP_CHANNEL_ID, BACKUP_CHANNEL_THREAD_ID, BACKUP_OTHER_BOT_TOKEN, BACKUP_CAPTION
)
from logger import logger
@@ -48,17 +51,14 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
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:
# Устанавливаем пароль PostgreSQL через переменную окружения
os.environ["PGPASSWORD"] = DB_PASSWORD
# Запускаем pg_dump для создания бэкапа
subprocess.run(
[
"pg_dump",
@@ -87,7 +87,6 @@ def _create_database_backup() -> tuple[str | None, Exception | None]:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
# Удаляем переменную окружения с паролем
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
@@ -104,10 +103,8 @@ def _cleanup_old_backups() -> Exception | None:
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)
@@ -146,19 +143,67 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
async def send_default():
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}")
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:
if BACKUP_SEND_MODE == "default":
await send_default()
elif BACKUP_SEND_MODE == "channel":
channel_id = BACKUP_CHANNEL_ID.strip()
thread_id = BACKUP_CHANNEL_THREAD_ID.strip()
if not channel_id:
logger.error("BACKUP_CHANNEL_ID не задан для режима 'channel', fallback на default")
await send_default()
return
send_kwargs = dict(chat_id=channel_id, document=backup_input_file)
if thread_id:
send_kwargs["message_thread_id"] = int(thread_id)
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
await bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен в канал: {channel_id} (топик: {thread_id})")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
logger.error(f"Не удалось отправить бэкап в канал {channel_id}: {e}, fallback на default")
await send_default()
elif BACKUP_SEND_MODE == "bot":
if not BACKUP_OTHER_BOT_TOKEN:
logger.error("BACKUP_OTHER_BOT_TOKEN не задан для режима 'bot', fallback на default")
await send_default()
return
other_bot = Bot(token=BACKUP_OTHER_BOT_TOKEN)
try:
for admin_id in ADMIN_ID:
try:
send_kwargs = dict(chat_id=admin_id, document=backup_input_file)
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await other_bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен админу через другого бота: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id} через другого бота: {e}")
await other_bot.session.close()
except Exception as e:
logger.error(f"Ошибка при отправке через другого бота: {e}, fallback на default")
await send_default()
else:
logger.error(f"Неизвестный BACKUP_SEND_MODE: {BACKUP_SEND_MODE}, fallback на default")
await send_default()
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
logger.error(f"Ошибка при отправке бэкапа: {e}")
raise
+138 -23
View File
@@ -1,4 +1,5 @@
import traceback
import os
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
@@ -8,29 +9,102 @@ from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
import subprocess
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from middlewares import register_middleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.0"
register_middleware(dp)
def get_git_commit_number() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
env = os.environ.copy()
env["GIT_DIR"] = os.path.join(cwd, ".git")
env["GIT_WORK_TREE"] = cwd
try:
local_number = subprocess.check_output(
["git", "rev-list", "--count", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
local_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
try:
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=cwd,
env=env
).decode().strip()
if branch == "HEAD":
describe = subprocess.check_output(
["git", "describe", "--tags", "--exact-match"],
cwd=cwd,
env=env,
stderr=subprocess.DEVNULL
).decode().strip()
if describe.startswith("v") or "release" in describe.lower():
branch = "main"
else:
branch = "dev"
except Exception:
branch = "dev"
except Exception as e:
return f"\n(Требуется обновление через CLI: {e})"
try:
remote_commit = subprocess.check_output(
["git", "ls-remote", "origin", f"refs/heads/{branch}"],
cwd=cwd,
env=env
).decode()
remote_hash = remote_commit.split()[0]
remote_number = subprocess.check_output(
["git", "rev-list", "--count", remote_hash],
cwd=cwd,
env=env
).decode().strip()
if local_hash == remote_hash:
return "\n(Актуальная версия)"
return (
f"\n(commit <a href=\"{repo_url}/commit/{local_hash}\">"
f"#{local_number}</a> / actual commit "
f"<a href=\"{repo_url}/commit/{remote_hash}\">#{remote_number}</a>)"
)
except Exception:
return ""
version = f"v4.3-Release{get_git_commit_number()}"
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(
event: ErrorEvent,
bot: Bot,
) -> bool:
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
@@ -39,30 +113,51 @@ async def errors_handler(
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
"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:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
event.update.callback_query,
state=fsm_context,
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(
@@ -73,19 +168,39 @@ async def errors_handler(
),
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}")
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
+469
View File
@@ -0,0 +1,469 @@
import os
import re
import subprocess
import sys
import requests
from rich.console import Console
from rich.prompt import Confirm, Prompt
from rich.table import Table
from rich.text import Text
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"):
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
console = Console()
BACK_DIR = os.path.expanduser("~/.solobot_backup")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print(
"[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]"
)
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print(
"[yellow]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/yellow]"
)
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
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)
def backup_project():
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]")
def fix_permissions():
"""Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try:
user = os.getenv("SUDO_USER") or os.getenv("USER")
if user:
subprocess.run(
["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True
)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
console.print("[green]✅ Права успешно установлены[/green]")
except subprocess.CalledProcessError 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]")
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 = {
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, ".git"),
}
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, _, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for file in files:
preserved_paths.add(os.path.join(root, file))
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) == os.path.join(PROJECT_DIR, "handlers"):
continue
if not update_img and os.path.abspath(dir_path) == os.path.join(PROJECT_DIR, "img"):
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]")
os.system("sudo apt update && sudo apt install -y git")
def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
try:
if not os.path.exists("venv"):
console.print(
"[yellow]⚠️ Виртуальное окружение не найдено. Создаю...[/yellow]"
)
subprocess.run("python3 -m venv venv", shell=True, check=True)
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
check=True,
)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
def restart_service():
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
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, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
return None
def get_remote_version(branch="main"):
try:
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
for line in response.text.splitlines():
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
except Exception:
return None
return None
def update_from_beta():
update_cli_launcher()
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]")
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]🖼 Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]📅 Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
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 "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
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])
fix_permissions()
install_dependencies()
restart_service()
console.print("[green]✅ Обновление с ветки dev завершено.[/green]")
def update_from_release():
update_cli_launcher()
if not Confirm.ask(
"[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"
):
return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not Confirm.ask("[red]❓ Вы точно хотите продолжить?[/red]"):
return
update_buttons = Confirm.ask("[yellow]🔄 Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[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
)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
if not tag_choices:
raise ValueError("Не удалось получить список релизов")
console.print("\n[bold green]Доступные релизы:[/bold green]")
for idx, tag in enumerate(tag_choices, 1):
console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
)
tag_name = tag_choices[int(selected) - 1]
if not Confirm.ask(
f"[yellow]🔁 Подтвердите установку релиза {tag_name}[/yellow]"
):
return
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
shell=True,
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 "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
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])
fix_permissions()
install_dependencies()
restart_service()
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("3", "Назад в меню")
console.print(table)
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
if choice == "1":
update_from_beta()
elif choice == "2":
update_from_release()
def show_menu():
table = Table(
title="Solobot CLI v0.2.0", 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)")
table.add_row("4", "Остановить бота (systemd)")
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Обновить CLI лаунчер")
table.add_row("9", "Выход")
console.print(table)
def update_cli_launcher():
"""Обновляет CLI лаунчер с dev ветки"""
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:
with open(
os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8"
) as f:
f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
else:
console.print("[red]❌ Не удалось загрузить новый CLI[/red]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении CLI: {e}[/red]")
def main():
os.chdir(PROJECT_DIR)
print_logo()
try:
while True:
show_menu()
choice = Prompt.ask(
"[bold blue]Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 10)],
)
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 Confirm.ask(
"[green]Вы действительно хотите запустить main.py вручную?[/green]"
):
subprocess.run(["venv/bin/python", "main.py"])
elif choice == "3":
if is_service_exists(SERVICE_NAME):
if Confirm.ask(
"[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 Confirm.ask(
"[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":
if Confirm.ask("[yellow]Обновить CLI лаунчер с dev ветки?[/yellow]"):
update_cli_launcher()
elif choice == "9":
if Confirm.ask("[yellow]Хотите обновить CLI перед выходом?[/yellow]"):
update_cli_launcher()
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
main()
-1763
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 *
+14
View File
@@ -0,0 +1,14 @@
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()
+121
View File
@@ -0,0 +1,121 @@
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, usage_limit: int, days: int = None
) -> bool:
try:
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
if exists:
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
return False
await session.execute(
insert(Coupon).values(
code=code,
amount=amount,
usage_limit=usage_limit,
usage_count=0,
is_used=False,
days=days,
)
)
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()
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()
+12
View File
@@ -0,0 +1,12 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
from config import DATABASE_URL
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
async_session_maker = async_sessionmaker(
bind=engine, expire_on_commit=False, class_=AsyncSession
)
Base = declarative_base()
+39
View File
@@ -0,0 +1,39 @@
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,
):
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,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id})")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
return False
+25
View File
@@ -0,0 +1,25 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment
async def get_hot_leads(session: AsyncSession):
"""
Возвращает пользователей, у которых есть успешные оплаты, но нет активных ключей.
"""
subquery = (
select(Key.tg_id)
.where(Key.expiry_time > func.extract("epoch", func.now()) * 1000)
.distinct()
)
stmt = (
select(Payment.tg_id)
.distinct()
.where(Payment.amount > 0)
.where(~Payment.tg_id.in_(subquery))
)
result = await session.execute(stmt)
return [row.tg_id for row in result]
+7
View File
@@ -0,0 +1,7 @@
from database.db import engine
from database.models import Base
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+197
View File
@@ -0,0 +1,197 @@
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,
):
try:
exists = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
if exists.scalar_one_or_none():
logger.info(
f"[Store Key] Ключ уже существует — пропускаем: tg_id={tg_id}, client_id={client_id}"
)
return
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,
)
session.add(new_key)
await session.commit()
logger.info(
f"✅ Ключ сохранён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
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:
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,
}
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):
stmt = delete(Key).where(
Key.tg_id == identifier
if str(identifier).isdigit()
else Key.client_id == identifier
)
await session.execute(stmt)
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, notified=False, notified_24h=False)
)
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}")
+201
View File
@@ -0,0 +1,201 @@
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
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)
source_code = Column(String, ForeignKey("tracking_sources.code"))
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"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
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)
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)
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)
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)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id"), 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"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), 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)
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)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
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 Log(DictLikeMixin, Base):
__tablename__ = "logs"
id = Column(Integer, primary_key=True)
event_type = Column(String)
tg_id = Column(BigInteger)
details = Column(JSON)
created_at = Column(DateTime, default=datetime.utcnow)
+148
View File
@@ -0,0 +1,148 @@
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 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()
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.info(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_notifications_bulk(
session: AsyncSession,
notification_type: str,
hours: int,
tg_ids: list[int] = None,
emails: list[str] = None,
) -> list[dict]:
from sqlalchemy import and_, func, select
from database.models import User, Key, Notification, BlockedUser
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}")
return []
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime
from sqlalchemy import insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Payment
from logger import logger
async def add_payment(
session: AsyncSession, tg_id: int, amount: float, payment_system: str
):
try:
stmt = insert(Payment).values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status="success",
created_at=datetime.utcnow(),
)
await session.execute(stmt)
await session.commit()
logger.info(
f"✅ Успешно добавлен платёж: {tg_id}, {amount}₽ через {payment_system}"
)
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении платежа: {e}")
await session.rollback()
raise
async def get_last_payments(session: AsyncSession, tg_id: int, limit: int = 3):
try:
result = await session.execute(
select(Payment)
.where(Payment.tg_id == tg_id)
.order_by(Payment.created_at.desc())
.limit(limit)
)
payments = result.scalars().all()
logger.info(
f"✅ Получены последние платежи пользователя {tg_id}, всего: {len(payments)}"
)
return [dict(p.__dict__) for p in payments]
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при получении платежей пользователя {tg_id}: {e}")
return []
+278
View File
@@ -0,0 +1,278 @@
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 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:
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'
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 rl.level <= :max_levels
"""
)
result = await session.execute(
text(bonus_query), {"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), {"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}"
)
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()
]
+120
View File
@@ -0,0 +1,120 @@
from sqlalchemy import delete, insert, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server
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:
try:
stmt = select(Server)
result = await session.execute(stmt)
servers = result.scalars().all()
grouped = {}
for s in servers:
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
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,
"cluster_name": cluster,
}
)
return grouped
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении серверов: {e}")
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}")
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
+169
View File
@@ -0,0 +1,169 @@
from datetime import date, datetime
from sqlalchemy import and_, func, not_, select, exists
from sqlalchemy.ext.asyncio import AsyncSession
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_trial_keys(session: AsyncSession) -> int:
subquery_success_payments = (
select(Payment.tg_id)
.where(and_(Payment.tg_id == Key.tg_id, Payment.status == "success"))
.exists()
)
return await session.scalar(
select(func.count()).select_from(Key).where(not_(subquery_success_payments))
)
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 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.payment_system.notin_(["referral", "coupon"])
)
)
)
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.payment_system.notin_(["referral", "coupon"])
)
)
)
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(
Payment.payment_system.notin_(["referral", "coupon"])
)
)
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.status == "success")
.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()
+130
View File
@@ -0,0 +1,130 @@
from datetime import datetime
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server, Tariff
from logger import logger
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None
):
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)
)
else:
result = await session.execute(select(Tariff))
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
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}")
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.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()
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:
logger.info(f"[TARIFF] Тариф {tariff_id} найден в БД: {tariff.group_code}")
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
return False
+44
View File
@@ -0,0 +1,44 @@
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()
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}")
+134
View File
@@ -0,0 +1,134 @@
from sqlalchemy import func, insert, select, not_
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
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()
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")
)
.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:
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where(User.source_code == code)
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.trial == 1))
.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")
& not_(Payment.payment_system.in_(["coupon", "referral"]))
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral"]))
)
.scalar_subquery()
)
query = 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)
result = await session.execute(query)
row = result.first()
if not row:
return None
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),
}
+209
View File
@@ -0,0 +1,209 @@
from datetime import datetime
from sqlalchemy import delete, exists, 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,
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,
):
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=[User.tg_id])
)
await session.execute(stmt)
await session.commit()
logger.info(
f"[DB] Новый пользователь добавлен: {tg_id} (source: {source_code})"
)
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:
result = await session.execute(select(User.balance).where(User.tg_id == tg_id))
current = result.scalar_one_or_none() or 0
new_balance = current + amount
await session.execute(
update(User).where(User.tg_id == tg_id).values(balance=new_balance)
)
await session.commit()
logger.info(
f"[DB] Баланс пользователя {tg_id} обновлён: {current}{new_balance}"
)
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(User.balance).where(User.tg_id == tg_id))
balance = result.scalar_one_or_none()
return round(balance, 1) if balance is not None else 0.0
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()
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()
async def get_trial(session: AsyncSession, tg_id: int) -> int:
result = await session.execute(select(User.trial).where(User.tg_id == tg_id))
trial = result.scalar_one_or_none()
return 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:
if only_if_exists:
result = await session.execute(select(User).where(User.tg_id == tg_id))
user = result.scalar_one_or_none()
if not user:
return None
await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(
username=username or user.username,
first_name=first_name or user.first_name,
last_name=last_name or user.last_name,
language_code=language_code or user.language_code,
is_bot=is_bot,
updated_at=datetime.utcnow(),
)
)
else:
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=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
.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": datetime.utcnow(),
},
)
)
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
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(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)
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:
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
await session.rollback()
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()
+2
View File
@@ -12,6 +12,7 @@ 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
router = Router(name="handlers_main_router")
@@ -28,4 +29,5 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+17 -9
View File
@@ -2,21 +2,27 @@ __all__ = ("router",)
from aiogram import Router
from .admin_backups import router as backups_router
from .admin_bans import router as bans_router
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
from .admin_restart import router as restart_router
from .admin_sender import router as sender_router
from .admin_servers import router as servers_router
from .admin_stats import router as stats_router
from .admin_users import router as users_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 .management import router as management_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 .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(
panel_router,
management_router,
servers_router,
clusters_router,
users_router,
stats_router,
backups_router,
@@ -24,4 +30,6 @@ router.include_routers(
coupons_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
)
-29
View File
@@ -1,29 +0,0 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "backups"),
IsAdminFilter(),
)
async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
)
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии: {exception}"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
await callback_query.message.edit_text(text=text, reply_markup=kb)
-92
View File
@@ -1,92 +0,0 @@
from typing import Any
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from database import delete_user_data
from filters.admin import IsAdminFilter
from keyboards.admin.bans_kb import build_bans_kb
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans"),
IsAdminFilter(),
)
async def handle_bans(callback_query: CallbackQuery):
text = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
await callback_query.message.edit_text(
text=text,
reply_markup=build_bans_kb(),
)
@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")
try:
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
import csv
import io
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"]])
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",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при экспорте: {e}",
reply_markup=kb,
)
@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")
try:
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
blocked_ids = [record["tg_id"] for record in blocked_users]
if not blocked_ids:
await callback_query.message.answer(
text="📂 Нет заблокировавших пользователей для удаления.",
reply_markup=kb,
)
return
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,
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при удалении записей: {e}",
reply_markup=kb,
)
-172
View File
@@ -1,172 +0,0 @@
from typing import Any
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 config import USERNAME_BOT
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from keyboards.admin.coupons_kb import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from logger import logger
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
async def handle_coupons(
callback_query: CallbackQuery,
):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_create"),
IsAdminFilter(),
)
async def handle_coupons_create(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"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("coupons"),
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def handle_coupon_data_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = build_admin_back_kb("coupons")
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
)
await message.answer(
text=text,
reply_markup=kb,
)
return
try:
coupon_code = parts[0]
coupon_amount = float(parts[1])
usage_limit = int(parts[2])
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
await message.answer(
text=text,
reply_markup=kb,
)
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b> \n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}</code>\n"
)
await message.answer(text=text, reply_markup=kb)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_list"),
IsAdminFilter(),
)
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await callback_query.message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\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"
)
await callback_query.message.edit_text(text=coupon_list, reply_markup=kb)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.answer("Произошла ошибка при получении списка купонов.")
@router.callback_query(AdminCouponDeleteCallback.filter(), IsAdminFilter())
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
coupon_code = callback_data.coupon_code
try:
result = await delete_coupon(coupon_code, session)
if result:
await callback_query.message.edit_text(f"Купон {coupon_code} удалён!")
else:
await callback_query.message.edit_text(f"❌ Купон с кодом {coupon_code} не найден.", show_alert=True)
await update_coupons_list(callback_query.message, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text("Произошла ошибка при удалении купона.", show_alert=True)
async def update_coupons_list(message, session: Any, page: int = 1):
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\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"
)
await message.edit_text(text=coupon_list, reply_markup=kb)
-48
View File
@@ -1,48 +0,0 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot import version
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_management_kb, build_panel_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
await state.clear()
if callback_query.message.text:
await callback_query.message.edit_text(text=text, reply_markup=build_panel_kb())
else:
try:
await callback_query.message.delete()
except Exception as e:
print(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(),
)
-40
View File
@@ -1,40 +0,0 @@
import subprocess
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"),
IsAdminFilter(),
)
async def handle_restart(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤔 Вы уверены, что хотите перезагрузить бота?",
reply_markup=build_restart_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart_confirm"),
IsAdminFilter(),
)
async def handle_restart_confirm(callback_query: CallbackQuery):
kb = build_admin_back_kb()
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except subprocess.CalledProcessError:
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except Exception as e:
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
-491
View File
@@ -1,491 +0,0 @@
import asyncio
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
from database import check_unique_server_name, delete_server, get_servers
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import create_key_on_cluster
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.servers_kb import (
AdminServerEditorCallback,
build_clusters_editor_kb,
build_delete_server_kb,
build_manage_cluster_kb,
build_manage_server_kb,
)
from logger import logger
router = Router()
class AdminServersEditor(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_new_domain = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "servers"),
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(
AdminPanelCallback.filter(F.action == "clusters_add"),
IsAdminFilter(),
)
async def handle_clusters_add(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя кластера должно быть уникальным!</b>\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("servers"))
await state.set_state(AdminServersEditor.waiting_for_cluster_name)
@router.message(AdminServersEditor.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("servers")
)
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("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_server_name)
@router.message(AdminServersEditor.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("servers")
)
return
server_name = message.text.strip()
if len(server_name) > 14:
await message.answer(
text="❌ Имя сервера не должно превышать 10 символов. Попробуйте снова.",
reply_markup=build_admin_back_kb("servers"),
)
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("servers"),
)
return
await state.update_data(server_name=server_name)
text = (
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"API URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port/panel_path</code>\n\n"
"URL должен быть без слэша на конце!\n"
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_api_url)
@router.message(AdminServersEditor.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: Message, state: FSMContext, session: Any):
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("servers"),
)
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"
"Subscription URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
"URL должен быть без слэша и имени клиента на конце!\n"
"Его можно увидеть в панели 3x-ui в информации о клиенте."
)
await message.answer(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_subscription_url)
@router.message(AdminServersEditor.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("servers"),
)
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("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_inbound_id)
@router.message(AdminServersEditor.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("servers"),
)
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("servers"),
)
await state.clear()
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_manage"), IsAdminFilter())
async def handle_clusters_manage(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, 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"🔧 Управление серверами для кластера {cluster_name}",
reply_markup=build_manage_cluster_kb(cluster_servers, cluster_name),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_availability"), IsAdminFilter())
async def handle_servers_availability(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, 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)
result_text = f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
total_online_users = 0
for server in cluster_servers:
xui = AsyncApi(server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD)
try:
await xui.login()
online_users = len(await xui.client.online())
total_online_users += online_users
result_text += f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
except Exception as e:
result_text += f"{server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
result_text += f"\n👥 Общее количество активных пользователей в кластере: {total_online_users}."
await callback_query.message.edit_text(text=result_text, reply_markup=build_admin_back_kb("servers"))
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_manage"), IsAdminFilter())
async def handle_servers_manage(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
server_name = callback_data.data
servers = await get_servers()
cluster_name, server = next(
((c, s) for c, cs in servers.items() for s in cs if s["server_name"] == server_name), (None, None)
)
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(server_name, cluster_name),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete"), IsAdminFilter())
async def handle_servers_delete(callback_query: CallbackQuery, callback_data: AdminServerEditorCallback):
server_name = callback_data.data
await callback_query.message.edit_text(
text=f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
reply_markup=build_delete_server_kb(server_name),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_delete_confirm"), IsAdminFilter())
async def handle_servers_delete_confirm(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, session: Any
):
server_name = callback_data.data
await delete_server(server_name, session)
await callback_query.message.edit_text(
text=f"🗑️ Сервер {server_name} успешно удален.", reply_markup=build_admin_back_kb("servers")
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "servers_add"), IsAdminFilter())
async def handle_servers_add(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, 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>server-asia</code>, <code>server-europe</code>"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_admin_back_kb("servers"),
)
await state.set_state(AdminServersEditor.waiting_for_server_name)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_backup"), IsAdminFilter())
async def handle_clusters_backup(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, 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,
)
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("servers"),
)
@router.callback_query(AdminServerEditorCallback.filter(F.action == "clusters_sync"), IsAdminFilter())
async def handle_clusters_sync(
callback_query: types.CallbackQuery, callback_data: AdminServerEditorCallback, 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.answer(
text=f"❌ Нет ключей для синхронизации в кластере {cluster_name}.",
reply_markup=build_admin_back_kb("servers"),
)
return
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.answer(
text=f"✅ Ключи успешно синхронизированы для кластера {cluster_name}",
reply_markup=build_admin_back_kb("servers"),
)
except Exception as e:
logger.error(f"Ошибка синхронизации ключей в кластере {cluster_name}: {e}")
await callback_query.message.answer(
text=f"❌ Произошла ошибка при синхронизации: {e}", reply_markup=build_admin_back_kb("servers")
)
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminServersEditor.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: pocomachodomen.ru",
)
@router.message(AdminServersEditor.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: asyncpg.Connection):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
new_domain_url = f"https://{new_domain}"
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
query = """
UPDATE keys
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
WHERE key NOT LIKE $1 || '%'
"""
try:
await session.execute(query, new_domain_url)
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(f"❌ Ошибка при обновлении домена: {e}", reply_markup=build_admin_back_kb("admin"))
return
try:
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(f"✅ Домен успешно изменен на {new_domain}!", reply_markup=build_admin_back_kb("admin"))
await state.clear()
-120
View File
@@ -1,120 +0,0 @@
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.stats_kb import build_stats_kb
from logger import logger
from utils.csv_export import export_payments_csv, export_users_csv
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats"),
IsAdminFilter(),
)
async def handle_stats(callback_query: CallbackQuery, session: Any):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = int(
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
)
total_payments_week = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
)
total_payments_month = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
)
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
registrations_week = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
registrations_month = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
moscow_tz = pytz.timezone("Europe/Moscow")
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
stats_message = (
f"📊 <b>Подробная статистика проекта:</b>\n\n"
f"👥 Пользователи:\n"
f" 📅 За день: <b>{registrations_today}</b>\n"
f" 📆 За неделю: <b>{registrations_week}</b>\n"
f" 📆 За месяц: <b>{registrations_month}</b>\n"
f" 🌐 За все время: <b>{total_users}</b>\n\n"
f"🌟 Активные пользователи:\n"
f" 🌟 Активных сегодня: <b>{users_updated_today}</b>\n\n"
f"👥 Рефералы:\n"
f" 🤝 Всего привлечено: <b>{total_referrals}</b>\n\n"
f"🔑 Ключи:\n"
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
f" ✅ Действующих: <b>{active_keys}</b>\n"
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
f"💰 Финансовая статистика:\n"
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
f" 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n\n"
f" ⏳ Последнее обновление: {update_time}"
)
await callback_query.message.edit_text(text=stats_message, reply_markup=build_stats_kb())
except TelegramBadRequest as e:
if "message is not modified" not in str(e): # skip when Telegram message is not modified
logger.error(f"Error in user_stats_menu: {e}")
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"),
IsAdminFilter(),
)
async def handle_export_users_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт пользователей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"),
IsAdminFilter(),
)
async def handle_export_payments_csv(callback_query: CallbackQuery, session: Any):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
await callback_query.message.answer_document(document=export, caption="📥 Экспорт платежей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.edit_text(text=f"❗ Произошла ошибка при экспорте: {e}", reply_markup=kb)
-717
View File
@@ -1,717 +0,0 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Any
import pytz
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from config import TOTAL_GB
from database import (
delete_key,
delete_user_data,
get_balance,
get_client_id_by_email,
get_servers,
update_balance,
update_key_expiry,
update_trial,
)
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import delete_key_from_cluster, get_user_traffic, renew_key_in_cluster, update_subscription
from handlers.utils import sanitize_key_name
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.users_kb import (
AdminUserEditorCallback,
AdminUserKeyEditorCallback,
build_editor_kb,
build_key_delete_kb,
build_key_edit_kb,
build_user_delete_kb,
build_user_edit_kb,
build_user_key_kb,
build_users_balance_change_kb,
build_users_balance_kb,
build_users_key_expiry_kb,
build_users_key_show_kb,
)
from logger import logger
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
router = Router()
class UserEditorState(StatesGroup):
# search
waiting_for_user_data = State()
waiting_for_key_name = State()
# updating data
waiting_for_balance = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_user"),
IsAdminFilter(),
)
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
text = (
"<b>🔍 Поиск пользователя</b>"
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
"\n\n🆔 ID - числовой айди"
"\n📝 Username - юзернейм пользователя"
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
)
await state.set_state(UserEditorState.waiting_for_user_data)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_key"),
IsAdminFilter(),
)
async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_key_name)
await callback_query.message.edit_text(text="🔑 Введите имя ключа для поиска:", reply_markup=build_admin_back_kb())
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
if message.text.isdigit():
tg_id = int(message.text)
else:
# Удаление '@' символа в начале сообщения
username = message.text.strip().lstrip("@")
# Удаление начала ссылки на профиль
username = username.replace("https://t.me/", "")
user = await session.fetchrow("SELECT tg_id FROM users WHERE username = $1", username)
if not user:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
tg_id = user["tg_id"]
await process_user_search(message, state, session, tg_id)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(message: Message, state: FSMContext, session: Any):
kb = build_admin_back_kb()
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(key_name, session)
if not key_details:
await message.answer(text="🚫 Пользователь с указанным именем ключа не найден.", reply_markup=kb)
return
await process_user_search(message, state, session, key_details["tg_id"])
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_send_message"),
IsAdminFilter(),
)
async def handle_send_message(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext
):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text="✉️ Введите текст сообщения, которое вы хотите отправить пользователю:", reply_markup=build_editor_kb(tg_id)
)
await state.update_data(tg_id=tg_id)
await state.set_state(UserEditorState.waiting_for_message_text)
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
async def handle_message_text_input(message: Message, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
try:
await message.bot.send_message(chat_id=tg_id, text=message.text)
await message.answer(text="✅ Сообщение успешно отправлено.", reply_markup=build_editor_kb(tg_id))
except Exception as e:
await message.answer(text=f"❌ Не удалось отправить сообщение: {e}", reply_markup=build_editor_kb(tg_id))
await state.clear()
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
IsAdminFilter(),
)
async def handle_trial_restore(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
tg_id = callback_data.tg_id
await update_trial(tg_id, 0, session)
await callback_query.message.edit_text(text="✅ Триал успешно восстановлен!", reply_markup=build_editor_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_edit"), IsAdminFilter())
async def handle_balance_change(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
tg_id = callback_data.tg_id
records = await session.fetch(
"""
SELECT amount, payment_system, status, created_at
FROM payments
WHERE tg_id = $1
ORDER BY created_at DESC
LIMIT 5
""",
tg_id,
)
balance = await get_balance(tg_id)
balance = int(balance)
text = (
f"<b>💵 Изменение баланса</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
f"\n💰 Баланс: <b>{balance}Р</b>"
f"\n📊 Последние операции (5):"
)
if records:
for record in records:
amount = record["amount"]
payment_system = record["payment_system"]
status = record["status"]
date = record["created_at"].strftime("%Y-%m-%d %H:%M:%S")
text += (
f"\n<blockquote>💸 Сумма: {amount} | {payment_system}"
f"\n📌 Статус: {status}"
f"\n⏳ Дата: {date}</blockquote>"
)
else:
text += "\n <i>🚫 Отсутствуют</i>"
await callback_query.message.edit_text(text=text, reply_markup=build_users_balance_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_add"), IsAdminFilter())
async def handle_balance_add(
callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
amount = callback_data.data
if amount:
await update_balance(tg_id, int(amount), session, is_admin=True)
await handle_balance_change(callback_query, callback_data, session)
return
await state.update_data(tg_id=tg_id, op_type="add")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите сумму, которую хотите добавить на баланс пользователя:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_take"), IsAdminFilter())
async def handle_balance_take(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id, op_type="take")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите сумму, которую хотите вычесть из баланса пользователя:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_balance_set"), IsAdminFilter())
async def handle_balance_set(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext):
tg_id = callback_data.tg_id
await state.update_data(tg_id=tg_id, op_type="set")
await state.set_state(UserEditorState.waiting_for_balance)
await callback_query.message.edit_text(
text="✍️ Введите баланс, который хотите установить пользователю:",
reply_markup=build_users_balance_change_kb(tg_id),
)
@router.message(UserEditorState.waiting_for_balance, IsAdminFilter())
async def handle_balance_input(message: Message, state: FSMContext, session: Any):
data = await state.get_data()
tg_id = data.get("tg_id")
op_type = data.get("op_type")
if not message.text.isdigit() or int(message.text) < 0:
await message.answer(
text="🚫 Пожалуйста, введите корректную сумму!", reply_markup=build_users_balance_change_kb(tg_id)
)
return
amount = int(message.text)
if op_type == "add":
text = f"✅ К балансу пользователя добавлено <b>{amount}Р</b>"
await update_balance(tg_id, amount, session)
elif op_type == "take":
text = f"✅ Из баланса пользователя было вычтено <b>{amount}Р</b>"
await update_balance(tg_id, -amount, session)
else:
text = f"✅ Баланс пользователя изменен на <b>{amount}Р</b>"
await set_user_balance(tg_id, amount, session)
await message.answer(text=text, reply_markup=build_users_balance_change_kb(tg_id))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_key_edit"), IsAdminFilter())
async def handle_key_edit(
callback_query: CallbackQuery,
callback_data: AdminUserEditorCallback | AdminUserKeyEditorCallback,
session: Any,
update: bool = False,
):
email = callback_data.data
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(callback_data.tg_id),
)
return
text = (
f"<b>🔑 Информация о ключе</b>"
f"\n\n<code>{key_details['key']}</code>"
f"\n\n⏰ Дата истечения: <b>{key_details['expiry_date']} (UTC)</b>"
f"\n🌐 Кластер: <b>{key_details['cluster_name']}</b>"
f"\n🆔 ID клиента: <b>{key_details['tg_id']}</b>"
)
if not update or not callback_data.edit:
await callback_query.message.edit_text(text=text, reply_markup=build_key_edit_kb(key_details, email))
else:
await callback_query.message.edit_text(
text=text, reply_markup=build_users_key_expiry_kb(callback_data.tg_id, email)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_expiry_edit"), IsAdminFilter())
async def handle_change_expiry(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_reply_markup(reply_markup=build_users_key_expiry_kb(tg_id, email))
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "add"), IsAdminFilter())
async def handle_expiry_add(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
email = callback_data.data
month = callback_data.month
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
if month:
await change_expiry_time(key_details["expiry_time"] + month * 30 * 24 * 3600 * 1000, email, session)
await handle_key_edit(callback_query, callback_data, session, True)
return
await state.update_data(tg_id=tg_id, email=email, op_type="add")
await state.set_state(UserEditorState.waiting_for_expiry_time)
await callback_query.message.edit_text(
text="✍️ Введите количество дней, которое хотите добавить к времени действия ключа:",
reply_markup=build_users_key_show_kb(tg_id, email),
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "take"), IsAdminFilter())
async def handle_expiry_take(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext
):
tg_id = callback_data.tg_id
email = callback_data.data
await state.update_data(tg_id=tg_id, email=email, op_type="take")
await state.set_state(UserEditorState.waiting_for_expiry_time)
await callback_query.message.edit_text(
text="✍️ Введите количество дней, которое хотите вычесть из времени действия ключа:",
reply_markup=build_users_key_show_kb(tg_id, email),
)
@router.callback_query(AdminUserKeyEditorCallback.filter(F.action == "set"), IsAdminFilter())
async def handle_expiry_set(
callback_query: CallbackQuery, callback_data: AdminUserKeyEditorCallback, state: FSMContext, session: Any
):
tg_id = callback_data.tg_id
email = callback_data.data
key_details = await get_key_details(email, session)
if not key_details:
await callback_query.message.edit_text(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
await state.update_data(tg_id=tg_id, email=email, op_type="set")
await state.set_state(UserEditorState.waiting_for_expiry_time)
text = (
"✍️ Введите новое время действия ключа:"
"\n\n📌 Формат: <b>год-месяц-день час:минута</b>"
f"\n\n📄 Текущая дата: {datetime.fromtimestamp(key_details['expiry_time'] / 1000).strftime('%Y-%m-%d %H:%M')}"
)
await callback_query.message.edit_text(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
async def handle_expiry_time_input(message: Message, state: FSMContext, session: Any):
data = await state.get_data()
tg_id = data.get("tg_id")
email = data.get("email")
op_type = data.get("op_type")
if op_type != "set" and (not message.text.isdigit() or int(message.text) < 0):
await message.answer(
text="🚫 Пожалуйста, введите корректное количество дней!",
reply_markup=build_users_key_show_kb(tg_id, email),
)
return
key_details = await get_key_details(email, session)
if not key_details:
await message.answer(
text="🚫 Информация о ключе не найдена.",
reply_markup=build_editor_kb(tg_id),
)
return
try:
current_expiry_time = datetime.fromtimestamp(key_details["expiry_time"] / 1000, tz=MOSCOW_TZ)
if op_type == "add":
days = int(message.text)
new_expiry_time = current_expiry_time + timedelta(days=days)
text = f"✅ Ко времени действия ключа добавлено <b>{days} дн.</b>"
elif op_type == "take":
days = int(message.text)
new_expiry_time = current_expiry_time - timedelta(days=days)
text = f"✅ Из времени действия ключа вычтено <b>{days} дн.</b>"
else:
new_expiry_time = datetime.strptime(message.text, "%Y-%m-%d %H:%M")
new_expiry_time = MOSCOW_TZ.localize(new_expiry_time)
text = f"✅ Время действия ключа изменено на <b>{message.text} (МСК)</b>"
new_expiry_timestamp = int(new_expiry_time.timestamp() * 1000)
await change_expiry_time(new_expiry_timestamp, email, session)
except ValueError:
text = "🚫 Пожалуйста, используйте корректный формат даты (ГГГГ-ММ-ДД ЧЧ:ММ)!"
except Exception as e:
text = f"❗ Произошла ошибка во время изменения времени действия ключа: {e}"
await message.answer(text=text, reply_markup=build_users_key_show_kb(tg_id, email))
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_update_key"), IsAdminFilter())
async def handle_update_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
tg_id = callback_data.tg_id
email = callback_data.data
try:
await update_subscription(tg_id, email, session)
await handle_key_edit(callback_query, callback_data, session, True)
except TelegramBadRequest:
pass
except Exception as e:
logger.error(f"Ошибка при обновлении ключа {email} администратором: {e}")
await callback_query.message.answer(
text=f"❗ Произошла ошибка при обновлении ключа: {e}", reply_markup=build_user_key_kb(tg_id, email)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key"), IsAdminFilter())
async def handle_delete_key(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback, session: Any):
email = callback_data.data
client_id = await session.fetchval("SELECT client_id FROM keys WHERE email = $1", email)
if client_id is None:
await callback_query.message.edit_text(
text="🚫 Ключ не найден!", reply_markup=build_editor_kb(callback_data.tg_id)
)
return
await callback_query.message.edit_text(
text="❓ Вы уверены, что хотите удалить ключ?", reply_markup=build_key_delete_kb(callback_data.tg_id, email)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_key_confirm"), IsAdminFilter())
async def handle_delete_key_confirm(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
email = callback_data.data
record = await session.fetchrow("SELECT client_id FROM keys WHERE email = $1", email)
kb = build_editor_kb(callback_data.tg_id)
if record:
client_id = record["client_id"]
clusters = await get_servers()
async def delete_key_from_servers():
tasks = []
for cluster_name, cluster_servers in clusters.items():
for _ in cluster_servers:
tasks.append(delete_key_from_cluster(cluster_name, email, client_id))
await asyncio.gather(*tasks)
await delete_key_from_servers()
await delete_key(client_id, session)
await callback_query.message.edit_text(text="✅ Ключ успешно удален.", reply_markup=kb)
else:
await callback_query.message.edit_text(text="🚫 Ключ не найден или уже удален.", reply_markup=kb)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user"), IsAdminFilter())
async def handle_delete_user(callback_query: CallbackQuery, callback_data: AdminUserEditorCallback):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text=f"❗️ Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=build_user_delete_kb(tg_id)
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_delete_user_confirm"), IsAdminFilter())
async def handle_delete_user_confirm(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
tg_id = callback_data.tg_id
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
async def delete_keys_from_servers():
try:
tasks = []
for email, client_id in key_records:
servers = await get_servers()
for cluster_id, _cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks)
except Exception as e:
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
try:
await delete_user_data(session, tg_id)
await callback_query.message.edit_text(
text=f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=build_editor_kb(callback_data.tg_id)
)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.edit_text(
text=f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_editor"), IsAdminFilter())
async def handle_editor(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, state: FSMContext, session: Any
):
await process_user_search(callback_query.message, state, session, callback_data.tg_id, callback_data.edit)
async def process_user_search(
message: types.Message, state: FSMContext, session: Any, tg_id: int, edit: bool = False
) -> None:
await state.clear()
balance = await session.fetchval("SELECT balance FROM connections WHERE tg_id = $1", tg_id)
if balance is None:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
balance = int(balance)
username = await session.fetchval("SELECT username FROM users WHERE tg_id = $1", tg_id)
key_records = await session.fetch("SELECT email, expiry_time FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval("SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id)
text = (
f"<b>📊 Информация о пользователе</b>"
f"\n\n🆔 ID: <b>{tg_id}</b>"
f"\n📄 Логин: <b>@{username}</b>"
f"\n💰 Баланс: <b>{balance}</b>"
f"\n👥 Количество рефералов: <b>{referral_count}</b>"
)
kb = build_user_edit_kb(tg_id, key_records)
if edit:
try:
await message.edit_text(text=text, reply_markup=kb)
except TelegramBadRequest:
pass
else:
await message.answer(text=text, reply_markup=kb)
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.client_id, k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
""",
email,
)
if not record:
return None
cluster_name = record["server_id"]
expiry_date = datetime.fromtimestamp(record["expiry_time"] / 1000, tz=timezone.utc)
return {
"client_id": record["client_id"],
"balance": record["balance"],
"tg_id": record["tg_id"],
"key": record["key"],
"cluster_name": cluster_name,
"expiry_time": record["expiry_time"],
"expiry_date": expiry_date.strftime("%d %B %Y года %H:%M"),
}
async def change_expiry_time(expiry_time: int, email: str, session: Any) -> Exception | None:
client_id = await get_client_id_by_email(email)
if client_id is None:
return ValueError(f"User with email {email} was not found")
server_id = await session.fetchrow("SELECT server_id FROM keys WHERE client_id = $1", client_id)
if not server_id:
return ValueError(f"User with client_id {server_id} was not found")
clusters = await get_servers()
async def update_key_on_all_servers():
tasks = [
asyncio.create_task(
renew_key_in_cluster(
cluster_name,
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
)
)
for cluster_name in clusters
]
await asyncio.gather(*tasks)
await update_key_on_all_servers()
await update_key_expiry(client_id, expiry_time, session)
async def set_user_balance(tg_id: int, balance: int, session: Any) -> None:
try:
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
balance,
tg_id,
)
except Exception as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
@router.callback_query(AdminUserEditorCallback.filter(F.action == "users_traffic"), IsAdminFilter())
async def handle_user_traffic(
callback_query: types.CallbackQuery, callback_data: AdminUserEditorCallback, session: Any
):
"""
Обработчик кнопки "📊 Трафик".
Получает трафик пользователя и отправляет администратору.
"""
tg_id = callback_data.tg_id
email = callback_data.data
await callback_query.message.edit_text("⏳ Получаем данные о трафике, пожалуйста, подождите...")
traffic_data = await get_user_traffic(session, tg_id, email)
if traffic_data["status"] == "error":
await callback_query.message.edit_text(traffic_data["message"], reply_markup=build_editor_kb(tg_id, True))
return
total_traffic = 0
result_text = f"📊 <b>Трафик подписки {email}:</b>\n\n"
for server, traffic in traffic_data["traffic"].items():
if isinstance(traffic, str):
result_text += f"{server}: {traffic}\n"
else:
result_text += f"🌍 {server}: <b>{traffic} ГБ</b>\n"
total_traffic += traffic
result_text += f"\n🔢 <b>Общий трафик:</b> {total_traffic:.2f} ГБ"
await callback_query.message.edit_text(result_text, reply_markup=build_editor_kb(tg_id, True))
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .ads_handler import router
+191
View File
@@ -0,0 +1,191 @@
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>.",
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"]
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):
try:
result = await session.execute(
select(TrackingSource).order_by(TrackingSource.created_at.desc())
)
ads = result.scalars().all()
reply_markup = build_ads_list_kb(ads, current_page=1, total_pages=1)
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:
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>."
)
+96
View File
@@ -0,0 +1,96 @@
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
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()
for ad in ads:
builder.button(
text=f"📎 {ad['name']}",
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
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="delete_confirm", code=code).pack(),
)
builder.row(build_admin_back_btn("ads"))
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()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .backups_handler import router
+35
View File
@@ -0,0 +1,35 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from backup import backup_database
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "backups"),
IsAdminFilter(),
)
async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...",
reply_markup=kb,
)
try:
exception = await backup_database()
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)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .bans_handler import router
+156
View File
@@ -0,0 +1,156 @@
import csv
import io
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import 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
router = Router()
@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\n"
"⬇ Выберите нужный раздел:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter()
)
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("management")
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
banned_users = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id"])
for user in banned_users:
writer.writerow([user.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)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter()
)
async def handle_bans_delete_banned(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("bans")
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
blocked_users = result.all()
blocked_ids = [user.tg_id for user in blocked_users]
if not blocked_ids:
await callback_query.message.answer(
text="📂 Нет заблокировавших пользователей для удаления.",
reply_markup=kb,
)
return
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute(
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
{"blocked_ids": blocked_ids},
)
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
reply_markup=kb,
)
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
):
build_admin_back_kb("bans")
try:
result = await session.execute(
text("SELECT tg_id, banned_at, reason, until FROM manual_bans")
)
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
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="📥 Экспорт вручную забаненных пользователей",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=build_admin_back_kb("bans"),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter()
)
async def handle_delete_manual_banned(
callback_query: CallbackQuery, session: AsyncSession
):
try:
await session.execute(delete(ManualBan))
await session.commit()
await callback_query.message.edit_text(
"🗑️ Вручную забаненные пользователи удалены.",
reply_markup=build_bans_kb(),
)
logger.info("[BANS] Очищены записи из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
await callback_query.message.edit_text(
"❌ Ошибка при удалении вручную забаненных пользователей."
)
+31
View File
@@ -0,0 +1,31 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_export").pack(),
)
builder.button(
text="📛 Забаненные вручную",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="🗑️ Удалить забанивших",
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
)
builder.button(
text="🗑️ Очистить вручную забаненных",
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
)
builder.button(
text="🔙 Назад", callback_data=AdminPanelCallback(action="management").pack()
)
builder.adjust(1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .clusters_handler import router
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
action: str
data: str | None = None
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
cluster_names = list(servers.keys())
for i in range(0, len(cluster_names), 2):
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(),
)
)
builder.row(*row_buttons)
builder.row(
InlineKeyboardButton(
text=" Добавить кластер",
callback_data=AdminClusterCallback(action="add").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_manage_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=AdminServerCallback(
action="manage", data=server["server_name"]
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер",
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
)
)
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="🌐 Доступность",
callback_data=AdminClusterCallback(
action="availability", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Синхронизация",
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап",
callback_data=AdminClusterCallback(
action="backup", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить время",
callback_data=AdminClusterCallback(
action="add_time", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Сменить название",
callback_data=AdminClusterCallback(
action="rename", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(
action="set_tariff", data=cluster_name
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
return builder.as_markup()
def build_sync_cluster_kb(
cluster_servers: list, cluster_name: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🔄 Синхронизировать {server['server_name']}",
callback_data=AdminClusterCallback(
action="sync-server", data=server["server_name"]
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Синхронизировать кластер",
callback_data=AdminClusterCallback(
action="sync-cluster", data=cluster_name
).pack(),
)
)
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="⬅️ Назад",
callback_data=AdminClusterCallback(
action="manage", data=cluster_name
).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .coupons_handler import router
+349
View File
@@ -0,0 +1,349 @@
from typing import Any
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
CallbackQuery,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
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,
)
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb()
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_create"),
IsAdminFilter(),
)
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
text = "🎫 <b>Выберите тип купона:</b>"
kb = InlineKeyboardBuilder()
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
kb.button(text="⏳ Время", callback_data="coupon_type_days")
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_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"
)
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_balance_data)
@router.callback_query(F.data == "coupon_type_days")
async def handle_days_coupon_selection(
callback_query: CallbackQuery, state: FSMContext
):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\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.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_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())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
coupon_amount = int(parts[1])
usage_limit = int(parts[2])
if coupon_amount <= 0:
raise ValueError("Сумма должна быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
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()
)
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
async def handle_days_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())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
"Пример: <b>'DAYS10 10 50'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
days = int(parts[1])
usage_limit = int(parts[2])
if days <= 0:
raise ValueError("Количество дней должно быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"⏳ <b>{format_days(days)}</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
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()
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_list"),
IsAdminFilter(),
)
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
await update_coupons_list(callback_query.message, session, page)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при получении списка купонов."
)
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter()
)
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(),
)
kb.button(
text="❌ Нет, отменить",
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(),
)
@router.callback_query(
AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter()
)
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(session, coupon_code)
if not result:
await callback_query.message.edit_text(
f"❌ Купон с кодом {coupon_code} не найден.",
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"),
)
return
await update_coupons_list(callback_query.message, session)
async def update_coupons_list(message, session: Any, page: int = 1):
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
text = format_coupons_list(coupons, USERNAME_BOT)
await message.edit_text(text=text, reply_markup=kb)
@router.inline_query(F.query.startswith("coupon_"))
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
if not INLINE_MODE:
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)
if not coupon:
await inline_query.answer(
results=[],
switch_pm_text="Купон не найден",
switch_pm_parameter="coupons",
cache_time=1,
)
return
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"👇 Нажми, чтобы активировать!"
)
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
result = InlineQueryResultArticle(
id=coupon_code,
title=title,
description=description,
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)
+92
View File
@@ -0,0 +1,92 @@
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 handlers.utils import format_days
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
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()
def build_coupons_list_kb(
coupons: list, current_page: int, total_pages: int
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for coupon in coupons:
coupon_code = coupon["code"]
builder.button(
text=f"{coupon_code}",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page - 1
).pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(
action="coupons_list", page=current_page + 1
).pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("coupons"))
builder.adjust(2)
return builder.as_markup()
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"
)
return coupon_list
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .management_handler import router
+43
View File
@@ -0,0 +1,43 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from middlewares import maintenance
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_management_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="💾 Создать резервную копию",
callback_data=AdminPanelCallback(action="backups").pack(),
)
builder.button(
text="📛 Управление банами",
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.button(
text="🔄 Перезагрузить бота",
callback_data=AdminPanelCallback(action="restart").pack(),
)
builder.button(
text="🌐 Сменить домен",
callback_data=AdminPanelCallback(action="change_domain").pack(),
)
builder.button(
text="🔑 Восстановить пробники",
callback_data=AdminPanelCallback(action="restore_trials").pack(),
)
maintenance_text = (
"🛠️ Выключить тех. работы"
if maintenance.maintenance_mode
else "🛠️ Включить тех. работы"
)
builder.button(
text=maintenance_text,
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
@@ -0,0 +1,103 @@
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 func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key
from filters.admin import IsAdminFilter
from logger import logger
from middlewares import maintenance
from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminPanelCallback, build_management_kb
router = Router()
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "management"), IsAdminFilter()
)
async def handle_management(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter()
)
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminManagementStates.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(
message: Message, state: FSMContext, session: AsyncSession
):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(
f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'"
)
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
new_domain_url = f"https://{new_domain}"
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
try:
stmt = (
update(Key)
.where(~Key.key.startswith(new_domain_url))
.values(key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url))
)
await session.execute(stmt)
await session.commit()
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.execute(select(Key.key).limit(1))
example = sample.scalar()
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(
f"✅ Домен успешно изменен на {new_domain}!",
reply_markup=build_admin_back_kb("admin"),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery):
maintenance.maintenance_mode = not maintenance.maintenance_mode
new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb())
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .panel_handler import router
+94
View File
@@ -0,0 +1,94 @@
from typing import Any
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK, MAIN_MENU
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
action: str
page: int
def __init__(self, /, **data: Any) -> None:
if "page" not in data or data["page"] is None:
data["page"] = 1
super().__init__(**data)
def build_panel_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
)
builder.button(
text="🔑 Поиск по названию ключа",
callback_data=AdminPanelCallback(action="search_key").pack(),
)
builder.row(
InlineKeyboardButton(
text="🖥️ Серверы", callback_data=AdminPanelCallback(action="clusters").pack()
),
InlineKeyboardButton(
text="🤖 Управление",
callback_data=AdminPanelCallback(action="management").pack(),
),
)
builder.row(
InlineKeyboardButton(
text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()
),
InlineKeyboardButton(
text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()
),
)
builder.row(
InlineKeyboardButton(
text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()
)
)
builder.row(
InlineKeyboardButton(
text="📊 Статистика",
callback_data=AdminPanelCallback(action="stats").pack(),
),
InlineKeyboardButton(
text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()
),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 2, 1, 2, 1)
return builder.as_markup()
def build_restart_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, перезагрузить",
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
return build_admin_singleton_kb(BACK, action)
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_admin_btn(text, action))
return builder.as_markup()
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
return build_admin_btn(BACK, action)
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(
text=text, callback_data=AdminPanelCallback(action=action).pack()
)
+59
View File
@@ -0,0 +1,59 @@
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot import version
from filters.admin import IsAdminFilter
from logger import logger
from .keyboard import AdminPanelCallback, build_panel_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
await state.clear()
if callback_query.message.text:
try:
await callback_query.message.edit_text(
text=text,
reply_markup=build_panel_kb(),
disable_web_page_preview=True,
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.warning(
"🔄 Попытка редактировать сообщение без изменений — пропущено."
)
else:
raise
else:
try:
await callback_query.message.delete()
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(
text=text,
reply_markup=build_panel_kb(),
disable_web_page_preview=True,
)
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext):
text = f"🤖 Панель администратора\n\n📌 Версия бота: {version}"
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .restart_handler import router
+48
View File
@@ -0,0 +1,48 @@
import asyncio
import os
import subprocess
import sys
import psutil
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_restart_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter()
)
async def handle_restart_confirm(
callback_query: CallbackQuery, callback_data: AdminPanelCallback
):
kb = build_admin_back_kb()
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
asyncio.create_task(restart_bot())
async def restart_bot():
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 as e:
print(f"[Restart] Ошибка при перезапуске: {e}")
os._exit(1)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .sender_handler import router
+68
View File
@@ -0,0 +1,68 @@
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
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
type: str
data: str | None = None
def build_sender_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="👥 Все пользователи",
callback_data=AdminSenderCallback(type="all").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="С подпиской",
callback_data=AdminSenderCallback(type="subscribed").pack(),
),
InlineKeyboardButton(
text="❌ Без подписки",
callback_data=AdminSenderCallback(type="unsubscribed").pack(),
),
)
builder.row(
InlineKeyboardButton(
text="📍 Не использовавшие триал",
callback_data=AdminSenderCallback(type="untrial").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔥 Горячие лиды",
callback_data=AdminSenderCallback(type="hotleads").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📢 Кластер",
callback_data=AdminSenderCallback(type="cluster-select").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for cluster in clusters:
name = cluster["cluster_name"]
builder.button(
text=f"🌐 {name}",
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
)
builder.adjust(2)
builder.row(build_admin_back_btn())
return builder.as_markup()
@@ -1,16 +1,19 @@
from datetime import datetime
from typing import Any
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 distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, Server, User
from filters.admin import IsAdminFilter
from keyboards.admin.panel_kb import AdminPanelCallback, build_admin_back_kb
from keyboards.admin.sender_kb import AdminSenderCallback, build_clusters_kb, build_sender_kb
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
@@ -48,8 +51,10 @@ async def handle_sender_callback_text(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
@@ -57,13 +62,14 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: Any):
"""
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
"""
async def handle_message_input(message: Message, state: FSMContext, session):
text_message = message.html_text if message.text else None
photo = message.photo[-1].file_id if message.photo else None
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
photo_url = (
message.caption
if message.photo and message.caption and message.caption.startswith("http")
else None
)
if not text_message and message.caption:
text_message = message.caption
@@ -74,67 +80,86 @@ async def handle_message_input(message: Message, state: FSMContext, session: Any
state_data = await state.get_data()
send_to = state_data.get("type", "all")
now_ms = int(datetime.utcnow().timestamp() * 1000)
query = None
if send_to == "subscribed":
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
WHERE k.expiry_time > $1
""",
int(datetime.utcnow().timestamp() * 1000),
)
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
tg_ids = await session.fetch(
"""
SELECT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
GROUP BY c.tg_id
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
""",
int(datetime.utcnow().timestamp() * 1000),
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(subquery))
elif send_to == "cluster":
cluster_name = state_data.get("cluster_name")
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
JOIN servers s ON k.server_id = s.cluster_name
WHERE s.cluster_name = $1
""",
cluster_name,
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
)
elif send_to == "hotleads":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
query = select(distinct(User.tg_id))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
total_users = len(tg_ids)
success_count = 0
for record in tg_ids:
tg_id = record["tg_id"]
await message.answer(
f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
)
for tg_id in tg_ids:
try:
if photo or photo_url:
await message.bot.send_photo(
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
chat_id=tg_id,
photo=photo or photo_url,
caption=text_message,
)
else:
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
await message.bot.send_message(
chat_id=tg_id, text=text_message, parse_mode="HTML"
)
success_count += 1
except Exception as e:
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
text = (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Всего пользователей:</b> {total_users}\n"
f" <b>Доставлено:</b> {success_count}\n"
f" <b>Не доставлено:</b> {total_users - success_count}"
await message.answer(
text=(
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f" <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
),
reply_markup=build_admin_back_kb("sender"),
)
await message.answer(text=text, reply_markup=build_admin_back_kb("stats"), parse_mode="HTML")
await state.clear()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .servers_handler import router
+50
View File
@@ -0,0 +1,50 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
class AdminServerCallback(CallbackData, prefix="admin_server"):
action: str
data: str
def build_manage_server_kb(
server_name: str, cluster_name: str, enabled: bool
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
toggle_text = "🔴 Отключить" if enabled else "🟢 Включить"
toggle_action = "disable" if enabled else "enable"
builder.button(
text=toggle_text,
callback_data=AdminServerCallback(
action=toggle_action, data=server_name
).pack(),
)
builder.button(
text="📈 Задать лимит",
callback_data=AdminServerCallback(action="set_limit", data=server_name).pack(),
)
builder.button(
text="🗑️ Удалить",
callback_data=AdminServerCallback(action="delete", data=server_name).pack(),
)
builder.button(
text="✏️ Сменить название",
callback_data=AdminServerCallback(action="rename", data=server_name).pack(),
)
builder.button(
text="🔙 Назад",
callback_data=f"cluster_servers|{cluster_name}",
)
builder.adjust(1)
return builder.as_markup()
+355
View File
@@ -0,0 +1,355 @@
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_servers
from database.models import Key, Server
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminServerCallback, build_manage_server_kb
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
@router.callback_query(
AdminServerCallback.filter(F.action == "manage"), IsAdminFilter()
)
async def handle_server_manage(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
session: AsyncSession,
):
server_name = callback_data.data
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@router.callback_query(
AdminServerCallback.filter(F.action == "delete"), IsAdminFilter()
)
async def process_callback_delete_server(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
state: FSMContext,
session: AsyncSession,
):
from ..clusters.clusters_handler import AdminClusterStates
server_name = callback_data.data
servers_dict = await get_servers(session)
cluster_name = None
for c_name, server_list in servers_dict.items():
if any(s["server_name"] == server_name for s in server_list):
cluster_name = c_name
break
if not cluster_name:
await callback_query.message.edit_text(
text=f"❌ Не удалось найти кластер для сервера '{server_name}'.",
reply_markup=build_admin_back_kb("clusters"),
)
return
stmt_keys_count = select(func.count()).where(Key.server_id == server_name)
result = await session.execute(stmt_keys_count)
keys_count = result.scalar_one()
if keys_count > 0:
await state.update_data(server_name=server_name, cluster_name=cluster_name)
subq = (
select(func.count())
.where(Key.server_id == Server.server_name)
.correlate(Server)
.scalar_subquery()
)
stmt_all_servers = select(Server.server_name, subq.label("key_count")).where(
Server.server_name != server_name
)
result = await session.execute(stmt_all_servers)
all_servers = result.all()
if all_servers:
builder = InlineKeyboardBuilder()
for s_name, key_count in all_servers:
builder.row(
InlineKeyboardButton(
text=f"{s_name} ({key_count})",
callback_data=f"transfer_to_server|{s_name}|{server_name}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(
action="manage", data=server_name
).pack(),
)
)
await callback_query.message.edit_text(
text=f"⚠️ На сервере '{server_name}' есть {keys_count} ключей. Выберите сервер для переноса ключей:",
reply_markup=builder.as_markup(),
)
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
return
stmt_remaining = select(func.count()).where(
(Server.cluster_name == cluster_name) & (Server.server_name != server_name)
)
result = await session.execute(stmt_remaining)
remaining_servers = result.scalar_one()
if remaining_servers == 0:
stmt_other_clusters = (
select(Server.cluster_name)
.distinct()
.where(Server.cluster_name != cluster_name)
)
result = await session.execute(stmt_other_clusters)
other_clusters = result.scalars().all()
if other_clusters:
stmt_cluster_keys = select(func.count()).where(
Key.server_id == cluster_name
)
result = await session.execute(stmt_cluster_keys)
cluster_keys_count = result.scalar_one()
if cluster_keys_count > 0:
await state.update_data(
server_name=server_name, cluster_name=cluster_name
)
subq_cluster = (
select(func.count())
.where(Key.server_id == Server.cluster_name)
.correlate(Server)
.scalar_subquery()
)
stmt_all_clusters = (
select(Server.cluster_name, subq_cluster.label("key_count"))
.where(Server.cluster_name != cluster_name)
.group_by(Server.cluster_name)
)
result = await session.execute(stmt_all_clusters)
all_clusters = result.all()
builder = InlineKeyboardBuilder()
for cl_name, key_count in all_clusters:
builder.row(
InlineKeyboardButton(
text=f"{cl_name} ({key_count})",
callback_data=f"transfer_to_cluster|{cl_name}|{cluster_name}|{server_name}",
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminServerCallback(
action="manage", data=server_name
).pack(),
)
)
await callback_query.message.edit_text(
text=f"⚠️ Это последний сервер в кластере '{cluster_name}'. На кластере есть {cluster_keys_count} ключей. Выберите кластер для переноса ключей:",
reply_markup=builder.as_markup(),
)
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
return
stmt_delete = delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален. Кластер '{cluster_name}' также удален, так как в нем не осталось серверов.",
reply_markup=build_admin_back_kb("clusters"),
)
else:
stmt_delete = delete(Server).where(
(Server.cluster_name == cluster_name) & (Server.server_name == server_name)
)
await session.execute(stmt_delete)
await session.commit()
await callback_query.message.edit_text(
text=f"✅ Сервер '{server_name}' удален.",
reply_markup=build_admin_back_kb("clusters"),
)
@router.callback_query(
AdminServerCallback.filter(F.action.in_(["enable", "disable"])), IsAdminFilter()
)
async def toggle_server_enabled(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
session: AsyncSession,
):
server_name = callback_data.data
action = callback_data.action
new_status = action == "enable"
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(enabled=new_status)
)
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if not server:
await callback_query.message.edit_text("❌ Сервер не найден.")
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await callback_query.message.edit_text(
text=text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=new_status
),
)
@router.callback_query(
AdminServerCallback.filter(F.action == "set_limit"), IsAdminFilter()
)
async def ask_server_limit(
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext
):
server_name = callback_data.data
await state.set_state(ServerLimitState.waiting_for_limit)
await state.update_data(server_name=server_name)
await callback_query.message.edit_text(
f"Введите лимит ключей для сервера <b>{server_name}</b> (целое число, 0 — без лимита):",
)
@router.message(ServerLimitState.waiting_for_limit, IsAdminFilter())
async def save_server_limit(
message: types.Message, state: FSMContext, session: AsyncSession
):
try:
limit = int(message.text.strip())
if limit < 0:
raise ValueError
data = await state.get_data()
server_name = data["server_name"]
new_value = limit if limit > 0 else None
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(max_keys=new_value)
)
await session.commit()
servers = await get_servers(session=session, include_enabled=True)
cluster_name, server = next(
(
(c, s)
for c, cs in servers.items()
for s in cs
if s["server_name"] == server_name
),
(None, None),
)
if not server:
await message.answer("❌ Сервер не найден.")
await state.clear()
return
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys is not None else "не задан"
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {server['api_url']}\n"
f"<b>🌐 Subscription URL:</b> {server['subscription_url']}\n"
f"<b>🔑 Inbound ID:</b> {server['inbound_id']}\n"
f"<b>📈 Лимит ключей:</b> {limit_display}"
)
await message.answer(
text,
reply_markup=build_manage_server_kb(
server_name, cluster_name, enabled=server.get("enabled", True)
),
)
await state.clear()
except ValueError:
await message.answer("❌ Введите корректное целое число (0 = без лимита)")
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .stats_handler import router
+30
View File
@@ -0,0 +1,30 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_stats_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔄 Обновить", callback_data=AdminPanelCallback(action="stats").pack()
)
builder.button(
text="📥 Выгрузить пользователей в CSV",
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
)
builder.button(
text="📥 Выгрузить оплаты в CSV",
callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack(),
)
builder.button(
text="📥 Выгрузить подписки в CSV",
callback_data=AdminPanelCallback(action="stats_export_keys_csv").pack(),
)
builder.button(
text="📥 Выгрузить горящих лидов",
callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
+301
View File
@@ -0,0 +1,301 @@
from datetime import datetime, timedelta
import pytz
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from collections import Counter
from bot import bot
from config import ADMIN_ID
from database import (
count_active_keys,
count_total_keys,
count_total_referrals,
count_total_users,
count_trial_keys,
count_users_registered_between,
count_users_registered_since,
count_users_updated_today,
get_tariff_distribution,
get_tariff_names,
get_tariff_groups,
get_tariff_durations,
sum_payments_between,
sum_payments_since,
sum_total_payments,
count_hot_leads
)
from filters.admin import IsAdminFilter
from logger import logger
from utils.csv_export import (
export_hot_leads_csv,
export_keys_csv,
export_payments_csv,
export_users_csv,
)
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_stats_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
try:
now = datetime.utcnow()
today = now.date()
moscow_tz = pytz.timezone("Europe/Moscow")
total_users = await count_total_users(session)
users_updated_today = await count_users_updated_today(session, today)
registrations_today = await count_users_registered_since(session, today)
registrations_yesterday = await count_users_registered_between(
session, today - timedelta(days=1), today
)
registrations_week = await count_users_registered_since(
session, today - timedelta(days=today.weekday())
)
registrations_month = await count_users_registered_since(
session, today.replace(day=1)
)
last_month_start = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
registrations_last_month = await count_users_registered_between(
session, last_month_start, today.replace(day=1)
)
total_keys = await count_total_keys(session)
active_keys = await count_active_keys(session)
expired_keys = total_keys - active_keys
trial_keys_count = await count_trial_keys(session)
tariff_counts, no_tariff_keys = await get_tariff_distribution(session, include_unbound=True)
tariff_names = await get_tariff_names(session, [tid for tid, _ in tariff_counts])
tariff_groups = await get_tariff_groups(session, [tid for tid, _ in tariff_counts])
tariff_durations = await get_tariff_durations(session, [tid for tid, _ in tariff_counts])
grouped_tariffs = {}
for tid, count in tariff_counts:
group = tariff_groups.get(tid, "unknown")
if group not in grouped_tariffs:
grouped_tariffs[group] = []
grouped_tariffs[group].append((tid, count))
tariff_stats_text = ""
duration_buckets = Counter()
now_ts = int(datetime.utcnow().timestamp() * 1000)
for key in no_tariff_keys:
duration_days = round((key["expiry_time"] - now_ts) / (1000 * 60 * 60 * 24))
if 25 <= duration_days <= 35:
bucket = "Без тарифа: 1 мес"
elif 80 <= duration_days <= 100:
bucket = "Без тарифа: 3 мес"
elif 170 <= duration_days <= 200:
bucket = "Без тарифа: 6 мес"
elif 350 <= duration_days <= 380:
bucket = "Без тарифа: 12 мес"
else:
bucket = "Без тарифа: прочее"
duration_buckets[bucket] += 1
bucket_order = {
"Без тарифа: 1 мес": 1,
"Без тарифа: 3 мес": 2,
"Без тарифа: 6 мес": 3,
"Без тарифа: 12 мес": 4,
"Без тарифа: прочее": 5
}
sorted_buckets = sorted(
duration_buckets.items(),
key=lambda x: bucket_order.get(x[0], 999)
)
for name, count in sorted_buckets:
tariff_stats_text += f"{name}: <b>{count}</b>\n"
for group, tariffs in grouped_tariffs.items():
tariff_stats_text += f"Тариф {group}\n"
sorted_tariffs = sorted(
tariffs,
key=lambda x: tariff_durations.get(x[0], 0)
)
for tid, count in sorted_tariffs:
name = tariff_names.get(tid, f"ID {tid}")
tariff_stats_text += f"{name}: <b>{count}</b>\n"
tariff_stats_text = (
"└ По тарифам и срокам:\n" + tariff_stats_text
if tariff_stats_text
else "└ Нет данных по тарифам\n"
)
total_referrals = await count_total_referrals(session)
total_payments_today = await sum_payments_since(session, today)
total_payments_yesterday = await sum_payments_between(
session, today - timedelta(days=1), today
)
total_payments_week = await sum_payments_since(
session, today - timedelta(days=today.weekday())
)
total_payments_month = await sum_payments_since(session, today.replace(day=1))
total_payments_last_month = await sum_payments_between(
session, last_month_start, today.replace(day=1)
)
total_payments_all_time = await sum_total_payments(session)
hot_leads_count = await count_hot_leads(session)
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
stats_message = (
f"📊 <b>Статистика проекта</b>\n\n"
f"👤 <b>Пользователи:</b>\n"
f"<blockquote>"
f"├ 🗓️ За день: <b>{registrations_today}</b>\n"
f"├ 🗓️ Вчера: <b>{registrations_yesterday}</b>\n"
f"├ 📆 За неделю: <b>{registrations_week}</b>\n"
f"├ 🗓️ За месяц: <b>{registrations_month}</b>\n"
f"├ 📅 За прошлый месяц: <b>{registrations_last_month}</b>\n"
f"└ 🌐 Всего: <b>{total_users}</b>\n"
f"</blockquote>\n"
f"💡 <b>Активность:</b>\n"
f"└ 👥 Сегодня были активны: <b>{users_updated_today}</b>\n\n"
f"🤝 <b>Реферальная система:</b>\n"
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
f"🔐 <b>Подписки:</b>\n"
f"<blockquote>"
f"├ 📦 Всего сгенерировано: <b>{total_keys}</b>\n"
f"├ ✅ Активных: <b>{active_keys}</b>\n"
f"├ ❌ Просроченных: <b>{expired_keys}</b>\n"
f"├ 🧪 Триальных: <b>{trial_keys_count}</b>\n"
f"{tariff_stats_text}"
f"</blockquote>\n"
f"💰 <b>Финансы:</b>\n"
f"<blockquote>"
f"├ 📅 За день: <b>{total_payments_today} ₽</b>\n"
f"├ 📆 Вчера: <b>{total_payments_yesterday} ₽</b>\n"
f"├ 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
f"├ 📆 За месяц: <b>{total_payments_month} ₽</b>\n"
f"├ 📆 Прошлый месяц: <b>{total_payments_last_month} ₽</b>\n"
f"└ 🏦 Всего: <b>{total_payments_all_time} ₽</b>\n"
f"</blockquote>\n"
f"🔥 <b>Горячие лиды: {hot_leads_count}</b>\n"
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
await callback_query.message.edit_text(
text=stats_message, reply_markup=build_stats_kb()
)
except TelegramBadRequest as e:
if "message is not modified" not in str(e):
logger.error(f"Error in user_stats_menu: {e}")
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
await callback_query.answer(
"Произошла ошибка при получении статистики", show_alert=True
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_users_csv"), IsAdminFilter()
)
async def handle_export_users_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_users_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт пользователей в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_payments_csv"), IsAdminFilter()
)
async def handle_export_payments_csv(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("stats")
try:
export = await export_payments_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт платежей в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте платежей: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_hot_leads_csv"), IsAdminFilter()
)
async def handle_export_hot_leads_csv(
callback_query: CallbackQuery, session: AsyncSession
):
kb = build_admin_back_kb("stats")
try:
export = await export_hot_leads_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт горящих лидов"
)
except Exception as e:
logger.error(f"Ошибка при экспорте горящих лидов: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
@router.callback_query(
AdminPanelCallback.filter(F.action == "stats_export_keys_csv"), IsAdminFilter()
)
async def handle_export_keys_csv(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("stats")
try:
export = await export_keys_csv(session)
await callback_query.message.answer_document(
document=export, caption="📅 Экспорт подписок в CSV"
)
except Exception as e:
logger.error(f"Ошибка при экспорте подписок: {e}")
await callback_query.message.edit_text(text=f"❗ Ошибка: {e}", reply_markup=kb)
async def send_daily_stats_report(session: AsyncSession):
try:
now_moscow = datetime.now(pytz.timezone("Europe/Moscow"))
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
today = (
pytz.timezone("Europe/Moscow")
.localize(datetime.combine((now_moscow - timedelta(days=1)).date(), datetime.min.time()))
.astimezone(pytz.UTC)
.replace(tzinfo=None)
)
registrations_today = await count_users_registered_since(session, today)
payments_today = await sum_payments_since(session, today)
active_keys = await count_active_keys(session)
text = (
"🗓️ <b>Сводка за день</b>\n\n"
f"👤 Новых пользователей: <b>{registrations_today}</b>\n"
f"💰 Оплачено: <b>{payments_today} ₽</b>\n"
f"🔐 Активных ключей: <b>{active_keys}</b>\n\n"
f"⏱️ <i>{update_time} МСК</i>"
)
for admin_id in ADMIN_ID:
await bot.send_message(admin_id, text)
except Exception as e:
logger.error(f"[Stats] Ошибка при отправке статистики: {e}")
@router.message(F.text == "Сводка", IsAdminFilter())
async def test_stats_command(message: Message, session: AsyncSession):
await send_daily_stats_report(session)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .tariffs_handler import router
+162
View File
@@ -0,0 +1,162 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
class AdminTariffCallback(CallbackData, prefix="tariff"):
action: str
def build_tariff_menu_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🆕 Новый тариф",
callback_data=AdminTariffCallback(action="create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📋 Мои тарифы",
callback_data=AdminTariffCallback(action="list").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminPanelCallback(action="admin").pack()
)
)
return builder.as_markup()
def build_cancel_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="❌ Отменить", callback_data="cancel_tariff_creation"
)
]
]
)
def build_tariff_groups_kb(groups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group in groups:
builder.button(
text=group,
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminPanelCallback(action="tariffs").pack()
)
)
return builder.as_markup()
def build_tariff_list_kb(tariffs: list[dict]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if tariffs:
group_code = tariffs[0]["group_code"]
else:
group_code = "unknown"
for t in tariffs:
title = f"{t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=title,
callback_data=AdminTariffCallback(action=f"view|{t['id']}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить тариф",
callback_data=AdminTariffCallback(action=f"create|{group_code}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminTariffCallback(action="list").pack()
)
)
return builder.as_markup()
def build_single_tariff_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=AdminTariffCallback(
action=f"edit|{tariff_id}"
).pack(),
),
InlineKeyboardButton(
text="🗑 Удалить",
callback_data=AdminTariffCallback(
action=f"delete|{tariff_id}"
).pack(),
),
],
[
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action="list").pack(),
)
],
]
)
def build_edit_tariff_fields_kb(tariff_id: int) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Название", callback_data=f"edit_field|{tariff_id}|name"
)
],
[
InlineKeyboardButton(
text="📅 Длительность",
callback_data=f"edit_field|{tariff_id}|duration_days",
)
],
[
InlineKeyboardButton(
text="💰 Цена", callback_data=f"edit_field|{tariff_id}|price_rub"
)
],
[
InlineKeyboardButton(
text="📦 Трафик (ГБ или 0)",
callback_data=f"edit_field|{tariff_id}|traffic_limit",
)
],
[
InlineKeyboardButton(
text="📱 Лимит устройств",
callback_data=f"edit_field|{tariff_id}|device_limit",
)
],
[
InlineKeyboardButton(
text="🔘 Активность", callback_data=f"toggle_active|{tariff_id}"
)
],
[InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminTariffCallback(action=f"view|{tariff_id}").pack()
)]
]
)
+506
View File
@@ -0,0 +1,506 @@
from datetime import datetime
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from sqlalchemy import delete, distinct, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_tariff
from database.models import Key, Server, Tariff
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminTariffCallback,
build_cancel_kb,
build_edit_tariff_fields_kb,
build_single_tariff_kb,
build_tariff_groups_kb,
build_tariff_list_kb,
build_tariff_menu_kb,
)
router = Router()
class TariffCreateState(StatesGroup):
group = State()
name = State()
duration = State()
price = State()
traffic = State()
confirm_more = State()
device_limit = State()
class TariffEditState(StatesGroup):
choosing_field = State()
editing_value = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "tariffs"), IsAdminFilter()
)
async def handle_tariff_menu(callback_query: CallbackQuery):
text = (
"<b>💸 Управление тарифами</b>\n\n"
"Вы можете выполнить следующие действия:\n\n"
"<b>🆕 Создать тариф</b>\n"
"• Установите длительность (в днях)\n"
"• Задайте цену (в рублях)\n"
"• Задайте лимит устройств (hwid/ip_limit)\n"
"• Укажите лимит трафика (в ГБ)\n\n"
"<b>📋 Редактировать тарифы</b>\n"
"• Просматривайте список текущих тарифов\n"
"• Изменяйте параметры или удаляйте при необходимости"
)
await callback_query.message.edit_text(
text=text, reply_markup=build_tariff_menu_kb()
)
@router.callback_query(
AdminTariffCallback.filter(F.action == "create"), IsAdminFilter()
)
async def start_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.group)
await callback.message.edit_text(
"📁 Введите <b>код группы</b>, в которую вы хотите добавить тариф.\n\n"
"Например: <code>basic</code>, <code>vip</code>, <code>business</code>\n\n"
"<b>Специальные группы:</b>\n"
"• <code>discounts</code> — тарифы со скидкой\n"
"• <code>discounts_max</code> — тарифы с максимальной скидкой\n"
"• <code>gifts</code> — тарифы для подарков",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.group, IsAdminFilter())
async def process_tariff_group(message: Message, state: FSMContext):
group_code = message.text.strip().lower()
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await message.answer(
"📝 Введите <b>название тарифа</b>\n\n"
"Например: <i>30 дней</i> или <i>1 месяц</i>\n\n"
"<i>Это название будет отображаться пользователю при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.name, IsAdminFilter())
async def process_tariff_name(message: Message, state: FSMContext):
await state.update_data(name=message.text.strip())
await state.set_state(TariffCreateState.duration)
await message.answer(
"📅 Введите <b>длительность тарифа в днях</b> (например: <i>30</i>):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.duration, IsAdminFilter())
async def process_tariff_duration(message: Message, state: FSMContext):
try:
days = int(message.text.strip())
if days <= 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректное количество дней (целое число больше 0):"
)
return
await state.update_data(duration_days=days)
await state.set_state(TariffCreateState.price)
await message.answer(
"💰 Введите <b>цену тарифа в рублях</b> (например: <i>150</i>)\n\n"
"<i>Будет показано клиенту при выборе тарифа</i>",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.price, IsAdminFilter())
async def process_tariff_price(message: Message, state: FSMContext):
try:
price = int(message.text.strip())
if price < 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректную цену (целое число 0 или больше):")
return
await state.update_data(price_rub=price)
await state.set_state(TariffCreateState.traffic)
await message.answer(
"📦 Введите <b>лимит трафика в ГБ</b> (например: <i>100</i>, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.traffic, IsAdminFilter())
async def process_tariff_traffic(message: Message, state: FSMContext):
try:
traffic = int(message.text.strip())
if traffic < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит трафика (целое число 0 или больше):"
)
return
await state.update_data(traffic_limit=traffic if traffic > 0 else None)
await state.set_state(TariffCreateState.device_limit)
await message.answer(
"📱 Введите <b>лимит устройств (HWID)</b> для тарифа (например: <i>3</i>, 0 — безлимит):",
reply_markup=build_cancel_kb(),
)
@router.message(TariffCreateState.device_limit, IsAdminFilter())
async def process_tariff_device_limit(
message: Message, state: FSMContext, session: AsyncSession
):
try:
device_limit = int(message.text.strip())
if device_limit < 0:
raise ValueError
except ValueError:
await message.answer(
"❌ Введите корректный лимит устройств (целое число 0 или больше):"
)
return
data = await state.get_data()
new_tariff = await create_tariff(
session,
{
"name": data["name"],
"group_code": data["group_code"],
"duration_days": data["duration_days"],
"price_rub": data["price_rub"],
"traffic_limit": data["traffic_limit"],
"device_limit": device_limit if device_limit > 0 else None,
},
)
await state.set_state(TariffCreateState.confirm_more)
await message.answer(
f"✅ Тариф <b>{new_tariff.name}</b> добавлен в группу <code>{data['group_code']}</code>.\n\n"
"➕ Хотите добавить ещё один тариф в эту группу?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="✅ Да", callback_data="add_more_tariff"),
InlineKeyboardButton(
text="❌ Нет", callback_data="done_tariff_group"
),
]
]
),
)
@router.callback_query(F.data == "add_more_tariff", IsAdminFilter())
async def handle_add_more_tariff(callback: CallbackQuery, state: FSMContext):
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
"📝 Введите <b>название следующего тарифа</b>:", reply_markup=build_cancel_kb()
)
@router.callback_query(F.data == "done_tariff_group", IsAdminFilter())
async def handle_done_tariff_group(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(
"✅ Группа тарифов успешно завершена.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(F.data == "cancel_tariff_creation", IsAdminFilter())
async def cancel_tariff_creation(callback: CallbackQuery, state: FSMContext):
await state.clear()
await callback.message.edit_text(
"❌ Создание тарифа отменено.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(AdminTariffCallback.filter(F.action == "list"), IsAdminFilter())
async def show_tariff_groups(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(
select(distinct(Tariff.group_code))
.where(Tariff.group_code.isnot(None))
.order_by(Tariff.group_code)
)
groups = [row[0] for row in result.fetchall()]
if not groups:
await callback.message.edit_text(
"❌ Нет сохранённых тарифов.", reply_markup=build_tariff_menu_kb()
)
return
special_groups = {
"discounts": "🔻 Скидки",
"discounts_max": "🔻 Макс. скидки",
"gifts": "🎁 Подарки",
}
text = "<b>📋 Выберите тарифную группу:</b>\n\n"
text += "<b>Специальные группы:</b>\n"
for code, label in special_groups.items():
status = "✅ создана" if code in groups else "❌ не создана"
text += f"{label} — <code>{code}</code> — <b>{status}</b>\n"
text += "\n"
await callback.message.edit_text(text, reply_markup=build_tariff_groups_kb(groups))
def tariff_to_dict(tariff: Tariff) -> dict:
return {
"id": tariff.id,
"name": tariff.name,
"price_rub": tariff.price_rub,
"group_code": tariff.group_code,
}
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("group|")), IsAdminFilter()
)
async def show_tariffs_in_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
group_code = callback_data.action.split("|", 1)[1]
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id)
)
tariffs = result.scalars().all()
if not tariffs:
await callback.message.edit_text("❌ В этой группе пока нет тарифов.")
return
tariff_dicts = [tariff_to_dict(t) for t in tariffs]
await callback.message.edit_text(
f"<b>📦 Тарифы группы: {group_code}</b>",
reply_markup=build_tariff_list_kb(tariff_dicts),
)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("view|")), IsAdminFilter()
)
async def view_tariff(
callback: CallbackQuery, callback_data: AdminTariffCallback, session: AsyncSession
):
tariff_id = int(callback_data.action.split("|", 1)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("delete|")), IsAdminFilter()
)
async def confirm_tariff_deletion(
callback: CallbackQuery, callback_data: AdminTariffCallback
):
tariff_id = int(callback_data.action.split("|", 1)[1])
await callback.message.edit_text(
"⚠️ Вы уверены, что хотите <b>удалить</b> этот тариф?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="✅ Да", callback_data=f"confirm_delete_tariff|{tariff_id}"
),
InlineKeyboardButton(
text="❌ Отмена", callback_data=f"view|{tariff_id}"
),
]
]
),
)
@router.callback_query(F.data.startswith("confirm_delete_tariff|"), IsAdminFilter())
async def delete_tariff(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|", 1)[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
group_code = tariff.group_code
await session.execute(
update(Key).where(Key.tariff_id == tariff_id).values(tariff_id=None)
)
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code)
)
remaining_tariffs = result.scalars().all()
if not remaining_tariffs:
await session.execute(
update(Server)
.where(Server.tariff_group == group_code)
.values(tariff_group=None)
)
await session.commit()
await callback.message.edit_text(
"🗑 Тариф успешно удалён.", reply_markup=build_tariff_menu_kb()
)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("edit|")), IsAdminFilter()
)
async def start_edit_tariff(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
tariff_id = int(callback_data.action.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(TariffEditState.choosing_field)
await callback.message.edit_text(
"<b>✏️ Что вы хотите изменить?</b>",
reply_markup=build_edit_tariff_fields_kb(tariff_id),
)
@router.callback_query(F.data.startswith("edit_field|"), IsAdminFilter())
async def ask_new_value(callback: CallbackQuery, state: FSMContext):
_, _tariff_id, field = callback.data.split("|")
await state.update_data(field=field)
await state.set_state(TariffEditState.editing_value)
field_names = {
"name": "название тарифа",
"duration_days": "длительность в днях",
"price_rub": "цену в рублях",
"traffic_limit": "лимит трафика в ГБ (0 — безлимит)",
"device_limit": "лимит устройств (0 — безлимит)",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_kb(),
)
@router.message(TariffEditState.editing_value, IsAdminFilter())
async def apply_edit(message: Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
tariff_id = data["tariff_id"]
field = data["field"]
value = message.text.strip()
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await message.answer("❌ Тариф не найден.")
await state.clear()
return
if field in ["duration_days", "price_rub", "traffic_limit", "device_limit"]:
try:
num = int(value)
if num < 0:
raise ValueError
if field in ["traffic_limit", "device_limit"]:
value = num if num > 0 else None
else:
value = num
except ValueError:
await message.answer("❌ Введите корректное число.")
return
setattr(tariff, field, value)
tariff.updated_at = datetime.utcnow()
await session.commit()
await state.clear()
text, markup = render_tariff_card(tariff)
await message.answer(text=text, reply_markup=markup)
@router.callback_query(F.data.startswith("toggle_active|"), IsAdminFilter())
async def toggle_tariff_status(callback: CallbackQuery, session: AsyncSession):
tariff_id = int(callback.data.split("|")[1])
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if not tariff:
await callback.message.edit_text("❌ Тариф не найден.")
return
tariff.is_active = not tariff.is_active
await session.commit()
text, markup = render_tariff_card(tariff)
await callback.message.edit_text(text=text, reply_markup=markup)
@router.callback_query(
AdminTariffCallback.filter(F.action.startswith("create|")), IsAdminFilter()
)
async def start_tariff_creation_existing_group(
callback: CallbackQuery, callback_data: AdminTariffCallback, state: FSMContext
):
group_code = callback_data.action.split("|", 1)[1]
await state.update_data(group_code=group_code)
await state.set_state(TariffCreateState.name)
await callback.message.edit_text(
f"📦 Добавление нового тарифа в группу <code>{group_code}</code>\n\n📝 Введите <b>название тарифа</b>:",
reply_markup=build_cancel_kb(),
)
def render_tariff_card(tariff: Tariff) -> tuple[str, InlineKeyboardMarkup]:
traffic_text = (
f"{tariff.traffic_limit} ГБ" if tariff.traffic_limit else "Безлимит"
)
device_text = (
f"{tariff.device_limit}" if tariff.device_limit is not None else "Безлимит"
)
text = (
f"<b>📄 Тариф: {tariff.name}</b>\n\n"
f"📁 Группа: <code>{tariff.group_code}</code>\n"
f"📅 Длительность: <b>{tariff.duration_days} дней</b>\n"
f"💰 Стоимость: <b>{tariff.price_rub}₽</b>\n"
f"📦 Трафик: <b>{traffic_text}</b>\n"
f"📱 Устройств: <b>{device_text}</b>\n"
f"{'✅ Активен' if tariff.is_active else '⛔ Отключен'}"
)
return text, build_single_tariff_kb(tariff.id)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .users_handler import router
+395
View File
@@ -0,0 +1,395 @@
from datetime import datetime, timezone
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from config import HWID_RESET_BUTTON
from database import get_clusters
from database.models import Key, Server, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days
from ..panel.keyboard import build_admin_back_btn
class AdminUserEditorCallback(CallbackData, prefix="admin_users"):
action: str
tg_id: int
data: str | int | None = None
edit: bool = False
class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
action: str
tg_id: int
data: str
month: int | None = None
edit: bool = False
def build_user_edit_kb(
tg_id: int, key_records: list, is_banned: bool = False
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
builder.button(
text=" Создать ключ",
callback_data=AdminUserEditorCallback(
action="users_create_key", tg_id=tg_id
).pack(),
)
for record in key_records:
email = record.email
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
days = (expiry - current_time).days
builder.button(
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=str(email)
).pack(),
)
builder.button(
text="✉️ Сообщение",
callback_data=AdminUserEditorCallback(
action="users_send_message", tg_id=tg_id
).pack(),
)
builder.button(
text="💸 Изменить баланс",
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
)
builder.button(
text="🤝 Выгрузить рефералов",
callback_data=AdminUserEditorCallback(
action="users_export_referrals", tg_id=tg_id
).pack(),
)
builder.button(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(
action="users_trial_restore", tg_id=tg_id
).pack(),
)
builder.button(
text="❌ Удалить клиента",
callback_data=AdminUserEditorCallback(
action="users_delete_user", tg_id=tg_id
).pack(),
)
builder.button(
text="✅ Разблокировать" if is_banned else "🚫 Заблокировать",
callback_data=AdminUserEditorCallback(
action="users_unban" if is_banned else "users_ban", tg_id=tg_id
).pack(),
)
builder.row(build_editor_btn("🔄 Обновить данные", tg_id, edit=True))
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_balance_edit", tg_id=tg_id
).pack(),
)
return builder.as_markup()
async def build_users_balance_kb(
session: AsyncSession, tg_id: int
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for amount in [100, 250, 500, 1000]:
builder.row(
InlineKeyboardButton(
text=f"+ {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=amount
).pack(),
),
InlineKeyboardButton(
text=f"- {amount}",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id, data=-amount
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Добавить",
callback_data=AdminUserEditorCallback(
action="users_balance_add", tg_id=tg_id
).pack(),
),
InlineKeyboardButton(
text="💵 Вычесть",
callback_data=AdminUserEditorCallback(
action="users_balance_take", tg_id=tg_id
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(
action="users_balance_set", tg_id=tg_id
).pack(),
)
)
builder.row(build_editor_back_btn(tg_id, True))
return builder.as_markup()
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email, edit=True
).pack(),
)
return builder.as_markup()
async def build_users_key_expiry_kb(
session: AsyncSession, tg_id: int, email: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
result = await session.execute(select(Key.server_id, Key.tariff_id).where(Key.email == email))
row = result.first()
server_id, tariff_id = (row if row else (None, None))
if tariff_id:
result = await session.execute(select(Tariff.group_code).where(Tariff.id == tariff_id))
row = result.first()
if row and row[0]:
group_code = row[0]
result = await session.execute(
select(Tariff)
.where(Tariff.group_code == group_code, Tariff.is_active.is_(True))
)
tariffs = result.scalars().all()
unique_durations = set()
for tariff in tariffs:
days = tariff.duration_days
if days < 1 or days in unique_durations:
continue
unique_durations.add(days)
label = format_days(days)
builder.row(
InlineKeyboardButton(
text=f"+ {label}",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=days
).pack(),
),
InlineKeyboardButton(
text=f"- {label}",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email, month=-days
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить дни",
callback_data=AdminUserKeyEditorCallback(
action="add", tg_id=tg_id, data=email
).pack(),
),
InlineKeyboardButton(
text="⏳ Вычесть дни",
callback_data=AdminUserKeyEditorCallback(
action="take", tg_id=tg_id, data=email
).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(
action="set", tg_id=tg_id, data=email
).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
)
)
return builder.as_markup()
def build_user_delete_kb(tg_id: int):
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Да, удалить!",
callback_data=AdminUserEditorCallback(
action="users_delete_user_confirm", tg_id=tg_id
).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(1)
return builder.as_markup()
def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK,
callback_data=AdminUserEditorCallback(
action="users_key_edit", tg_id=tg_id, data=email
).pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="⏳ Время истечения",
callback_data=AdminUserEditorCallback(
action="users_expiry_edit", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="🔄 Перевыпустить",
callback_data=AdminUserEditorCallback(
action="users_update_key", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="📦 Тариф",
callback_data=AdminUserEditorCallback(
action="users_renew", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(
action="users_delete_key", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="📊 Трафик",
callback_data=AdminUserEditorCallback(
action="users_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.button(
text="♻️ Сбросить трафик",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
).pack(),
)
if HWID_RESET_BUTTON:
builder.button(
text="💻 HWID",
callback_data=AdminUserEditorCallback(
action="users_hwid_menu", data=email, tg_id=key_details["tg_id"]
).pack(),
)
builder.row(build_editor_back_btn(key_details["tg_id"], True))
builder.adjust(1)
return builder.as_markup()
def build_hwid_menu_kb(email: str, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="♻️ Сбросить HWID",
callback_data=AdminUserEditorCallback(
action="users_hwid_reset", data=email, tg_id=tg_id
).pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminUserEditorCallback(
action="users_key_edit", data=email, tg_id=tg_id
).pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_key_delete_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=AdminUserEditorCallback(
action="users_delete_key_confirm", data=email, tg_id=tg_id
).pack(),
)
)
builder.row(build_editor_back_btn(tg_id))
builder.adjust(1)
return builder.as_markup()
def build_editor_kb(tg_id: int, edit: bool = False) -> InlineKeyboardMarkup:
return build_editor_singleton_kb(BACK, tg_id, edit)
def build_editor_singleton_kb(
text: str, tg_id: int, edit: bool = False
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_editor_btn(text, tg_id, edit))
return builder.as_markup()
def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButton:
return build_editor_btn(BACK, tg_id, edit)
def build_editor_btn(text: str, tg_id: int, edit: bool = False) -> InlineKeyboardButton:
return InlineKeyboardButton(
text=text,
callback_data=AdminUserEditorCallback(
action="users_editor", tg_id=tg_id, edit=edit
).pack(),
)
async def build_cluster_selection_kb(
session, tg_id: int, email: str, action: str
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
clusters = await get_clusters(session)
for cluster_id in clusters:
builder.button(
text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}"
)
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
builder.adjust(1)
return builder.as_markup()
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
# Основные кнопки меню:
MAIN_MENU = "👤 Личный кабинет"
BACK = "⬅️ Назад"
SUPPORT = "💬 Поддержка"
CHANNEL = "📢 Канал"
ABOUT_VPN = "💬 О сервисе"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = "➕ Добавить новую подписку"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💵 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
INSTRUCTIONS = "📘 Инструкции"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
MY_SUB = "🔐 Моя подписка"
# Меню Оплат и баланса
BALANCE_HISTORY = "📊 История пополнения"
PAYMENT = "💳 Пополнить баланс"
COUPON = "🎟️ Активировать купон"
COUPON_RESTART = "🎟️ Попробовать другой купон"
YOOKASSA = "💳 ЮКасса: быстрая оплата"
YOOMONEY = "💳 ЮМани: перевод по карте"
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
STARS = "⭐ Оплата Звездами"
ROBOKASSA = "⭐ RoboKassa"
DISCOUNT_TARIFF = "🔥 Получить скидку"
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
# Кнопки подписки на канал
SUB_CHANELL = "📢 Подписаться"
SUB_CHANELL_DONE = "✅ Я подписался"
# Подарки
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
GIFTS_MENU = "В меню подарков"
SHARE_GIFT = "🎁 Поделиться подарком"
GET_GIFT = "🎁 Получить подарок"
# Создание и подключение ключей
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
DOWNLOAD_MACOS_BUTTON = "🍏 Скачать macOS"
DOWNLOAD_PC_BUTTON = "💻 Скачать Windows"
IMPORT_IOS = "🍏 Подключить"
IMPORT_ANDROID = "🤖 Подключить"
PC_BUTTON = "💻 Компьютеры"
TV_BUTTON = "📺 Андроид TV"
CONNECT_PHONE = "📱 Подключить телефон"
CONNECT_DEVICE = "📲 Подключить устройство"
CONNECT_WINDOWS_BUTTON = "💻 Подключить"
CONNECT_MACOS_BUTTON = "🍏 Подключить"
ALIAS = "✏️"
UNFREEZE = "🟢 Разморозить подписку"
FREEZE = "🛑 Заморозить подписку"
RENEW = "⏳ Продлить"
RENEW_FULL = "⏳ Продлить подписку"
DELETE = "❌ Удалить"
CHANGE_LOCATION = "🌍 Сменить локацию"
QR = "📷 Показать QR-код"
IPHONE = "🍏 Айфон"
ANDROID = "🤖 Андроид"
PC = "💻 Компьютер"
PC_PC = "💻 Windows"
PC_MACOS = "🍏 macOS"
TV = "📺 Телевизор"
ROUTER = "📶 Роутер"
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "🔄 Продлить подписку"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
# Кнопки касс
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
STARS_BOT = "🤖 Бот для покупки звезд"
-6
View File
@@ -1,6 +0,0 @@
DOWNLOAD_IOS_BUTTON = "🍏 Скачать iOS"
DOWNLOAD_ANDROID_BUTTON = "🤖 Скачать Android"
IMPORT_IOS = "🍏 Подключить"
IMPORT_ANDROID = "🤖 Подключить"
PC_BUTTON = "💻 Компьютеры"
TV_BUTTON = "📺 Андроид TV"
-9
View File
@@ -1,9 +0,0 @@
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
PROFILE = "👤 Личный кабинет"
BACK = "В меню подарков"
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
SHARE_GIFT = "🎁 Поделиться подарком"
APPLY = "✅ Подтвердить"
CANCEL = "❌ Отмена"
NO_GIFTS = "У вас нет подарков."
-3
View File
@@ -1,3 +0,0 @@
RENEW_KEY = "🔄 Продлить подписку"
ADD_KEY = " Добавить подписку"
PROFILE = "👤 Личный кабинет"
-10
View File
@@ -1,10 +0,0 @@
ADD_SUB = " Подписка"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
BALANCE_HISTORY = "📊 История пополнения"
PAYMENT = "💳 Пополнить баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
MAIN_MENU = "⬅️ Главное меню"
-12
View File
@@ -1,12 +0,0 @@
PAY = "Пополнить"
PAY_2 = "Оплатить"
BACK = "⬅️ Назад"
CUSTOM_SUM = "💰 Ввести свою сумму"
PROFILE = "👤 Личный кабинет"
KEY_CREATION_PAYMENT_MESSAGE = (
"Вы выбрали пополнение на {amount} рублей для создания нового ключа. Перейдите по ссылке для оплаты:"
)
KEY_RENEWAL_PAYMENT_MESSAGE = (
"Вы выбрали пополнение на {amount} рублей для продления ключа. Перейдите по ссылке для оплаты:"
)
DEFAULT_PAYMENT_MESSAGE = "Вы выбрали пополнение на {amount} рублей. Перейдите по ссылке для оплаты:"
+62 -33
View File
@@ -7,7 +7,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CAPTCHA_EMOJIS
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
@@ -17,17 +17,45 @@ router = Router()
async def generate_captcha(message: Message, state: FSMContext):
correct_emoji, correct_text = secrets.choice(list(CAPTCHA_EMOJIS.items()))
wrong_emojis = random.sample([e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3)
wrong_emojis = random.sample(
[e for e in CAPTCHA_EMOJIS.keys() if e != correct_emoji], 3
)
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
await state.update_data(
correct_emoji=correct_emoji,
message_id=message.message_id,
chat_id=message.chat.id,
original_text=message.text,
)
state_data = await state.get_data()
if "user_data" not in state_data:
from_user = message.from_user
if not from_user:
logger.warning(
"[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data"
)
return None
await state.update_data(
user_data={
"tg_id": from_user.id,
"username": getattr(from_user, "username", None),
"first_name": getattr(from_user, "first_name", None),
"last_name": getattr(from_user, "last_name", None),
"language_code": getattr(from_user, "language_code", None),
"is_bot": getattr(from_user, "is_bot", False),
}
)
update_data = {
"correct_emoji": correct_emoji,
"message_id": message.message_id,
"chat_id": message.chat.id,
}
state_data = await state.get_data()
if "original_text" not in state_data:
update_data["original_text"] = message.text
await state.update_data(**update_data)
builder = InlineKeyboardBuilder()
for emoji in all_emojis:
@@ -35,41 +63,42 @@ async def generate_captcha(message: Message, state: FSMContext):
builder.adjust(2, 2)
return {
"text": f"🔒 Для подтверждения, что вы не робот, выберите кнопку с {correct_text}",
"text": CAPTCHA_PROMPT_MSG.format(correct_text=correct_text),
"markup": builder.as_markup(),
}
@router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import start_command
async def check_captcha(
callback: CallbackQuery, state: FSMContext, session: Any, admin: bool
):
from handlers.start import process_start_logic
selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji")
message_id = state_data.get("message_id")
chat_id = state_data.get("chat_id")
original_text = state_data.get("original_text")
user_data = state_data.get("user_data")
if not message_id or not chat_id:
target_message = callback.message
else:
try:
target_message = await callback.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=callback.message.text,
)
except Exception:
target_message = callback.message
target_message = callback.message
if selected_emoji == correct_emoji:
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
await start_command(target_message, state, session, admin, captcha=False)
else:
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
await process_start_logic(
message=target_message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data,
)
else:
logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
if captcha:
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
)
+237 -22
View File
@@ -1,24 +1,45 @@
import html
from datetime import datetime
from typing import Any
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, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import ADMIN_ID
from database import (
add_user,
check_coupon_usage,
check_user_exists,
create_coupon_usage,
get_coupon_by_code,
get_keys,
update_balance,
update_coupon_usage_count,
update_key_expiry,
add_payment,
get_tariff_by_id,
)
from .utils import edit_or_send_message
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_ALREADY_USED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from handlers.utils import edit_or_send_message, format_days
from logger import logger
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
router = Router()
@@ -26,9 +47,11 @@ router = Router()
@router.callback_query(F.data == "activate_coupon")
@router.message(F.text == "/activate_coupon")
async def handle_activate_coupon(callback_query_or_message: Message | CallbackQuery, state: FSMContext):
async def handle_activate_coupon(
callback_query_or_message: Message | CallbackQuery, state: FSMContext
):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
@@ -37,8 +60,7 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
await edit_or_send_message(
target_message=target_message,
text="<b>🎫 Введите код купона:</b>\n\n"
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
text=COUPON_INPUT_PROMPT,
reply_markup=builder.as_markup(),
media_path=None,
)
@@ -48,30 +70,223 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
await activate_coupon(message, state, session, coupon_code=coupon_code)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await message.answer(activation_result, reply_markup=builder.as_markup())
async def activate_coupon(
message: Message,
state: FSMContext,
session: AsyncSession,
coupon_code: str,
admin: bool = False,
user_data: dict | None = None,
):
logger.info(f"Активация купона: {coupon_code}")
coupon = await get_coupon_by_code(session, coupon_code)
if not coupon:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input")
)
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
if coupon.usage_count >= coupon.usage_limit or coupon.is_used:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
usage = await check_coupon_usage(session, coupon.id, user_id)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
user_exists = await check_user_exists(session, user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
session=session,
tg_id=user.id,
username=getattr(user, "username", None),
first_name=getattr(user, "first_name", None),
last_name=getattr(user, "last_name", None),
language_code=getattr(user, "language_code", None),
is_bot=getattr(user, "is_bot", False),
)
if coupon.amount > 0:
try:
await update_balance(session, user_id, coupon.amount)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, user_id)
await add_payment(
session,
tg_id=user_id,
amount=coupon.amount,
payment_system="coupon"
)
await message.answer(
f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей."
)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
if coupon.days:
try:
keys = await get_keys(session, user_id)
active_keys = [k for k in keys if not k.is_frozen]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
await state.clear()
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = (
"<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
)
for key in active_keys:
key_display = html.escape((key.alias or key.email).strip())
expiry_date = datetime.fromtimestamp(
key.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|{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(CouponActivationState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon.id, user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
await state.clear()
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
coupon_record = await get_coupon_by_code(coupon_code, session)
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession,
admin: bool = False,
):
from database.models import Coupon, Key
if not coupon_record:
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
tg_id = callback_query.from_user.id
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
try:
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
coupon = result.scalar_one_or_none()
if not coupon or coupon.usage_count >= coupon.usage_limit:
await callback_query.message.edit_text(
"❌ Купон недействителен или лимит исчерпан."
)
await state.clear()
return
if usage_exists:
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
usage = await check_coupon_usage(session, coupon.id, tg_id)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
coupon_amount = coupon_record["amount"]
result = await session.execute(
select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id)
)
key = result.scalar_one_or_none()
if not key or key.is_frozen:
await callback_query.message.edit_text(
"❌ Выбранная подписка не найдена или заморожена."
)
await state.clear()
return
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key.expiry_time
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
await update_balance(user_id, coupon_amount, session)
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
tariff = None
if key.tariff_id:
tariff = await get_tariff_by_id(session, key.tariff_id)
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else None
await renew_key_in_cluster(
cluster_id=key.server_id,
email=key.email,
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=total_gb,
hwid_device_limit=device_limit,
session=session
)
await update_key_expiry(session, client_id, new_expiry)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, tg_id)
alias = key.alias or key.email
expiry_date = datetime.fromtimestamp(
new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")
).strftime("%d.%m.%y, %H:%M")
await callback_query.message.answer(
f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon.days)}⏳ до {expiry_date}📆."
)
await process_callback_view_profile(
callback_query.message, state, admin, session
)
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,
session: AsyncSession = None,
):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin, session)
await state.clear()
@router.callback_query(F.data == "exit_coupon_input")
async def handle_exit_coupon_input(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession = None,
):
await state.clear()
is_admin = callback_query.from_user.id in ADMIN_ID
await process_callback_view_profile(
callback_query.message, state, admin=is_admin, session=session
)
+20 -7
View File
@@ -1,10 +1,17 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
LabeledPrice,
Message,
PreCheckoutQuery,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RUB_TO_XTR
from handlers.buttons import BACK, MAIN_MENU
from logger import logger
from .utils import edit_or_send_message
@@ -24,14 +31,18 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"))
builder.row(
InlineKeyboardButton(
text="🤖 Бот для покупки звезд", url="https://t.me/PremiumBot"
)
)
builder.row(
InlineKeyboardButton(
text="💰 Ввести сумму доната",
callback_data="enter_custom_donate_amount",
)
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = (
"🌟 Поддержите наш проект! 💪\n\n"
@@ -49,7 +60,7 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "enter_custom_donate_amount")
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
text = "💸 Введите сумму доната в рублях:"
await edit_or_send_message(
@@ -66,7 +77,9 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
if message.text.isdigit():
amount = int(message.text)
if amount // RUB_TO_XTR <= 0:
await message.answer(f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:")
await message.answer(
f"Сумма доната должна быть больше {RUB_TO_XTR}. Пожалуйста, введите сумму еще раз:"
)
return
await state.update_data(amount=amount)
@@ -75,7 +88,7 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
try:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
await message.answer_invoice(
title=f"Донат проекту {amount} рублей",
@@ -103,7 +116,7 @@ async def on_successful_donate(message: Message, state: FSMContext):
try:
amount = float(message.successful_payment.invoice_payload.split("_")[0])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await message.answer(
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
reply_markup=builder.as_markup(),
+21
View File
@@ -0,0 +1,21 @@
from aiogram import F, Router
from aiogram.types import InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import SUPPORT_CHAT_URL
from handlers.buttons import MAIN_MENU, SUPPORT
from handlers.texts import FALLBACK_MESSAGE
fallback_router = Router()
@fallback_router.message(F.text)
async def handle_unhandled_messages(message: Message):
keyboard = InlineKeyboardBuilder()
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await message.answer(
FALLBACK_MESSAGE,
reply_markup=keyboard.as_markup(),
)
+93 -29
View File
@@ -1,18 +1,35 @@
import os
from typing import Any
from aiogram import F, Router, types
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CONNECT_MACOS, CONNECT_WINDOWS, SUPPORT_CHAT_URL
from config import (
CONNECT_MACOS,
CONNECT_WINDOWS,
DOWNLOAD_MACOS,
DOWNLOAD_PC,
SUPPORT_CHAT_URL,
)
from database import get_key_details
from handlers.buttons import (
BACK,
CONNECT_MACOS_BUTTON,
CONNECT_WINDOWS_BUTTON,
DOWNLOAD_MACOS_BUTTON,
DOWNLOAD_PC_BUTTON,
MAIN_MENU,
PC_MACOS,
PC_PC,
SUPPORT,
TV_CONTINUE,
TV_INSTRUCTIONS,
)
from handlers.texts import (
CHOOSE_DEVICE_TEXT,
CONNECT_TV_TEXT,
INSTRUCTION_MACOS,
INSTRUCTION_PC,
INSTRUCTIONS,
KEY_MESSAGE,
@@ -30,8 +47,8 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
image_path = os.path.join("img", "instructions.jpg")
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
@@ -49,10 +66,10 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
if not record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
@@ -61,16 +78,38 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
)
return
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=PC_PC, callback_data=f"windows_menu|{key_name}")
)
builder.row(
InlineKeyboardButton(text=PC_MACOS, callback_data=f"macos_menu|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("windows_menu|"))
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"))
builder.row(InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}"))
builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=DOWNLOAD_PC_BUTTON, url=DOWNLOAD_PC))
builder.row(
InlineKeyboardButton(text=CONNECT_WINDOWS_BUTTON, url=f"{CONNECT_WINDOWS}{key}")
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -80,15 +119,40 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
)
@router.callback_query(F.data.startswith("macos_menu|"))
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_MACOS_BUTTON, url=DOWNLOAD_MACOS))
builder.row(
InlineKeyboardButton(text=CONNECT_MACOS_BUTTON, url=f"{CONNECT_MACOS}{key}")
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_pc|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=instruction_message,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_tv|"))
async def process_connect_tv(callback_query: CallbackQuery):
async def process_connect_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="▶ Продолжить", callback_data=f"continue_tv|{key_name}"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(
InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
@@ -102,17 +166,17 @@ async def process_connect_tv(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
tg_id = callback_query.from_user.id
record = await get_key_details(key_name, session)
subscription_link = record["key"]
record = await get_key_details(session, key_name)
subscription_link = record.get("key") or record.get("remnawave_link")
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message, text=message_text, reply_markup=builder.as_markup(), media_path=None
)
target_message=callback_query.message,
text=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
+10 -2
View File
@@ -2,12 +2,20 @@ __all__ = ("router",)
from aiogram import Router
from .key_management import router as management_router
from .key_connect import router as connect_router
from .key_freeze import router as freeze_router
from .key_mode import router as key_mode_router
from .key_renew import router as renew_router
from .key_view import router as view_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
keys_router,
management_router,
view_router,
renew_router,
freeze_router,
connect_router,
key_mode_router,
)
+262
View File
@@ -0,0 +1,262 @@
import os
from io import BytesIO
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
INSTRUCTIONS_BUTTON,
)
from database.models import Key
from handlers.buttons import (
ANDROID,
BACK,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PC,
TV,
)
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
IOS_DESCRIPTION_TEMPLATE,
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}")
)
builder.row(
InlineKeyboardButton(
text=ANDROID, callback_data=f"connect_android|{key_name}"
)
)
builder.row(
InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}")
)
builder.row(
InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}")
)
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(
callback_query: CallbackQuery, session: AsyncSession
):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
row = result.scalar_one_or_none()
if not row:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = row
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(
callback_query: CallbackQuery, session: AsyncSession
):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(
callback_query: CallbackQuery, session: AsyncSession
):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID)
)
builder.row(
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}")
)
if INSTRUCTIONS_BUTTON:
builder.row(
InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions")
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession):
try:
key_name = callback_query.data.split("|")[1]
stmt = select(Key).where(Key.email == key_name)
result = await session.execute(stmt)
record = result.scalars().first()
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr_data = record.key or record.remnawave_link
if not qr_data:
await callback_query.message.answer(
"❌ У этой подписки отсутствует ссылка для подключения."
)
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record.email}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}")
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
+245
View File
@@ -0,0 +1,245 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import text
from database import get_key_details, mark_key_as_frozen, mark_key_as_unfrozen
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(
callback_query: CallbackQuery, session: Any
):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=True, session=session
)
if result["status"] != "success":
text_error = f"Произошла ошибка при включении подписки.\nДетали: {result.get('error') or result.get('results')}"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
callback_query.message, text_error, builder.as_markup()
)
return
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
logger.info(f"[Unfreeze Debug] expiry_time из БД: {leftover}")
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
await session.commit()
from database.servers import get_servers
servers = await get_servers(session)
cluster_servers = servers.get(cluster_id, [])
tariff = None
for srv in cluster_servers:
result = await session.execute(
text(
"""
SELECT t.*
FROM tariffs t
JOIN servers s ON s.tariff_group = t.group_code
WHERE s.server_name = :server_name
ORDER BY t.duration_days DESC
LIMIT 1
"""
),
{"server_name": srv["server_name"]},
)
row = result.mappings().first()
if row:
tariff = row
break
await session.commit()
if not tariff:
logger.info(
"[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения."
)
base_bytes = 15 * 1024
hwid_limit = 1
else:
base_bytes = int(tariff.get("traffic_limit") or 0)
hwid_limit = int(tariff.get("device_limit") or 0)
added_days = max(leftover / (1000 * 86400), 0.01)
total_gb = int((added_days / 30) * base_bytes)
logger.info(
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
session=session,
hwid_device_limit=hwid_limit,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(callback_query.message, text_ok, builder.as_markup())
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(
callback_query: CallbackQuery, session: Any
):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(
callback_query: CallbackQuery, session: Any
):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(
cluster_id, email, client_id, enable=False, session=session
)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await mark_key_as_frozen(session, record["tg_id"], client_id, time_left)
await session.commit()
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
-538
View File
@@ -1,538 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from client import delete_client
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_ANDROID,
CONNECT_IOS,
CONNECT_PHONE_BUTTON,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
)
from handlers.buttons.add_subscribe import (
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
PC_BUTTON,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import DISCOUNTS, key_message_success
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
waiting_for_key_name = "waiting_for_key_name"
viewing_profile = "viewing_profile"
waiting_for_message = "waiting_for_message"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
updated = await update_trial(tg_id, 1, session)
if updated:
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text="⏳ Пожалуйста, подождите, создаем вам подключение...",
reply_markup=None,
)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
else:
logger.error(f"Не удалось обновить статус триального периода для пользователя {tg_id}.")
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text="💳 Выберите тарифный план для создания нового ключа:",
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=f"💳 Недостаточно средств. Для продолжения необходимо пополнить баланс на {required_amount}₽.",
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text="⏳ Подождите, создаем вам подключение...",
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await create_key(tg_id, expiry_time, None, session, callback_query, None, plan_id)
await update_balance(tg_id, -plan_price, session)
async def create_key(
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(
f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов"
)
servers = await session.fetch(
"SELECT server_name FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
countries = [server["server_name"] for server in servers]
logger.info(f"[Country Selection] Список серверов: {countries}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in countries:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text="🌍 Пожалуйста, выберите страну для вашего ключа:",
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
least_loaded_cluster = await get_least_loaded_cluster()
tasks = [
asyncio.create_task(
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
await update_trial(tg_id, 1, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
else:
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
if target_message:
await edit_or_send_message(
target_message=target_message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
)
else:
await bot.send_message(
chat_id=tg_id,
text=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
servers = await session.fetch("SELECT server_name FROM servers")
countries = [row["server_name"] for row in servers]
logger.info(f"Доступные страны для смены локации: {countries}")
builder = InlineKeyboardBuilder()
for country in countries:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, None, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
key_name = old_key_name
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
old_client_id = old_key_details.get("client_id") if old_key_details else None
old_email = old_key_details.get("email") if old_key_details else None
old_server_id = old_key_details.get("server_id") if old_key_details else None
if old_client_id and old_email and old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
old_email,
old_client_id,
)
if not deletion_success:
raise ValueError(f"Не удалось удалить клиента с сервера {old_server_id}.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET client_id = $1, email = $2, expiry_time = $3, key = $4, server_id = $5
WHERE tg_id = $6 AND email = $7
""",
client_id,
email,
expiry_timestamp,
public_link,
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text="📱 Подключить телефон", callback_data=f"connect_phone|{key_name}"))
else:
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
await edit_or_send_message(
target_message=callback_query.message, text=key_message_text, reply_markup=builder.as_markup(), media_path=None
)
if state:
await state.clear()
+12
View File
@@ -0,0 +1,12 @@
__all__ = ("router",)
from aiogram import Router
from .key_cluster_mode import router as cluster_router
from .key_country_mode import router as country_router
from .key_create import router as create_router
from .key_discount_mode import router as discount_router
router = Router(name="key_mode_router")
router.include_routers(create_router, cluster_router, country_router, discount_router)
+234
View File
@@ -0,0 +1,234 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import (
CallbackQuery,
FSInputFile,
InlineKeyboardButton,
Message,
WebAppInfo,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL, TRIAL_CONFIG
from database import (
get_key_details,
get_tariff_by_id,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import (
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
MY_SUB,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import key_message_success
from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
format_days,
format_months,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_cluster_mode(
tg_id: int,
expiry_time: datetime,
state,
session,
message_or_query: Message | CallbackQuery | None = None,
plan: int = None,
):
target_message = None
safe_to_edit = False
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
while True:
key_name = generate_random_email()
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
device_limit = 0
traffic_limit_gb = None
if is_trial:
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
elif plan:
tariff = await get_tariff_by_id(session, plan)
if tariff:
if tariff.get("device_limit") is not None:
device_limit = int(tariff["device_limit"])
if tariff.get("traffic_limit") is not None:
traffic_limit_gb = int(tariff["traffic_limit"])
least_loaded_cluster = await get_least_loaded_cluster(session)
await create_key_on_cluster(
cluster_id=least_loaded_cluster,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
plan=plan,
session=session,
hwid_limit=device_limit,
traffic_limit_bytes=traffic_limit_gb,
is_trial=is_trial,
)
logger.info(
f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}"
)
key_record = await get_key_details(session, email)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
if is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(session, tg_id, 1)
if data.get("tariff_id"):
tariff = await get_tariff_by_id(session, data["tariff_id"])
if tariff:
await update_balance(session, tg_id, -tariff["price_rub"])
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = (
"❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
elif CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(
text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"
)
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
tariff_info = None
if plan:
tariff_info = await get_tariff_by_id(session, plan)
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
)
else:
tariff_duration = ""
if tariff_info and tariff_info.get("duration_days", 0) > 0:
duration_days = tariff_info["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
)
default_media_path = "img/pic.jpg"
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
+655
View File
@@ -0,0 +1,655 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_user,
check_server_name_by_cluster,
check_user_exists,
get_key_details,
get_trial,
update_balance,
update_trial,
)
from database.models import Key, Server, Tariff
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
MY_SUB
)
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
format_days,
format_months,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: AsyncSession,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
target_message = None
safe_to_edit = False
if state and plan:
await state.update_data(tariff_id=plan)
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
least_loaded_cluster = await get_least_loaded_cluster(session)
if not least_loaded_cluster:
logger.error("❌ Не удалось определить наименее загруженный кластер")
text = "❌ Нет доступных кластеров для создания ключа."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
result = await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
).where(Server.cluster_name == least_loaded_cluster)
)
servers = result.mappings().all()
if not servers:
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
available_servers = []
tasks = [
asyncio.create_task(check_server_availability(server, session))
for server in servers
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.warning(
f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}"
)
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message, text=text, reply_markup=None
)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
logger.info(
f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}"
)
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for server_name in available_servers:
if old_key_name:
callback_data = f"select_country|{server_name}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{server_name}|{ts}"
builder.row(InlineKeyboardButton(text=server_name, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(session, old_key_name)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
await callback_query.answer(
"❌ Кластер для текущего сервера не найден", show_alert=True
)
return
cluster_name = cluster_info["cluster_name"]
servers = (
(
await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
)
.mappings()
.all()
)
if not servers:
await callback_query.answer(
"❌ Доступных серверов в кластере не найдено", show_alert=True
)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info, session))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer(
"❌ Нет доступных серверов для смены локации", show_alert=True
)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}")
)
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(
f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}"
)
await callback_query.answer(
"❌ Ошибка смены локации. Попробуйте снова.", show_alert=True
)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer(
"❌ Некорректное время истечения. Попробуйте снова."
)
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: AsyncSession,
callback_query: CallbackQuery,
old_key_name: str = None,
tariff_id: int | None = None,
):
from_user = callback_query.from_user
if not await check_user_exists(session, tg_id):
await add_user(
session=session,
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(session, old_key_name)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
tariff_id = old_key_details.get("tariff_id") or tariff_id
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
traffic_limit_bytes = None
device_limit = 0
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
if is_trial:
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
device_limit = TRIAL_CONFIG.get("hwid_limit", 1)
elif data.get("tariff_id") or tariff_id:
tariff_id = data.get("tariff_id") or tariff_id
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if tariff:
if tariff.traffic_limit is not None:
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
if tariff.device_limit is not None:
device_limit = int(tariff.device_limit)
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
try:
result = await session.execute(
select(Server).where(Server.server_name == selected_country)
)
server_info = result.scalar_one_or_none()
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден")
panel_type = server_info.panel_type.lower()
cluster_info = await check_server_name_by_cluster(session, server_info.server_name)
if not cluster_info:
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if old_key_name:
old_server_id = old_key_details["server_id"]
if old_server_id:
result = await session.execute(select(Server).where(Server.server_name == old_server_id))
old_server_info = result.scalar_one_or_none()
if old_server_info:
try:
if old_server_info.panel_type.lower() == "3x-ui":
xui = await get_xui_instance(old_server_info.api_url)
await delete_client(xui, old_server_info.inbound_id, email, client_id)
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(key=None)
)
elif old_server_info.panel_type.lower() == "remnawave":
remna = RemnawaveAPI(old_server_info.api_url)
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(remnawave_link=None)
)
except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
if panel_type == "remnawave" or is_full_remnawave:
remna = RemnawaveAPI(server_info.api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info.inbound_id],
}
if traffic_limit_bytes:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit:
user_data["hwidDeviceLimit"] = device_limit
result = await remna.create_user(user_data)
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
if old_key_name:
await session.execute(
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(client_id=client_id)
)
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info={
"api_url": server_info.api_url,
"inbound_id": server_info.inbound_id,
"server_name": server_info.server_name,
"panel_type": server_info.panel_type,
},
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
session=session,
plan=tariff_id,
is_trial=is_trial,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
if old_key_name:
update_data = {"server_id": selected_country}
if panel_type == "3x-ui":
update_data["key"] = public_link
elif panel_type == "remnawave":
update_data["remnawave_link"] = remnawave_link
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.email == email).values(**update_data))
else:
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_timestamp,
key=public_link,
remnawave_link=remnawave_link,
server_id=selected_country,
tariff_id=tariff_id,
)
session.add(new_key)
if is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(session, tg_id, 1)
if tariff_id:
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
row = result.scalar_one_or_none()
if row:
await update_balance(session, tg_id, -row)
await session.commit()
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
return
builder = InlineKeyboardBuilder()
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if (panel_type == "remnawave" or is_full_remnawave) and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}")
)
elif CONNECT_PHONE_BUTTON:
builder.row(
InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}")
)
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")
)
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
tariff_info = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff_info = result.scalar_one_or_none()
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 1)
)
else:
tariff_duration = ""
if tariff_info and tariff_info.get("duration_days", 0) > 0:
duration_days = tariff_info["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0
)
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path="img/pic.jpg",
)
if state:
await state.clear()
async def check_server_availability(server_info: dict, session: AsyncSession) -> bool:
server_name = server_info.get("server_name", "unknown")
panel_type = server_info.get("panel_type", "3x-ui").lower()
enabled = server_info.get("enabled", True)
max_keys = server_info.get("max_keys")
if not enabled:
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
return False
try:
if max_keys is not None:
result = await session.execute(
select(func.count())
.select_from(Key)
.where(Key.server_id == server_name)
)
key_count = result.scalar()
if key_count >= max_keys:
logger.info(
f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}."
)
return False
except SQLAlchemyError as e:
logger.warning(
f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}"
)
return False
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(
remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0
)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
else:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
return True
except TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
return False
+243
View File
@@ -0,0 +1,243 @@
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from math import ceil
from config import (
NOTIFY_EXTRA_DAYS,
TRIAL_CONFIG,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_tariff_by_id,
get_tariffs_for_cluster,
get_trial,
)
from handlers.buttons import MAIN_MENU, PAYMENT
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
CREATING_CONNECTION_MSG,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
from logger import logger
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(
callback_query: CallbackQuery, state: FSMContext, session: Any
):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
base_days = TRIAL_CONFIG["duration_days"]
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
total_days = base_days + extra_days
expiry_time = current_time + timedelta(days=total_days)
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
cluster_name = await get_least_loaded_cluster(session)
tariffs = await get_tariffs_for_cluster(session, cluster_name)
if not tariffs:
await edit_or_send_message(
target_message=(
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
),
text="❌ Нет доступных тарифов для выбора.",
reply_markup=None,
)
return
builder = InlineKeyboardBuilder()
for t in tariffs:
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
target_message = (
message_or_query.message
if isinstance(message_or_query, CallbackQuery)
else message_or_query
)
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(
callback_query: CallbackQuery, session: Any, state: FSMContext
):
tg_id = callback_query.from_user.id
tariff_id = int(callback_query.data.split("|")[1])
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
return
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(session, tg_id)
price_rub = tariff["price_rub"]
if balance < price_rub:
required_amount = ceil(price_rub - balance)
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"tariff_id": tariff_id,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
)
return
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key")
)
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(tariff_id=tariff_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
async def create_key(
tg_id: int,
expiry_time,
state,
session,
message_or_query=None,
old_key_name: str = None,
plan: int = None,
):
if not await check_user_exists(session, tg_id):
from_user = (
message_or_query.from_user
if isinstance(message_or_query, CallbackQuery | Message)
else None
)
if from_user:
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
if USE_COUNTRY_SELECTION:
await key_country_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
plan=plan,
)
else:
await key_cluster_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
plan=plan,
)
@@ -0,0 +1,99 @@
from datetime import datetime, timedelta
from aiogram import F, Router
from aiogram.types import CallbackQuery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database import get_tariffs
from database.models import Notification
from handlers.notifications.notify_kb import build_tariffs_keyboard
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from logger import logger
from .key_create import select_tariff_plan
router = Router()
@router.callback_query(F.data == "hot_lead_discount")
async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_2",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
return
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
@router.callback_query(F.data.startswith("discount_tariff|"))
async def handle_discount_tariff_selection(callback: CallbackQuery, session, state):
try:
tariff_id = int(callback.data.split("|")[1])
fake_callback = CallbackQuery.model_construct(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
message=callback.message,
data=f"select_tariff_plan|{tariff_id}",
)
await select_tariff_plan(fake_callback, session=session, state=state)
except Exception as e:
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
@router.callback_query(F.data == "hot_lead_final_discount")
async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_3",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
return
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
+364
View File
@@ -0,0 +1,364 @@
from datetime import datetime, timedelta
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from math import ceil
import pytz
from bot import bot
from config import USE_NEW_PAYMENT_FLOW
from database import (
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
get_tariff_by_id,
get_tariffs,
update_balance,
update_key_expiry,
check_tariff_exists,
)
from database.models import Server, Key
from handlers.buttons import BACK, MAIN_MENU, PAYMENT
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
get_renewal_message,
)
from handlers.buttons import MY_SUB
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(
callback_query: CallbackQuery, session: AsyncSession
):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("<b>Ключ не найден.</b>")
return
client_id = record["client_id"]
expiry_time = record["expiry_time"]
server_id = record["server_id"]
tariff_id = record.get("tariff_id")
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
try:
server_id_int = int(server_id)
filter_condition = or_(
Server.id == server_id_int,
Server.server_name == server_id,
Server.cluster_name == server_id,
)
except ValueError:
filter_condition = or_(
Server.server_name == server_id,
Server.cluster_name == server_id,
)
row = await session.execute(
select(Server.tariff_group).where(filter_condition).limit(1)
)
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
return
cluster_group = row[0]
selected_tariffs = []
target_group = cluster_group
if tariff_id:
if await check_tariff_exists(session, tariff_id):
current_tariff = await get_tariff_by_id(session, tariff_id)
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
target_group = current_tariff["group_code"]
tariffs = await get_tariffs(session, group_code=target_group)
if not tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
selected_tariffs = [t for t in tariffs if t["is_active"]]
if not selected_tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
builder = InlineKeyboardBuilder()
for t in selected_tariffs:
button_text = f"{t['name']}{t['price_rub']}"
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"renew_plan|{t['id']}|{client_id}",
)
)
builder.row(
InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record['email']}")
)
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(
f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}"
)
await callback_query.message.answer(
"❌ Произошла ошибка при обработке. Попробуйте позже."
)
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, session: Any):
tg_id = callback_query.from_user.id
tariff_id, client_id = callback_query.data.split("|")[1:]
tariff_id = int(tariff_id)
try:
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff["is_active"]:
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
record = await get_key_by_server(session, tg_id, client_id)
if not record:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
return
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(
current_time + timedelta(days=duration_days).total_seconds() * 1000
)
else:
new_expiry_time = int(
expiry_time + timedelta(days=duration_days).total_seconds() * 1000
)
balance = round(await get_balance(session, tg_id), 2)
cost = round(cost, 2)
if balance < cost:
required_amount = ceil(cost - balance)
logger.info(f"[RENEW] Недостаточно средств: {required_amount}")
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"tariff_id": tariff_id,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(
InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")
)
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(
required_amount=required_amount
),
reply_markup=builder.as_markup(),
)
return
logger.info(
f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней"
)
await complete_key_renewal(
session,
tg_id,
client_id,
email,
new_expiry_time,
total_gb,
cost,
callback_query,
tariff_id,
)
except Exception as e:
logger.error(
f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}"
)
async def resolve_cluster_name(
session: AsyncSession, server_or_cluster: str
) -> str | None:
result = await session.execute(
select(Server).where(Server.cluster_name == server_or_cluster).limit(1)
)
server = result.scalars().first()
if server:
return server_or_cluster
result = await session.execute(
select(Server.cluster_name)
.where(Server.server_name == server_or_cluster)
.limit(1)
)
row = result.scalar()
return row
async def complete_key_renewal(
session: AsyncSession,
tg_id: int,
client_id: str,
email: str,
new_expiry_time: int,
total_gb: int,
cost: float,
callback_query: CallbackQuery | None,
tariff_id: int,
):
try:
logger.info(
f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)"
)
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return
if tariff["duration_days"] % 30 == 0:
months_formatted = format_months(tariff["duration_days"] // 30)
else:
months_formatted = format_days(tariff["duration_days"])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
formatted_expiry_date = datetime.fromtimestamp(
new_expiry_time / 1000, tz=moscow_tz
).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz))
)
response_message = get_renewal_message(
tariff_name=months_formatted,
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date
)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
else:
await bot.send_message(
tg_id, response_message, reply_markup=builder.as_markup()
)
key_info = await get_key_details(session, email)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
return
server_or_cluster = key_info["server_id"]
cluster_id = await resolve_cluster_name(session, server_or_cluster)
if not cluster_id:
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
return
await renew_key_in_cluster(
cluster_id,
email,
client_id,
new_expiry_time,
total_gb,
session,
hwid_device_limit=tariff["device_limit"],
)
await update_key_expiry(session, client_id, new_expiry_time)
await session.execute(
update(Key)
.where(Key.client_id == client_id)
.values(tariff_id=tariff_id)
)
await update_balance(session, tg_id, -cost)
logger.info(
f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})"
)
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
+828 -237
View File
File diff suppressed because it is too large Load Diff

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