580 Commits

Author SHA1 Message Date
Vladislav Lisitsyn d30e932f94 Merge pull request #125 from hteppl/fix-tv-preview
Удаление превью ссылок в сообщении текста для ТВ
2024-12-29 02:26:05 +03:00
hteppl 87a3e124f4 Fix CONNECT_TV_TEXT: add disable_web_page_preview=True 2024-12-29 02:05:47 +03:00
Vladless 579a8ca7e4 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:46:20 +03:00
Vladless b1276c42a4 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:28:38 +03:00
Vladless 0996f15c30 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:22:56 +03:00
Vladislav Lisitsyn b2fb41edc3 Merge pull request #124 from hteppl/mics-updates
Небольшие изменения
2024-12-26 23:03:38 +03:00
hteppl 10ce155b8c Fix key back button 2024-12-26 03:40:26 +03:00
hteppl 0ca647fd64 Remove Telegram URL prefix when finding a user by username 2024-12-26 03:36:54 +03:00
hteppl 33d4fd2e2e Monthly payments in admin stats 2024-12-26 03:36:28 +03:00
Vladislav Lisitsyn 7a2f5cd899 Merge pull request #123 from hteppl/license
Лицензия и обновленный README
2024-12-24 17:40:48 +03:00
Vladless 6fbe1632bc gift_links 2024-12-24 15:02:48 +03:00
Vladless 36baacc0e1 buttons_pay/security_update/MacOS 2024-12-24 02:30:53 +03:00
Vladless eb7effed4c buttons_pay/security_update 2024-12-24 02:30:09 +03:00
hteppl f27b62f8e7 Update README.md 2024-12-23 16:01:16 +03:00
hteppl 4aa9be2408 Add CC BY-NC 4.0 LICENSE 2024-12-23 16:00:48 +03:00
hteppl 4fca2cad95 Remove legacy installation scripts 2024-12-23 16:00:26 +03:00
Vladless b41314af76 minor update 2024-12-23 01:23:34 +03:00
Vladless ce3985ff96 gift_code/buttons/commands 2024-12-23 01:14:28 +03:00
Vladless 9b9a72ef04 add support for business/optimization 2024-12-20 23:29:27 +03:00
Vladless ce78caf065 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:39:07 +03:00
Vladless c01b528055 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:37:41 +03:00
Vladislav Lisitsyn 725167f663 Merge pull request #122 from hteppl/fix-referrals
WIP: фикс уявзимости, где айди приглашенного реферала, совпадает с айди пригласившего
2024-12-16 23:41:29 +03:00
hteppl 18dde7e882 Fix self-referred bug 2024-12-16 23:19:39 +03:00
Vladislav Lisitsyn f5ec703994 Merge pull request #120 from crell2100/main
add CHANEL_EXISTS variable
2024-12-16 23:09:42 +03:00
crell2100 5e07feb815 donats_enable->donations_enable (rename variable) 2024-12-16 16:40:01 +03:00
crell2100 c394fb3d2a Merge branch 'main' into main 2024-12-15 22:51:32 +03:00
crell2100 a7979af27a add DONATS_ENABLE variable 2024-12-15 22:30:20 +03:00
crell2100 d29d454727 add CHANEL_EXISTS variable 2024-12-15 18:07:52 +03:00
Vladless de6a79a634 remove gifts 2024-12-15 00:23:08 +03:00
Vladless fd3d5d8de6 add import 2024-12-15 00:23:08 +03:00
Vladless f1358b36b7 add import 2024-12-15 00:23:08 +03:00
Vladislav Lisitsyn 831472ab51 Update README.md 2024-12-14 21:24:42 +03:00
Vladislav Lisitsyn bbb554e4e9 Merge pull request #119 from TrackLine/main
Add Yoomoney pay service
2024-12-14 19:07:25 +03:00
TrackLine 7677ae9193 Add Yoomoney pay service 2024-12-14 19:49:54 +04:00
Vladless ca87286898 sub_name 2024-12-14 18:46:43 +03:00
Vladless f821241edb fix middleware 2024-12-14 17:39:39 +03:00
Vladislav Lisitsyn 9706730d37 Merge pull request #118 from izzzzzi/main
Migrate to ruff
2024-12-14 02:02:09 +03:00
Vladislav Lisitsyn 8b11c65c8e Merge branch 'main' into main 2024-12-14 02:02:00 +03:00
Vladless 2750517be4 vat_code 2024-12-14 00:37:20 +03:00
Vladless e5da607c9d sub_name/delete user 2024-12-13 23:01:58 +03:00
Zakhar Izmaylov 1e912487af Fix requirements.txt 2024-12-13 12:13:50 +03:00
Zakhar Izmaylov 69f7d05527 Migrate to ruff 2024-12-13 12:09:20 +03:00
Vladless 2b572da089 rm wrench 2024-12-11 15:41:45 +03:00
Vladislav Lisitsyn c75fd8516a Merge pull request #113 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 04:14:24 +02:00
Vladless a81b09eb1e pic for tariffs 2024-12-10 05:13:54 +03:00
Vladless 145c716529 pic for tariffs 2024-12-10 05:12:49 +03:00
Vladless bc1ddc349a Revert "Merge pull request #112 from Vladless/hteppl-optimize-sources"
This reverts commit 5f557fa2b2, reversing
changes made to 4ea1f101ff.
2024-12-10 03:53:07 +03:00
Vladislav Lisitsyn 5f557fa2b2 Merge pull request #112 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 02:27:34 +02:00
Vladless 940867baed Merge branch 'optimize-sources' of https://github.com/hteppl/Solo_bot into hteppl-optimize-sources 2024-12-10 03:26:42 +03:00
Vladislav Lisitsyn 4ea1f101ff Merge pull request #108 from hteppl/optimize-routing-system-v2
Оптимизация системы рутинга 2 // LP
2024-12-10 00:54:32 +02:00
Vladislav Lisitsyn d0d36d9f1a Merge pull request #104 from hteppl/create-sql-schema
Оптимизация создания базы данных // LP
2024-12-10 00:54:22 +02:00
hteppl eee8d86426 Make utils as package + fix python unresolved imports 2024-12-08 05:32:23 +03:00
hteppl 94d335d950 Move payments utils into utils folder 2024-12-08 05:13:38 +03:00
hteppl 9756d2e2b7 Move keys utils into utils folder 2024-12-08 05:11:31 +03:00
hteppl 3e4d7972c7 Move utils.py to the utils folder 2024-12-08 05:02:41 +03:00
hteppl 0e9c128a15 Move util-related files into separated folder + refactor imports 2024-12-08 04:57:20 +03:00
Oleg Kraev e850bd85a8 Merge branch 'Vladless:main' into optimize-routing-system-v2 2024-12-08 04:37:31 +03:00
hteppl b02c716992 Implement global router 2024-12-08 04:36:59 +03:00
hteppl 822d845241 Include routers only if payment method enabled in the config 2024-12-08 04:23:59 +03:00
hteppl 8dc478a606 Create and implement router for admin 2024-12-08 04:21:37 +03:00
hteppl d664036f5e Create and implement router for instructions 2024-12-08 04:20:09 +03:00
hteppl fe655a7ee5 Create and implement router for keys 2024-12-08 04:19:16 +03:00
hteppl 56ff84f7a5 Create and implement router for payments 2024-12-08 04:17:50 +03:00
hteppl 69b1705fd5 Create basic main router for handlers 2024-12-08 04:15:52 +03:00
Vladislav Lisitsyn 956cdaf020 Merge pull request #107 from hteppl/linux-start-scripts
Linux cкрипты для автоматической настройки зависимостей и запуска
2024-12-07 15:32:56 +02:00
Vladless 0b44237317 add pay button 2024-12-07 16:32:16 +03:00
Vladislav Lisitsyn 67880704a0 Update README.md 2024-12-07 00:27:53 +03:00
Vladislav Lisitsyn 682fb0bdb0 Update README.md 2024-12-07 00:17:48 +03:00
hteppl 71c7a10165 Add linux shell scripts to configure dependencies automatically 2024-12-06 18:39:35 +03:00
Vladless 5577780565 ping servers and notifications 2024-12-06 05:19:44 +03:00
Vladless 5db2b7c861 ping servers and notifications 2024-12-06 04:48:58 +03:00
Vladless 370ceb8382 back button in custom pay 2024-12-06 02:25:40 +03:00
Vladless 3226438e69 back button in custom pay 2024-12-06 02:25:18 +03:00
Vladless c432edf8fc preview pc_instruction disable 2024-12-06 01:42:14 +03:00
Vladless 49aa863eb4 Server availability 2024-12-06 01:14:25 +03:00
Vladless c58039cb9e logs folder and autoremove 2024-12-06 00:46:54 +03:00
Vladless ee242c98c7 sales in texts 2024-12-05 23:56:06 +03:00
Vladless f3dfb03749 backup for 3x-ui 2024-12-05 23:31:48 +03:00
Vladless 02ea333af1 fix refferal 2024-12-05 22:59:34 +03:00
Vladless 142a5ffaab fix refferal 2024-12-05 22:45:10 +03:00
Vladless db24a9abda backup time 2024-12-05 21:09:37 +03:00
hteppl 3fcb6adaad Create schema.sql database template + optimize init_db 2024-12-05 07:59:49 +03:00
Vladless 5be9632c5e fix trial 2024-12-04 17:25:32 +03:00
Vladless 42faa00e32 change version 2024-12-04 03:10:15 +03:00
Vladless f6fbf31109 unique server_name/ link moderation 2024-12-04 03:02:29 +03:00
Vladless 8751c9d4f2 unique server_name 2024-12-04 02:46:57 +03:00
Vladless 04e1510001 plan on create new key after trial 2024-12-04 01:28:28 +03:00
Vladless 2b1c62e869 admin_update 2024-12-04 00:16:23 +03:00
Vladless 2899de2632 add button for MacOS 2024-12-04 00:06:21 +03:00
Vladless 9451deec4c Clusters on/off in config 2024-12-02 04:58:14 +03:00
Vladless d8b0ff9961 profile/reorganization and news 2024-12-02 04:46:05 +03:00
Vladless cb6bd1135b sale size 2024-12-02 04:20:32 +03:00
Vladless 47210fc20a rename server info 2024-12-02 04:08:36 +03:00
Vladless 2815b8a340 move button 2024-12-02 04:06:46 +03:00
Vladless 1301b81e79 pc button for trial_key 2024-12-02 03:54:07 +03:00
Vladless 0353d9d628 pc button for trial_key 2024-12-02 03:37:28 +03:00
Vladless d0084f4062 add/delete servers in admin 2024-12-02 03:25:06 +03:00
Vladless 4ce36e3f22 servers in admin_editor 2024-12-02 00:28:33 +03:00
Vladless 14cfe61e33 servers in database 2024-12-01 23:40:37 +03:00
Vladless 0e5961bdc5 servers in database 2024-12-01 23:24:53 +03:00
Vladless d9064ff016 autoconfig servers in database 2024-12-01 23:24:13 +03:00
Vladless efc687d0fe notification inactive users 2024-12-01 22:24:00 +03:00
Vladless 1c3e42f94a sudo in restart 2024-11-29 00:01:48 +03:00
Vladless fa7347a85c autoname for trial in profile 2024-11-28 20:48:34 +03:00
Vladless 7f1c783239 /start 2024-11-28 15:37:21 +03:00
Vladless 8c85bdd4d8 link Successful payment 2024-11-27 21:18:07 +03:00
Vladless 7991a3aa4d fix stars 2024-11-27 17:30:53 +03:00
Vladless 2c86417c9d update subs 2024-11-26 20:10:14 +03:00
Vladless f18af7010f fix admin 2024-11-26 20:03:21 +03:00
Vladless bbcd72bab4 notifications 2024-11-26 17:48:41 +03:00
Vladless 54d7299977 fix renew 2024-11-26 17:30:41 +03:00
Vladless ca8b032230 random email for add device 2024-11-26 17:21:15 +03:00
Vladislav Lisitsyn be53b79221 Merge pull request #102 from izzzzzi/main
Refactor
2024-11-26 03:25:33 +03:00
Zakhar Izmaylov f6af8e3212 Fix start 2024-11-26 03:21:43 +03:00
Zakhar Izmaylov a6e07c8080 Small fix 2024-11-26 02:54:32 +03:00
Zakhar Izmaylov 7db545e249 Enhanced notifications and admin: CSV export for users/payments, better feedback/navigation, DEV_MODE checks for notifications, .csv in .gitignore, fixed trial expiry with TRIAL_TIME. 2024-11-26 02:52:15 +03:00
Zahar Izmailov 7d751e0588 Merge branch 'Vladless:main' into main 2024-11-26 00:27:58 +03:00
Vladless 033b978a37 back button 2024-11-25 01:16:06 +03:00
Vladless d9676bd355 fix session 2024-11-25 00:58:46 +03:00
Zakhar Izmaylov 6a7890845d Refactor notification and admin user editor handlers to improve user feedback. Comment out inactive trial user notifications in notifications.py. Enhance error messages with consistent "Назад" button for better navigation in admin_user_editor.py. 2024-11-25 00:29:06 +03:00
Zakhar Izmaylov 79ea8d431d Update button texts in start and admin user editor handlers for improved navigation. Change "Личный кабинет" to "Поддержка" in start.py and adjust "Назад" callback data in admin_user_editor.py for consistency. 2024-11-25 00:16:12 +03:00
Zakhar Izmaylov 27db7f6814 Refactor import statements in notification, profile, key management, and keys handlers for improved organization and consistency. Ensure RENEWAL_PLANS is included in all relevant files. 2024-11-24 23:45:38 +03:00
Zakhar Izmaylov 900ffd3e2e Refactor notification and profile handlers to include RENEWAL_PLANS from config. Update key management and keys handlers to streamline imports and enhance code organization. 2024-11-24 23:45:27 +03:00
Zahar Izmailov 7eeacd94c1 Merge branch 'Vladless:main' into main 2024-11-24 23:37:58 +03:00
Vladless ac61783fef 3.2 2024-11-24 23:10:22 +03:00
Vladless 80838a2796 reset traffic 2024-11-24 23:02:34 +03:00
Zakhar Izmaylov b820a70f01 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 22:37:23 +03:00
Zakhar Izmaylov e3584587b8 Remove unused __init__.py file and update payment handler callback buttons to unify navigation. Change "back_to_profile" callback to "pay" in multiple payment processing files for consistency. 2024-11-24 22:35:43 +03:00
Vladless 1370ff983c ID_Inbound 2024-11-24 22:29:32 +03:00
Vladless 274bbc66be ID_Inbound 2024-11-24 22:29:05 +03:00
Vladislav Lisitsyn 7a7f4ee3e2 Merge pull request #100 from izzzzzi/main
Refactoring and Update
2024-11-24 21:54:53 +03:00
Vladislav Lisitsyn 1cbf0f643b Merge branch 'main' into main 2024-11-24 21:54:30 +03:00
Zakhar Izmaylov 0e5236184c Refactor database notification query to remove unnecessary string conversion for hours parameter, improving data handling efficiency. 2024-11-24 20:48:16 +03:00
Zakhar Izmaylov 55517c1398 Refactor database and notification handlers for improved readability and consistency. Clean up import statements, enhance logging messages, and streamline function calls across multiple files. Ensure proper session handling in payment processing functions. 2024-11-24 20:29:22 +03:00
Vladless 12a2a12273 fix back cryptobot 2024-11-24 20:19:23 +03:00
Zakhar Izmaylov 02120262cd Enhance notification system for inactive trial users by adding notification time checks and logging. Implement functionality to record notifications sent to users, ensuring they are notified no more than once every 24 hours. 2024-11-24 20:02:40 +03:00
Zakhar Izmaylov 89c5a4db2c Refactor referral system to support multi-level bonuses and enhance logging. Update database interactions for referral handling and statistics retrieval. Change key display format in notifications and admin user editor for better readability. 2024-11-24 19:38:59 +03:00
Zakhar Izmaylov 2cfbfc5fd0 Update table 2024-11-24 14:14:06 +03:00
Zakhar Izmaylov 3f7d0a26fd Fix delete msg 2024-11-24 13:51:18 +03:00
Zakhar Izmaylov dccc6166e8 notify_inactive_trial_users 2024-11-24 11:58:43 +03:00
Zakhar Izmaylov a79c51dce4 Fix admin 2024-11-24 11:51:17 +03:00
Zakhar Izmaylov e99c41ae0c Update connection_exists 2024-11-24 11:42:29 +03:00
Zakhar Izmaylov 5ca72a4a65 Fix srssion 2024-11-24 11:26:54 +03:00
Zakhar Izmaylov 97d4031f0c Update id 2024-11-24 11:07:52 +03:00
Zakhar Izmaylov 2b73c601d0 Fix 2024-11-24 10:54:08 +03:00
Zakhar Izmaylov c6d372c080 Fix get_trial 2024-11-24 10:35:12 +03:00
Zakhar Izmaylov 592afcf56e Add logging 2024-11-24 09:15:09 +03:00
Zakhar Izmaylov fb64afe06e Fix trial_status 2024-11-24 08:27:27 +03:00
Zakhar Izmaylov 7d4a33e679 Fix 2024-11-24 08:13:52 +03:00
Zakhar Izmaylov 53dc006f2d Update InlineKeyboardBuilder 2024-11-24 01:57:42 +03:00
Zakhar Izmaylov a672a0ddb0 Fix 2024-11-24 01:47:37 +03:00
Zakhar Izmaylov cfc3cedc2e Add notify_inactive_trial_users 2024-11-24 01:34:39 +03:00
Zakhar Izmaylov 7fe5c3d870 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:24:10 +03:00
Zakhar Izmaylov 2e5bcbe407 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:19:06 +03:00
Zakhar Izmaylov 0867ebdd61 Fix typing 2024-11-24 01:19:00 +03:00
Zakhar Izmaylov fe59470256 Refactoring 2024-11-24 01:18:42 +03:00
Vladless 9cd7ea1451 update clusters 2024-11-23 20:21:47 +03:00
Vladless c55764d1a4 fix users 2024-11-23 17:30:39 +03:00
Vladislav Lisitsyn 14364dbcfe Merge pull request #98 from izzzzzi/main
fix ref/serach from username and new stats in admin
2024-11-23 08:18:10 +03:00
Zakhar Izmaylov 4b05bd8e18 Fix admin 2024-11-23 07:14:58 +03:00
Zakhar Izmaylov 5363f15fe7 Add pylint 2024-11-22 21:34:56 +03:00
Zakhar Izmaylov 42ad24df1d Update config 2024-11-22 21:24:55 +03:00
Zakhar Izmaylov f7e03fb2b9 Fix ref 2024-11-22 17:45:38 +03:00
Zakhar Izmaylov 4e3fea2b11 Update text admin 2024-11-22 01:14:23 +03:00
Zakhar Izmaylov 32b9c0e779 Update push 2024-11-22 01:12:19 +03:00
Zakhar Izmaylov 50c7aa52c0 Add search from username 2024-11-22 00:43:23 +03:00
Zakhar Izmaylov f742d3bad5 Update user_stats_menu 2024-11-21 21:14:44 +03:00
Zakhar Izmaylov d100dc7b2d Update instructions 2024-11-21 20:49:58 +03:00
Zakhar Izmaylov 384c2c4c1c Update text and add view_tariffs 2024-11-21 20:31:24 +03:00
Zakhar Izmaylov 6f0cbf6c8c Fix send_to_alls 2024-11-20 17:30:10 +03:00
Zakhar Izmaylov ef850506d6 Update admin 2024-11-20 17:18:28 +03:00
Zakhar Izmaylov 3a4ab1a956 Add new table and add stats in admin 2024-11-20 16:47:21 +03:00
Zakhar Izmaylov 187daba997 Update logger 2024-11-20 15:46:46 +03:00
Zakhar Izmaylov 6d4c98b901 Fix img and logger add button back for donate 2024-11-20 15:36:25 +03:00
Zahar Izmailov 1119d607e8 Merge branch 'Vladless:main' into main 2024-11-20 15:28:44 +03:00
Zakhar Izmaylov 6f2b9694f3 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-20 09:43:22 +03:00
Zakhar Izmaylov 4b1549ca65 Fix 2024-11-20 09:43:16 +03:00
Vladless acc7c701fe fix bugs 2024-11-20 00:18:29 +03:00
Vladless 96a76f0425 clusters update 2024-11-19 19:31:31 +03:00
Vladless 2ab8d093dc Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-19 14:58:25 +03:00
Vladless f0aa11f711 security update 1.0 2024-11-19 14:58:07 +03:00
Vladislav Lisitsyn 7a7f3cac95 Update start.py 2024-11-19 11:52:06 +03:00
Vladless 9a44f4e37f security update 1.0 2024-11-19 11:43:22 +03:00
Vladless 1db43423fc security update 1.0 2024-11-19 11:21:34 +03:00
Vladislav Lisitsyn cf776c46c8 Merge pull request #97 from izzzzzi/main
Fix key and session
2024-11-19 09:03:20 +03:00
Zakhar Izmaylov 7628b8d45b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-19 08:32:33 +03:00
Zakhar Izmaylov ba717bd5e5 Fix key and session 2024-11-19 08:32:30 +03:00
Vladislav Lisitsyn 3cea1f9653 Merge pull request #96 from izzzzzi/main
Add back button for coupons
2024-11-18 22:51:16 +03:00
Vladless 713f0a0eac renew/delete 2024-11-18 22:40:59 +03:00
Zakhar Izmaylov 8d7c1cbcb8 Fix admin 2024-11-18 22:19:17 +03:00
Zakhar Izmaylov 97ca831e43 Add back button for coupons 2024-11-18 22:03:51 +03:00
Vladless 488f52248f add time in robokassa 2024-11-18 20:03:45 +03:00
Vladless 956644fd57 fix update_sub 2024-11-18 16:04:29 +03:00
Vladless 942adc4490 Fixed unexpected behavior of the device selection button 2024-11-18 14:57:12 +03:00
Vladless e04ccd893f Fixed unexpected behavior of the device selection button 2024-11-18 14:56:41 +03:00
Vladislav Lisitsyn 12b9b9c77e Merge pull request #95 from izzzzzi/main
Add admin middleware
2024-11-18 12:54:05 +03:00
Zakhar Izmaylov 663996493c Update Middleware 2024-11-17 23:42:09 +03:00
Zakhar Izmaylov f4651b587e Fix 2024-11-17 16:24:12 +03:00
Zakhar Izmaylov a7c9556761 Add button 2024-11-17 16:19:47 +03:00
Zakhar Izmaylov 5f8280061a Formatting 2024-11-17 16:15:51 +03:00
Zakhar Izmaylov 222803f147 Fix Middleware 2024-11-17 16:14:35 +03:00
Zakhar Izmaylov 9903298592 Fix admin 2024-11-17 16:10:58 +03:00
Zakhar Izmaylov e45b64e2ad Fix admin 2024-11-17 16:05:20 +03:00
Zakhar Izmaylov d33fe0040e Add default admin 2024-11-17 16:01:18 +03:00
Zakhar Izmaylov be0e9d46e9 Fix start 2024-11-17 15:53:31 +03:00
Zakhar Izmaylov d5d61e4cc8 Fix commands 2024-11-17 15:47:31 +03:00
Zakhar Izmaylov 3573c8f7d0 Fix start 2024-11-17 15:44:41 +03:00
Zakhar Izmaylov db206dc8be Fix 2024-11-17 15:39:53 +03:00
Zakhar Izmaylov 92f8a03f76 Fix admin middleware 2024-11-17 15:39:27 +03:00
Zakhar Izmaylov ff9c01a941 Fix send backup 2024-11-17 15:29:56 +03:00
Zakhar Izmaylov 20d424f2d4 Fix admin and create coupons 2024-11-17 15:26:03 +03:00
Zakhar Izmaylov a91629bd7f Fix admin 2024-11-17 15:10:28 +03:00
Zakhar Izmaylov 7a02410f73 Update img 2024-11-17 14:17:51 +03:00
Zakhar Izmaylov 657035db5d Fix img 2024-11-17 14:15:27 +03:00
Zakhar Izmaylov 1eab4cf29c Fix 2024-11-17 14:09:05 +03:00
Zakhar Izmaylov bea360b978 Update delete models 2024-11-17 14:08:51 +03:00
Zakhar Izmaylov 09251f1e9e Add new table and middleware 2024-11-17 14:05:31 +03:00
Zakhar Izmaylov 755f871251 Refactoring and fix 2024-11-17 13:33:17 +03:00
Zakhar Izmaylov c2cc341a34 Add admin middleware 2024-11-17 13:11:49 +03:00
Zahar Izmailov 35383724f1 Merge branch 'Vladless:main' into main 2024-11-17 10:42:43 +03:00
Vladless 5d4c55e5be version 2024-11-17 03:20:06 +03:00
Vladless 722661888a add version 2024-11-17 01:32:52 +03:00
Vladless b080387f67 robokassa release 2024-11-17 00:01:26 +03:00
Zakhar Izmaylov 371cbcf10d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-16 23:56:54 +03:00
Zakhar Izmaylov d3e88402cb #TODO Admin and bash 2024-11-16 23:56:45 +03:00
Vladislav Lisitsyn be3c2aa73a Merge pull request #94 from izzzzzi/main
Add scripts
2024-11-16 22:54:50 +03:00
Zahar Izmailov d411ee5eff Merge branch 'Vladless:main' into main 2024-11-16 21:06:04 +03:00
Zakhar Izmaylov f5b01c7b49 Delete nginx config 2024-11-16 21:04:06 +03:00
Zakhar Izmaylov 086f73cf6d Update 2024-11-16 21:03:39 +03:00
Zakhar Izmaylov f996c16669 Add bash for node 2024-11-16 19:05:09 +03:00
Vladislav Lisitsyn e4e12c2dff Merge pull request #93 from izzzzzi/main
Add dry logging
2024-11-16 18:46:12 +03:00
Zakhar Izmaylov 8ba3195030 Add dry logging 2024-11-16 18:01:28 +03:00
Vladless e512ecae4f fix delete 2024-11-16 00:44:12 +03:00
Vladless 7eb89d7c23 ip 2024-11-15 23:59:42 +03:00
Vladless 8efafe206f fix back_button 2024-11-15 23:27:08 +03:00
Vladless 0317b14d2e Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 23:15:41 +03:00
Vladless 2a9c407439 fix logging 2024-11-15 23:15:19 +03:00
Vladislav Lisitsyn 2e6d3c8b12 Update start.py 2024-11-15 22:06:23 +03:00
Vladless 74bb638806 pc instructions and button 2024-11-15 21:49:27 +03:00
Vladless fe00b3534d fix cluster 2024-11-15 20:30:57 +03:00
Vladless 16b438c93f changing notifications 2024-11-15 20:15:19 +03:00
Vladless 304caf2242 logging for add client 2024-11-15 19:24:09 +03:00
Vladless 37d3eeb277 formatting 2024-11-15 19:19:13 +03:00
Vladless 3ee6449de0 rm DEBUG 2024-11-15 19:16:37 +03:00
Vladless 116a65e48e total_gb 2024-11-15 19:16:06 +03:00
Vladislav Lisitsyn e61ab751bb Merge pull request #92 from izzzzzi/main
Fix pay stars
2024-11-15 18:03:04 +03:00
Zakhar Izmaylov da99e358df Fix router 2024-11-15 17:28:54 +03:00
Zakhar Izmaylov b9e9eeb214 Fix pay stars 2024-11-15 17:28:06 +03:00
Zakhar Izmaylov 124ec36661 Fix pay stars 2024-11-15 17:25:42 +03:00
Vladless f29fd41a4c fix donate 2024-11-15 15:48:52 +03:00
Vladless 098b879c5c fix donate 2024-11-15 15:40:20 +03:00
Vladislav Lisitsyn 24abd4c087 Merge pull request #91 from izzzzzi/main
Add donate
2024-11-15 15:09:46 +03:00
Vladless 85e5d96112 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 15:09:01 +03:00
Vladless 1a85ff4ae8 robokassa_beta 2024-11-15 15:07:51 +03:00
Zakhar Izmaylov e87df0c8eb Add donate 2024-11-15 14:38:14 +03:00
Vladislav Lisitsyn 213284f5f8 Merge pull request #90 from izzzzzi/main
Added support for the list of admins
2024-11-15 13:51:33 +03:00
Vladless 37110b230b fix message delete 2024-11-15 13:51:12 +03:00
Zahar Izmailov 8f12316584 Merge branch 'Vladless:main' into main 2024-11-15 13:37:57 +03:00
Zakhar Izmaylov 85d6fbc3e3 Added support for the list of admins 2024-11-15 13:37:24 +03:00
Vladislav Lisitsyn 317e591d1f Merge pull request #89 from izzzzzi/main
Fix stars
2024-11-15 13:30:46 +03:00
Zakhar Izmaylov 0f0b2708e7 Fix stars 2024-11-15 13:25:57 +03:00
Vladislav Lisitsyn fe6bbe7088 Merge pull request #88 from izzzzzi/main
Add dev mode and fix menu and fix stars and cryptobot
2024-11-15 12:53:05 +03:00
Zakhar Izmaylov bba036e7e9 Add multy pay 2024-11-15 11:58:48 +03:00
Zakhar Izmaylov 2f10ae0b74 Add dev mode and fix menu and fix stars and cryptobot 2024-11-15 11:40:25 +03:00
Vladless deafbfb4ee coupons initial 2024-11-15 03:03:24 +03:00
Vladless afe701ccaf fix payments 2024-11-14 15:13:52 +03:00
Vladless 9b8024b8c3 servers in clusters/total_gb 2024-11-14 14:17:16 +03:00
Vladless 8de8236703 servers in clusters/total_gb 2024-11-14 03:04:23 +03:00
Vladless 3fb4150a9e servers in clusters/total_gb 2024-11-14 03:03:32 +03:00
Vladless 5257621f43 custom trial time 2024-11-13 18:51:25 +03:00
Vladislav Lisitsyn 6ab6ac231a Merge pull request #87 from izzzzzi/main
Update keyboards and restructuring for filters
2024-11-13 18:04:38 +03:00
Vladless be51a4cdbf custom bonus 2024-11-13 17:27:08 +03:00
Zakhar Izmaylov 2bbebe7d4f Add models.py #TODO 2024-11-13 17:10:25 +03:00
Zakhar Izmaylov 0a2b164c49 Add DatabaseMiddleware #TODO 2024-11-13 17:03:18 +03:00
Zakhar Izmaylov 33c68527c2 Restructuring for filters 2024-11-13 16:53:23 +03:00
Zakhar Izmaylov 3a46d12657 Update keyboards, update texts.py 2024-11-13 16:48:56 +03:00
Vladless 399537530a add TOTAL_GB 2024-11-13 16:18:16 +03:00
Vladless f6aa28a0e7 small fix 2024-11-13 15:38:20 +03:00
Vladless a7e5e74857 small fix 2024-11-13 05:41:49 +03:00
Vladless 2f4caeeeb7 redesign/bug fixes/admin trial 2024-11-13 04:05:43 +03:00
Vladless 92af5e5d37 redesign/bug fixes/admin trial 2024-11-13 04:04:40 +03:00
Vladless 9b87d02b5d redesign/bug fixes/admin trial 2024-11-13 04:01:13 +03:00
Vladless 0f9d4e3935 redesign/bug fixes/admin trial 2024-11-13 03:59:34 +03:00
Vladislav Lisitsyn 23f668e251 Merge pull request #86 from izzzzzi/main
Refactoring admin
2024-11-12 13:11:09 +03:00
Zakhar Izmaylov fa27648c7c Refactoring user editor 2024-11-12 08:10:40 +03:00
Zakhar Izmaylov bc63a4b070 Refactoring 2024-11-12 08:10:28 +03:00
Zakhar Izmaylov 8c3e055acd Refactoring admin 2024-11-12 07:51:54 +03:00
Vladislav Lisitsyn 1b7b7b1e18 Merge pull request #85 from izzzzzi/main
Fix formating and freekassa
2024-11-12 00:17:33 +03:00
Zakhar Izmaylov c63c8d08cf Fix formating and freekassa 2024-11-12 00:11:33 +03:00
Vladless 840ef3648b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot into izzzzzi-main 2024-11-11 23:35:49 +03:00
Zakhar Izmaylov 299a07e9a3 Fix texts 2024-11-11 23:35:01 +03:00
Zakhar Izmaylov abf5d686cb Small refactoring admin 2024-11-11 23:17:09 +03:00
Zakhar Izmaylov 00f4904b8c Fix bot.py 2024-11-11 23:11:14 +03:00
Zakhar Izmaylov c8b913b55a Fix, Update menu 2024-11-11 23:07:54 +03:00
Vladislav Lisitsyn 914d34c6f5 Merge pull request #83 from izzzzzi/main
Add multypay
2024-11-11 23:03:26 +03:00
Zakhar Izmaylov 6eb201198e Remove btn 2024-11-11 22:49:45 +03:00
Zakhar Izmaylov d088a6d161 Add multypay 2024-11-11 22:45:11 +03:00
Vladislav Lisitsyn 3a1268e97b Merge pull request #82 from izzzzzi/main
Add stars pay(beta)
2024-11-11 22:38:17 +03:00
Vladless 1c27ed7bf0 fix freekassa 2024-11-11 22:37:41 +03:00
Zakhar Izmaylov 2be5c74101 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-11 22:33:03 +03:00
Zakhar Izmaylov ed34e26e80 Fix cryptobot 2024-11-11 22:31:52 +03:00
Zahar Izmailov 1cc5348270 Merge branch 'Vladless:main' into main 2024-11-11 22:28:00 +03:00
Zakhar Izmaylov f0f0469b1f Add stars pay(beta) 2024-11-11 22:26:10 +03:00
Vladislav Lisitsyn e9ff030b4b Merge pull request #81 from izzzzzi/main
Add crypto(beta), fix import, fix requirements.txt
2024-11-11 22:25:45 +03:00
Zakhar Izmaylov 84def9c3e3 Add crypto(beta), fix import, fix requirements.txt 2024-11-11 21:51:34 +03:00
Vladislav Lisitsyn 08daa6fcfc Update README.md 2024-11-11 17:51:44 +03:00
Vladislav Lisitsyn cd2d68d1b4 Update README.md 2024-11-11 17:51:12 +03:00
Vladislav Lisitsyn c480c03c31 Merge pull request #80 from izzzzzi/main
Add base cryptobot
2024-11-11 17:48:31 +03:00
Vladless 00efd1ce16 minPython 3.10/py3xui/subs/rm_artefacts 2024-11-11 17:47:26 +03:00
Zakhar Izmaylov 1869db7615 Add base cryptobot 2024-11-11 14:26:00 +03:00
Vladless a9e06b7a3b isort settings 2024-11-11 09:22:14 +03:00
Vladless 223559e44c bug fix 2024-11-11 07:42:22 +03:00
Vladislav Lisitsyn 627fd595da Merge pull request #79 from izzzzzi/main
Add make and config for formatting
2024-11-10 23:12:42 +03:00
Zakhar Izmaylov 56a4f8a51e Add make and config for formatting 2024-11-10 11:02:15 +03:00
Vladless c17dc371da reset traffic 2024-11-10 03:31:19 +03:00
Vladless 5e17847717 bug and fix 2024-11-10 02:25:36 +03:00
Vladislav Lisitsyn 0ec26c282b Merge pull request #78 from izzzzzi/main
Refactoring and fix
2024-11-09 23:59:29 +03:00
Zakhar Izmaylov 570ffcf818 Fix 2024-11-09 23:20:08 +03:00
Zakhar Izmaylov 5118256a1b Small fix 2024-11-09 16:23:39 +03:00
Zakhar Izmaylov d851692c0d Refactoring admin_panel.py 2024-11-09 15:54:16 +03:00
Zakhar Izmaylov a5a8560640 Refactoring notifications.py and commands.py 2024-11-09 15:49:05 +03:00
Zakhar Izmaylov 9518ef9f07 Refactoring profile.py 2024-11-09 15:35:22 +03:00
Zakhar Izmaylov f05cfb1c11 Refactoring start.py 2024-11-09 14:38:30 +03:00
Zakhar Izmaylov 0668335a5d Add middleware logging 2024-11-09 12:20:07 +03:00
Zakhar Izmaylov d40e6b4992 Add middlewaer datatabase # TODO 2024-11-09 12:17:07 +03:00
Zakhar Izmaylov 7d51ff3a85 Delete wraps 2024-11-09 12:01:24 +03:00
Zakhar Izmaylov eec5e8c887 isort and black 2024-11-09 11:44:15 +03:00
Zakhar Izmaylov b979748679 Migrate logging for loguru 2024-11-09 11:41:48 +03:00
Zakhar Izmaylov 71ded92b53 Fix logging 2024-11-09 11:33:17 +03:00
Zakhar Izmaylov 1ae030a256 Fix callback_query 2024-11-09 11:17:11 +03:00
Zakhar Izmaylov b0e3a8482c Fix PAYMENT_OPTIONS and RENEWAL_PLANS 2024-11-09 10:52:41 +03:00
Zakhar Izmaylov f44c6cb2e3 Refactoring and fix 2024-11-09 10:36:16 +03:00
Vladislav Lisitsyn a0d3eb3a52 Update README.md 2024-11-08 07:13:42 +03:00
Vladislav Lisitsyn e8aed23e1a Update README.md 2024-11-08 07:08:20 +03:00
Vladislav Lisitsyn 7300cc58c3 Merge pull request #77 from Vladless/subscriptions-feature
preview v2.
2024-11-08 07:07:00 +03:00
Vladless 045d362e93 preview v2. 2024-11-08 07:06:32 +03:00
Vladislav Lisitsyn eba46cd587 Merge pull request #76 from Vladless/subscriptions-feature
preview v3.
2024-11-08 07:05:30 +03:00
Vladless 31b87e7dd7 preview v3. 2024-11-08 07:04:51 +03:00
Vladislav Lisitsyn 7539a1cc93 Merge pull request #75 from Vladless/subscriptions-feature
v3. Поправлены уведомления для новой версии.
2024-11-08 06:53:12 +03:00
Vladless 109816199f v3. Поправлены уведомления для новой версии. 2024-11-08 06:52:37 +03:00
Vladislav Lisitsyn 1d56f85e6d Merge pull request #74 from Vladless/subscriptions-feature
v3. Админка переведена на новую версию.
2024-11-08 06:43:15 +03:00
Vladless ffcecb2d6c v3. Админка переведена на новую версию. 2024-11-08 06:42:12 +03:00
Vladislav Lisitsyn e598bf502f Merge pull request #73 from Vladless/subscriptions-feature
Админка
2024-11-08 06:03:34 +03:00
Vladless 918436f2cc v3. Админка переведена на новую версию. 2024-11-08 06:02:29 +03:00
Vladless 33e33b4be9 v3. Админка переведена на новую версию. 2024-11-08 06:02:07 +03:00
Vladislav Lisitsyn c342eb5d81 Update README.md 2024-11-07 07:58:58 +03:00
Vladislav Lisitsyn 3a178c5ab8 Update README.md 2024-11-07 07:58:41 +03:00
Vladislav Lisitsyn 166c4385a9 Merge pull request #72 from Vladless/subscriptions-feature
v3. Полноценные подписки
2024-11-07 07:56:55 +03:00
Vladless 9a67bf3976 v3. Полноценные подписки 2024-11-07 07:53:29 +03:00
Vladislav Lisitsyn 9c61a8475c Merge pull request #71 from Vladless/subscriptions-feature
Проверка ключей
2024-11-05 22:02:36 +03:00
Vladless 7b5cbb8629 Проверка ключей 2024-11-05 22:00:32 +03:00
Vladislav Lisitsyn 1e813d506f Merge pull request #70 from Vladless/subscriptions-feature
Админка. Расширение
2024-11-04 01:10:40 +03:00
Vladless 43a95a7d5a Админка. Расширение 2024-11-04 01:10:02 +03:00
Vladislav Lisitsyn 787a1e13c0 Merge pull request #69 from Vladless/subscriptions-feature
Переработка уведомлений
2024-11-03 23:42:07 +03:00
Vladless 6abeae35f8 Переработка уведомлений 2024-11-03 23:41:26 +03:00
Vladislav Lisitsyn cb94158bd0 Merge pull request #68 from Vladless/subscriptions-feature
Профиль. Логи
2024-11-03 21:54:11 +03:00
Vladless cf0924959c Профиль. Логи 2024-11-03 21:52:59 +03:00
Vladislav Lisitsyn 2ad8627215 Merge pull request #67 from Vladless/subscriptions-feature
Уведомления/Профиль. Логгирование
2024-11-03 21:40:41 +03:00
Vladless c2429a0269 Профиль. Логи 2024-11-03 21:39:56 +03:00
Vladless fd35207f2f Уведомления. Правки 2024-11-03 21:38:26 +03:00
Vladislav Lisitsyn a9e318136f Merge pull request #66 from Vladless/subscriptions-feature
Уведомления. Правки
2024-11-03 01:49:54 +03:00
Vladless 5f46156907 Уведомления. Правки 2024-11-03 01:49:02 +03:00
Vladislav Lisitsyn ee7928dda8 Update README.md 2024-11-03 00:34:00 +03:00
Vladislav Lisitsyn b39634d4fa Merge pull request #65 from Vladless/subscriptions-feature
добавление freekassa
2024-11-03 00:32:38 +03:00
Vladless 23cd7c9e60 добавление freekassa 2024-11-03 00:31:57 +03:00
Vladislav Lisitsyn 9ba1cacdaf Update README.md 2024-11-02 04:30:23 +03:00
Vladislav Lisitsyn cc5d47e546 Update README.md 2024-11-02 04:30:01 +03:00
Vladislav Lisitsyn 4ba6ef8f0d Update README.md 2024-11-02 04:29:12 +03:00
Vladislav Lisitsyn 9003dc5841 Merge pull request #64 from Vladless/subscriptions-feature
Подписки. Обновление ключей. Кнопки
2024-11-02 04:25:49 +03:00
Vladislav Lisitsyn ba2b2c63a4 Merge branch 'main' into subscriptions-feature 2024-11-02 04:25:39 +03:00
Vladless 4ada912f98 удобная смена подписки 2024-11-02 04:17:54 +03:00
Vladless 9a10859109 перевод на v2raytun 2024-11-02 03:10:49 +03:00
Vladless 5131c82b75 перевод на v2raytun 2024-11-02 01:17:04 +03:00
Vladless e590c08095 перевод на v2raytun 2024-11-01 23:52:21 +03:00
Vladislav Lisitsyn e7dcd58314 Update README.md 2024-10-31 23:59:20 +03:00
Vladislav Lisitsyn bebd051f47 Merge pull request #63 from Vladless/subscriptions-feature
Кнопка автодобавления конфигурации в клиентское приложение
2024-10-30 20:07:12 +03:00
Vladless 56babedde6 Кнопка автодобавления конфигурации в клиентское приложение 2024-10-30 20:05:38 +03:00
Vladislav Lisitsyn e9e4973dc9 Merge pull request #62 from Vladless/subscriptions-feature
Подписки. Полная версия
2024-10-30 18:03:36 +03:00
Vladless 8eb76ff9e4 Подписки. Полная версия 2024-10-30 18:03:04 +03:00
Vladless 69de84d430 ReadMe 2024-10-30 04:50:53 +03:00
Vladless 52b41fe521 ReadMe 2024-10-30 03:57:27 +03:00
Vladless 61527de86c Подписки. initial commit 2024-10-30 02:35:27 +03:00
Vladislav Lisitsyn 8e08a3cc7e Merge pull request #61 from Vladless/new1
правки
2024-10-30 00:02:43 +03:00
Vladislav Lisitsyn 63dd62b202 Delete handlers/__pycache__ directory 2024-10-30 00:02:27 +03:00
Vladless be3cf72012 правки 2024-10-30 00:00:18 +03:00
Vladislav Lisitsyn 8386776121 Merge pull request #60 from Vladless/new1
исправлен траффик при продлении
2024-10-29 23:46:37 +03:00
Vladless 50ac2f8ec3 исправлен траффик при продлении 2024-10-29 23:45:27 +03:00
Vladislav Lisitsyn 9146fe892b Merge pull request #59 from Vladless/new1
Уведомления. Имя сервера вместо id
2024-10-29 23:20:22 +03:00
Vladless eb783d847b Уведомления. Имя сервера вместо id 2024-10-29 23:19:54 +03:00
Vladislav Lisitsyn 70fbeb2244 Merge pull request #57 from Vladless/new1
rm update_bot
2024-10-28 19:50:29 +03:00
Vladless 474aabd83f rm update_bot 2024-10-28 19:49:03 +03:00
Vladislav Lisitsyn de326fbb37 Merge pull request #56 from Vladless/new1
New1
2024-10-28 01:01:44 +03:00
Vladislav Lisitsyn 49989bdcb2 Merge branch 'main' into new1 2024-10-28 01:01:34 +03:00
Vladless f5394154a1 ReadMe 2024-10-28 01:00:20 +03:00
Vladless 0aadf6642e rm 2024-10-28 00:54:22 +03:00
Vladless 2922ed47d4 rm 2024-10-28 00:47:09 +03:00
Vladislav Lisitsyn 9299c2ce1c Merge pull request #55 from Vladless/new1
rm
2024-10-28 00:39:58 +03:00
Vladislav Lisitsyn b721bac9c3 rm 2024-10-28 00:35:07 +03:00
Vladislav Lisitsyn ace38846d0 rm 2024-10-28 00:30:27 +03:00
Vladislav Lisitsyn 57163395df Merge pull request #54 from Vladless/new1
Уведомления. Продление по кнопке
2024-10-27 23:48:03 +03:00
Vladless 4df2d0fed1 Уведомления. Продление по кнопке 2024-10-27 22:58:56 +03:00
Vladislav Lisitsyn d87028e3a4 Merge pull request #53 from Vladless/new1
/admin. GUI интерфейс в боте
2024-10-27 22:47:09 +03:00
Vladless d89e90e22d /admin. GUI интерфейс в боте 2024-10-27 22:45:16 +03:00
Vladislav Lisitsyn 101e701b20 Merge pull request #52 from Vladless/new1
Админ. Установка времени ключей
2024-10-27 06:16:55 +03:00
Vladless df97d6b716 Админ. Установка времени ключей 2024-10-27 06:15:34 +03:00
Vladislav Lisitsyn 4173b31049 Merge pull request #51 from Vladless/new1
New1
2024-10-27 03:55:35 +03:00
Vladless 13be4e8a5e обложка 2024-10-27 03:54:52 +03:00
Vladless 5fb340f68e обложка 2024-10-27 03:54:19 +03:00
Vladislav Lisitsyn d13926b7d8 Delete handlers/__pycache__ directory 2024-10-27 03:20:45 +03:00
Vladislav Lisitsyn 86fddcd37b Merge pull request #50 from Vladless/new1
Полная реструктуризация. Тексты все в texts.py
2024-10-27 03:17:40 +03:00
Vladless fc8d853688 Полная реструктуризация. Тексты все в texts.py 2024-10-27 03:16:58 +03:00
Vladislav Lisitsyn 88a89410bb Merge pull request #49 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:30:08 +03:00
Vladless 857dd9a8ec Порядок. Исправления мелких недочетов 2024-10-27 01:29:34 +03:00
Vladislav Lisitsyn a7461d0ca5 Merge pull request #48 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:10:58 +03:00
Vladless d61237fdb1 Порядок. Исправления мелких недочетов 2024-10-27 01:03:00 +03:00
Vladislav Lisitsyn eaaef9f326 Update trial_key.py 2024-10-26 16:05:58 +03:00
Vladislav Lisitsyn 3f288e3618 Merge pull request #47 from Vladless/new1
админ. команда баланса
2024-10-26 03:11:00 +03:00
Vladless 23327dc33e админ. команда баланса 2024-10-26 03:10:28 +03:00
Vladislav Lisitsyn 6be434b7ea Merge pull request #46 from Vladless/new1
админка в боте
2024-10-26 02:45:43 +03:00
Vladless c3df15a25e админка в боте 2024-10-26 02:45:19 +03:00
Vladislav Lisitsyn 2c46e0ce30 Merge pull request #45 from Vladless/new1
админка в боте
2024-10-26 02:40:03 +03:00
Vladless 9150240679 админка в боте 2024-10-26 02:39:35 +03:00
Vladislav Lisitsyn 5e988d9aab Merge pull request #44 from Vladless/new1
админка в боте
2024-10-26 02:34:39 +03:00
Vladless 2f32af20fe админка в боте 2024-10-26 02:34:05 +03:00
Vladislav Lisitsyn d74417cfb0 Merge pull request #43 from Vladless/new1
ReadMe
2024-10-26 02:19:28 +03:00
Vladless d5ad01db94 ReadMe 2024-10-26 02:18:48 +03:00
Vladislav Lisitsyn 01ab264a8e Merge pull request #42 from Vladless/new1
Docker и docker-compose
2024-10-26 02:13:46 +03:00
Vladless 2cf2795acc Docker и docker-compose 2024-10-26 02:12:34 +03:00
Vladislav Lisitsyn b08fadabb3 Merge pull request #41 from Vladless/new1
создание ключа в 1 кнопку
2024-10-26 00:57:22 +03:00
Vladless 150535c717 создание ключа в 1 кнопку 2024-10-26 00:55:03 +03:00
Vladislav Lisitsyn aeafa9201b Merge pull request #40 from Vladless/new1
Автопродление ключа
2024-10-25 22:21:07 +03:00
Vladless e71698891b Автопродление ключа 2024-10-25 22:20:12 +03:00
Vladislav Lisitsyn affb88089d Merge pull request #39 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:56:48 +03:00
Vladless c58704ba24 Логи. Уведомления. Тексты 2024-10-25 20:56:02 +03:00
Vladislav Lisitsyn d32f9dfed0 Merge pull request #38 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:46:56 +03:00
Vladless 25c106958a Логи. Уведомления. Тексты 2024-10-25 20:45:12 +03:00
Vladislav Lisitsyn aa046fdc59 Merge pull request #37 from Vladless/new1
New1
2024-10-22 02:07:33 +03:00
Vladless 505ab67828 основной текст в отдельном файле 2024-10-22 02:06:58 +03:00
Vladless c30f8800a0 основной текст в отдельном файле 2024-10-22 02:02:17 +03:00
Vladislav Lisitsyn aaa46dd23e Merge pull request #36 from Vladless/new1
основной текст в отдельном файле
2024-10-22 01:55:10 +03:00
Vladless 9829e7cd3e основной текст в отдельном файле 2024-10-22 01:53:50 +03:00
Vladless ba3352aa46 поправка подсчета людей 2024-10-22 01:00:56 +03:00
Vladless 83040169e6 автоввод паролей 2024-10-22 00:30:30 +03:00
Vladless 9ffb27252f Бэкапы. команда ручного бэкапа 2024-10-22 00:25:16 +03:00
Vladless eac985b031 Бэкапы. команда ручного бэкапа 2024-10-22 00:13:13 +03:00
Vladless d3f4374ee1 Бэкапы. Отправка админу 2024-10-21 23:59:26 +03:00
Vladless d2e1dec293 requirements 2024-10-21 23:40:50 +03:00
Vladless 2afde914f9 версия бота. Релиз 2024-10-21 03:36:29 +03:00
Vladless e98d290954 версия бота. Релиз 2024-10-21 02:52:34 +03:00
Vladless f41a41a0d7 напоминание за сутки у активного клиента 2024-10-21 02:47:59 +03:00
Vladless d4afd998c7 ReadMe.md 2024-10-21 02:35:53 +03:00
Vladless 48c9f07e2b напоминание за сутки у активного клиента 2024-10-21 02:26:15 +03:00
Vladless 6ef7a2cca9 ReadMe.md 2024-10-21 02:16:37 +03:00
Vladless 9659100f26 завершение работы без убивания процесса 2024-10-21 02:12:15 +03:00
Vladless bc7594da66 завершщение работы без убивания процесса 2024-10-21 02:08:47 +03:00
Vladless 456d9d4d3c резервные копии базы данных и уведомления 2024-10-21 01:23:27 +03:00
Vladless bb978c0d16 автопродление ключа 2024-10-13 23:11:39 +03:00
Vladless e6380f5bda уведомление о неиспользованном триале 2024-10-13 04:02:47 +03:00
Vladless 2eb124561b ввод своей суммы для пополнения баланса 2024-10-13 03:37:14 +03:00
Vladless c1917ceea5 значительная переработка интерфейса 2024-10-13 02:51:45 +03:00
Vladless 743feda746 значительная переработка интерфейса 2024-10-12 03:14:01 +03:00
Vladless 08650d1a92 ReadMe 2024-10-09 04:01:08 +03:00
Vladislav Lisitsyn bc9f6267e8 Merge pull request #35 from Vladless/youkassa
ReadMe
2024-10-09 03:41:23 +03:00
Vladislav Lisitsyn e65cad8728 Merge branch 'main' into youkassa 2024-10-09 03:41:11 +03:00
Vladless d22913e770 ReadMe 2024-10-09 03:40:31 +03:00
Vladislav Lisitsyn 5445e5cfde Merge pull request #34 from Vladless/youkassa
Уведомления
2024-10-09 02:16:07 +03:00
Vladless 03c2da001d логика, отправка уведомлений всем 2024-10-09 02:14:41 +03:00
Vladless 927e1ba7ab логика, отправка уведомлений всем 2024-10-09 02:13:42 +03:00
Vladless fd19d34be8 логика, отправка уведомлений всем 2024-10-09 02:09:22 +03:00
Vladislav Lisitsyn b4f7f24ef0 Merge pull request #33 from Vladless/youkassa
aiohttp, уведомления, правки
2024-10-09 01:10:30 +03:00
Vladislav Lisitsyn 5feafaed44 Merge branch 'main' into youkassa 2024-10-09 01:10:23 +03:00
Vladless 0a7121253a aiohttp, уведомления, правки 2024-10-09 00:51:22 +03:00
Vladislav Lisitsyn 9b30b19d7b Merge pull request #31 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 03:10:09 +03:00
Vladless af1bea7e7d правка сообщения пригласить 2024-10-04 03:09:32 +03:00
Vladislav Lisitsyn e22b32ce26 Merge pull request #30 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 02:45:26 +03:00
Vladless efb2b47411 правка сообщения пригласить 2024-10-04 02:44:32 +03:00
Vladislav Lisitsyn 5afca6fe52 Merge pull request #29 from Vladless/youkassa
Youkassa
2024-10-04 02:39:12 +03:00
Vladless 1410757bde правка сообщения пригласить 2024-10-04 02:35:48 +03:00
Vladless 97c01c5b29 реферальная система 2024-10-04 02:31:05 +03:00
Vladislav Lisitsyn 964fce10ce Merge pull request #28 from Vladless/youkassa
Youkassa
2024-10-04 02:13:12 +03:00
Vladless 62cc3e9cf4 реферальная система 2024-10-04 02:06:16 +03:00
Vlad 02acf69ff6 чистота кода 2024-10-03 07:20:13 +03:00
Vladislav Lisitsyn 2884d5f34b Merge pull request #27 from Vladless/youkassa
Youkassa
2024-10-03 07:09:08 +03:00
Vlad 05203b7609 чистота кода 2024-10-03 07:08:17 +03:00
Vlad 88e8ca9f8b уведомления за 10 часов и при истечении 2024-10-03 06:52:24 +03:00
Vladislav Lisitsyn edb1073a35 Merge pull request #26 from Vladless/youkassa
Выбор сервера
2024-10-03 06:45:59 +03:00
Vlad ab845682ad поправлен тег 2024-10-03 06:45:07 +03:00
Vlad dc1df48d8b заполненность серверов в процентах 2024-10-03 06:42:40 +03:00
Vlad 7a70acac72 работающий перенос ключей 2024-10-03 06:31:44 +03:00
Vlad edc7b987b9 работающий перенос ключей 2024-10-03 06:20:38 +03:00
Vlad f262543902 выбор сервера, переработка ссылок 2024-10-03 04:43:55 +03:00
Vladislav Lisitsyn 41f86f22bf Merge pull request #25 from Vladless/youkassa
смена слогана
2024-10-02 05:16:33 +03:00
Vlad 127d9e8d3b смена слогана 2024-10-02 05:12:55 +03:00
Vladislav Lisitsyn 9135581656 Merge pull request #24 from Vladless/youkassa
Youkassa
2024-10-02 05:04:01 +03:00
Vlad f1b9f42f35 смена слогана 2024-10-02 05:03:08 +03:00
Vlad 45ddf524eb смена слогана 2024-10-02 04:51:48 +03:00
Vlad 92eb1c6bce смена слогана 2024-10-02 04:46:05 +03:00
Vladislav Lisitsyn 9f6d48f389 Merge pull request #23 from Vladless/youkassa
новостные сообщения, поправленный requirements
2024-10-02 04:16:36 +03:00
Vlad 151d529077 новостные сообщения, поправленный requirements 2024-10-02 04:16:04 +03:00
Vladislav Lisitsyn fbb692d145 Merge pull request #22 from Vladless/youkassa
истекшие ключи автоматически удаляются
2024-10-02 02:29:44 +03:00
Vlad 82db609119 истекшие ключи автоматически удаляются 2024-10-02 02:06:42 +03:00
Vlad 7a97b03fab порядок в стурктуре проекта, isort, импорты 2024-10-02 01:35:39 +03:00
Vlad 93a17e75f1 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:19:48 +03:00
Vladislav Lisitsyn 2e5ed0ab98 Merge pull request #21 from Vladless/youkassa
Доработка платежей, уведомления и переработка интерфейса
2024-10-02 01:13:35 +03:00
Vlad 69b9208624 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:13:04 +03:00
Vladislav Lisitsyn 994684f78f Merge pull request #20 from Vladless/youkassa
Оплата и пополнение баланса через Юкасса
2024-10-01 14:27:58 +03:00
Vlad e063097117 Оплата и пополнение баланса через Юкасса 2024-10-01 14:27:16 +03:00
Vladislav Lisitsyn 64929d81e3 Merge pull request #19 from Vladless/new
WebHooks for YouKassa
2024-10-01 02:10:28 +03:00
Vlad 47ae27d4ec WebHooks for YouKassa 2024-10-01 01:20:29 +03:00
Vladislav Lisitsyn f63987c676 Merge pull request #18 from Vladless/new
дополнительные варианты оплаты и продления
2024-10-01 00:47:58 +03:00
Vlad bd534eb547 дополнительные варианты оплаты и продления 2024-10-01 00:15:01 +03:00
Vladislav Lisitsyn c470b75748 Update README.md 2024-09-29 18:04:48 +03:00
Vladislav Lisitsyn 773e56b5a1 Update README.md 2024-09-29 02:31:26 +03:00
Vladislav Lisitsyn d094e621b5 Update README.md 2024-09-29 02:31:06 +03:00
Vladislav Lisitsyn b62b82cbf1 Update README.md 2024-09-29 02:23:52 +03:00
Vladislav Lisitsyn cd39682d60 Merge pull request #17 from Vladless/new
isort и правка импортов
2024-09-29 02:15:16 +03:00
Vlad f6fe9037b3 isort и правка импортов 2024-09-29 02:14:49 +03:00
Vladislav Lisitsyn 4312d8b14e Update README.md 2024-09-29 02:13:32 +03:00
Vladislav Lisitsyn 947f9a55ad Create README.md 2024-09-29 02:11:50 +03:00
Vladislav Lisitsyn be096fe90d Merge pull request #15 from Vladless/new
правки и доработки интерфейса
2024-09-28 13:13:23 +03:00
Vlad 958a8fb74c правки и доработки интерфейса 2024-09-28 13:12:04 +03:00
Vladislav Lisitsyn f5fa8264f6 Merge pull request #14 from Vladless/new
правки и доработки интерфейса
2024-09-28 04:09:11 +03:00
Vlad 1036a26989 правки и доработки интерфейса 2024-09-28 03:49:43 +03:00
Vlad cef411e0c3 правки и доработки интерфейса 2024-09-28 03:44:08 +03:00
Vlad 3ed8bb5040 правки и доработки интерфейса 2024-09-28 03:26:28 +03:00
Vladislav Lisitsyn b462e16f49 Merge pull request #13 from Vladless/new
пополнение баланса даже без ключа
2024-09-26 03:12:12 +03:00
Vlad 6ea92d4d4a пополнение баланса даже без ключа 2024-09-26 03:11:22 +03:00
Vladislav Lisitsyn 3bbfdf787e Merge pull request #12 from Vladless/new
отображение ключей и создание на сутки
2024-09-26 02:39:03 +03:00
Vlad 754c272584 отображение ключей и создание на сутки 2024-09-26 02:38:32 +03:00
Vladislav Lisitsyn dd122e3c4e Merge pull request #11 from Vladless/new
меню
2024-09-25 03:42:39 +03:00
Vlad b97ccbbfaf меню 2024-09-25 02:24:25 +03:00
Vladislav Lisitsyn 755dfc6ff1 Merge pull request #10 from Vladless/new
New
2024-09-25 01:58:47 +03:00
Vlad 0bea6e9618 добавление уведомлений 2024-09-25 01:58:10 +03:00
Vlad b133e309a5 изменена логика поиска ключей в базе данных 2024-09-25 01:51:26 +03:00
Vlad 11d30fe58b изменена логика поиска ключей в базе данных 2024-09-25 01:45:37 +03:00
Vlad ecad56e445 правки 2024-09-24 02:36:30 +03:00
Vlad aba0ca137d правки 2024-09-24 02:35:54 +03:00
Vlad c381963cf9 правки 2024-09-23 19:08:41 +03:00
Vlad 0638b85d9d правки 2024-09-23 18:54:16 +03:00
Vlad 8fc7d607d1 правки 2024-09-23 16:48:32 +03:00
Vladislav Lisitsyn cddd31f1b4 Merge pull request #9 from Vladless/new
New
2024-09-23 16:42:31 +03:00
Vlad 14d13fd444 правки 2024-09-23 16:35:46 +03:00
Vlad 91157667fd isort 2024-09-21 03:08:57 +03:00
Vladislav Lisitsyn 07dc51a1f7 Merge pull request #8 from Vladless/new
New
2024-09-21 03:00:53 +03:00
Vlad f97d4d412b валидация email 2024-09-21 02:59:25 +03:00
Vlad bcc7760f42 удалены лишние импорты 2024-09-21 01:29:33 +03:00
Vlad 7b13cf92ea основные функции завершены 2024-09-21 01:21:35 +03:00
Vlad d73777f67c пробник создается всего один раз 2024-09-21 01:13:12 +03:00
Vlad 15b702f8ce правильная логика продления ключей 2024-09-21 00:56:08 +03:00
Vlad 83c2487ef2 переход на postresql 2024-09-21 00:21:23 +03:00
Vlad 58602883d7 исправление ошибки в моих ключах 2024-09-20 23:24:39 +03:00
Vlad 2e9a63ad4f исправление ошибки в моих ключах 2024-09-20 14:25:22 +03:00
Vlad 915a071e98 доработка кода 2024-09-20 05:48:09 +03:00
Vlad d66bf51b91 редизайн, кнопки назад 2024-09-20 03:48:41 +03:00
Vlad 2b869aa9b8 апдейт юзеров 2024-09-19 03:41:44 +03:00
Vlad bfc7ffe024 оптимизация 2024-09-18 04:09:38 +03:00
Vladislav Lisitsyn 7265b2c35e Merge pull request #7 from Vladless/new
New
2024-09-18 01:45:13 +03:00
Vlad 864c099b2f переработка интерфейса 2024-09-18 01:44:27 +03:00
Vlad b181aff960 работающая оплата с подтверждением после перевода 2024-09-17 23:51:03 +03:00
Vlad 3d3a1c017a структура проекта 2024-09-17 03:11:36 +03:00
Vladislav Lisitsyn ab10e95430 Merge pull request #6 from Vladless/new
New
2024-09-17 03:08:57 +03:00
Vlad 232db222ca структура проекта 2024-09-17 03:08:07 +03:00
Vlad 5df49500c0 мои ключи 2024-09-17 02:38:52 +03:00
Vladislav Lisitsyn 649c7e6893 Merge pull request #5 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 01:45:40 +03:00
Vlad 20b0e09b6e рабочий вариант выдачи ключей через подтверждение 2024-09-17 01:45:12 +03:00
Vladislav Lisitsyn 506f0fb981 Merge pull request #4 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 00:40:01 +03:00
Vlad b3a8989927 рабочий вариант выдачи ключей через подтверждение 2024-09-17 00:39:06 +03:00
Vladislav Lisitsyn e631cfa0da Merge pull request #3 from Vladless/new
New
2024-09-16 23:01:51 +03:00
Vlad 76beb0ee41 опрос 2024-09-16 23:01:15 +03:00
Vlad 6e0090e90c опрос 2024-09-16 07:03:41 +03:00
Vlad e37b623e02 bot.1.0 2024-09-16 06:58:36 +03:00
Vlad e4001947d3 ключ, статистика, срок 2024-09-16 06:50:53 +03:00
Vladislav Lisitsyn 1a93b13640 Merge pull request #2 from Vladless/base
Base
2024-09-16 05:03:02 +03:00
Vlad 342c039ad8 добавлена дата окончания подписки 2024-09-16 04:02:17 +03:00
Vlad bd36a20873 логика статистики 2024-09-16 03:43:25 +03:00
Vlad 4db32703cf кнопка статистики 2024-09-16 03:34:05 +03:00
Vladislav Lisitsyn c5493f9d5a Delete bot_old.py 2024-09-16 03:16:01 +03:00
Vladislav Lisitsyn 3bedba085f Merge pull request #1 from Vladless/base
Добавлена начальная база данных
2024-09-13 03:40:05 +03:00
Vlad 56dd62fdf9 Добавлена начальная база данных 2024-09-13 03:39:21 +03:00
Vlad b53a1a4cea Начальный этап бота с выдачей ключей 2024-09-13 02:13:24 +03:00
161 changed files with 114883 additions and 18513 deletions
-23
View File
@@ -1,23 +0,0 @@
#!/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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 867 KiB

+3 -6
View File
@@ -7,7 +7,6 @@ __pycache__/
dist/
build/
*.egg-info/
alembic/
# Virtual environments
venv/
@@ -29,7 +28,6 @@ env/
# Sensitive configuration files
config.py
config.ini
alembic.ini
.env
# Backup files
@@ -53,9 +51,8 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
/logs
setup.py
.ruff_cache
.github/workflows/
setup.py
+3 -4
View File
@@ -1,7 +1,6 @@
formatting:
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
@echo "Running Ruff..." && ruff check . --fix
@echo "Running Ruff format..." && ruff format .
lint:
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff checks..." && ruff check .
+129 -62
View File
@@ -1,87 +1,154 @@
# 🚀 SoloBot
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
Три версии — море возможностей:
# Описание
- [v1.4](https://github.com/Vladless/Solo_bot/releases/tag/v1.4) — бот для продажи ключей VLESS:
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
![](preview.jpg)
## ⚙️ Основные возможности SoloBot
- [v2.3.1](https://github.com/Vladless/Solo_bot/releases/tag/v2.3.1) — стабильная версия подписок вместо ключей с
кнопками автодобавления в приложение:
| 📌 Раздел | 💡 Возможности |
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Мультипанель** | • Работа с панелями [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> |
![](preview_v_2.jpg)
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
</a>
- [v3.1](https://github.com/Vladless/Solo_bot/releases/tag/v3.1) — версия бота со значительным расширением возможностей.
## 🛠 Обновления
[Последние актуальные релизы.](https://github.com/Vladless/Solo_bot/releases)
Если вам не хватает функций — направьте их в [issue](https://github.com/Vladless/Solo_bot/issues), мы реализуем.
### SoloBot регулярно получает новые функции и улучшения.
## 📋 Оглавление
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
1. [Описание](#-описание)
2. [Стек технологий](#-стек-технологий)
3. [Установка](#-установка)
4. [Конфигурация](#-конфигурация)
5. [Запуск](#-запуск)
6. [Контакты](#-solobot-в-telegram-и-полная-версия)
# Наш сайт и полная версия
---
### Переходи на наш сайт!
<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>
## 📖 Описание
SoloBot реализует множество функций, включая:
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
- **Выдача подписок на различные периоды** (1 месяц, 3 месяца, полгода, год).
- **Использование тестового периода подписки.**
- **Продление ключей** на указанные периоды.
- **Полный контроль** клиента над своими ключами:
- **Просмотр** информации о ключе (сервер, оставшееся время, сам ключ).
- **Продление** ключей.
- **Удаление** ключей.
- **Смена локации** (перемещение ключа между серверами).
- **Поддержка нескольких ключей и устройств** для одного клиента.
- **Реферальная программа** с уникальной ссылкой для приглашений.
- Доступ к **инструкциям** для подключения к сервису.
- Пополнение баланса:
* **Юкасса** (Самозанятость и ИП)
* **Юмани** (Физические Лица) ([TrackLine](https://github.com/TrackLine))
* **FreeKassa** (Физические Лица)
* **Robokassa** (Физические Лица)
* **Cryptobot** (Криптовалюта) ([izzzzzi](https://github.com/izzzzzi))
* **Звезды Telegram**
- Периодические **бэкапы базы данных клиентов**.
- Уведомления:
- Произвольные сообщения через панель администратора.
- Уведомления о неиспользованных пробниках.
- Уведомления об истекающих ключах (за сутки, за 6 часов и в момент истечения).
- **Чат поддержки** и канал для связи.
- **Автоматическое продление ключа** при наличии достаточного баланса.
- **Удобная панель администратора прямо в боте**
- Серверы:
- **Мультисерверность** — добавление сервера прямо в админке
- **Проверка на доступность** — бот следит за серверами и их откликом
- **Балансировка** — бот выдает подписку на наименее загруженный сервер
![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 уже помог сотням пользователей в нашем сообществе:
- **Python** версии 3.10 (3.12, для последних версий).
- **PostgreSQL** — для хранения данных.
- **aiogram** — для взаимодействия с Telegram API.
- **aiohttp** — для API запросов к панелям.
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
<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>
---
## ⚙️ Установка
### 1️⃣ Шаг 1: Клонирование репозитория
Клонируйте репозиторий и перейдите в его директорию:
```bash
git clone https://github.com/Vladless/Solo_bot.git
cd solo_bot
```
### 2️⃣ Шаг 2: Создание и подключение виртуального окружения
Создайте виртуальное окружение **Python** и перейдите в сессию:
```
python3 -m venv venv
source venv/bin/activate
```
### 3️⃣ Шаг 3: Установка зависимостей
Установите зависимости:
```
pip install -r requirements.txt
```
### 🛠️ Конфигурация
Для правильной работы вам нужно:
* Установить и запустить **PostgreSQL**, создать пользователя для работы с базой данных и выдать ему права
* Настроить ваш сервер для работы с ботом, выпустить SSL сертификат для домена
* Настроить вебхуки и указать пути до них через любой веб-сервер, например **nginx**
* Создать файл config.py в корневой папке проекта с вашими данными:
```
API_TOKEN = "токен вашего бота телеграм"
ADMIN_USERNAME = "логин от вашей панели x-ray"
ADMIN_PASSWORD = "пароль от вашей панели x-ray"
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
AUTH_URL = f"{API_URL}/login/"
```
**Мы высылаем детальный гайд и недостающие файлы в поддержке бота!**
### 🚀 Запуск
Введите команду из виртуального окружения `venv/bin/activate`
```
python3 main.py
```
### 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/737fbc21-e4b1-4c69-9323-71284d34e454)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
Связаться с нами через [**➡ поддержку**](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)
+102
View File
@@ -0,0 +1,102 @@
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)
);
+65 -174
View File
@@ -1,209 +1,100 @@
import os
import subprocess
from datetime import datetime, timedelta
from pathlib import Path
from datetime import datetime
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,
BACKUP_SEND_MODE, BACKUP_CHANNEL_ID, BACKUP_CHANNEL_THREAD_ID, BACKUP_OTHER_BOT_TOKEN, BACKUP_CAPTION
)
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
from logger import logger
async def backup_database() -> Exception | None:
"""
Создает резервную копию базы данных и отправляет ее администраторам.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
backup_file_path, exception = _create_database_backup()
if exception:
logger.error(f"Ошибка при создании бэкапа базы данных: {exception}")
return exception
async def backup_database():
from bot import bot
try:
await _send_backup_to_admins(backup_file_path)
exception = _cleanup_old_backups()
if exception:
logger.error(f"Ошибка при удалении старых бэкапов базы данных: {exception}")
return exception
return None
if backup_file_path := _create_database_backup():
await _send_backup_to_admin(bot, backup_file_path)
_cleanup_old_backups()
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа базы данных: {e}")
return e
logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
def _create_database_backup() -> tuple[str | None, Exception | None]:
"""
Создает резервную копию базы данных PostgreSQL.
def _create_database_backup():
USER = DB_USER
HOST = "localhost"
BACKUP_DIR = BACK_DIR
DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
Returns:
Tuple[Optional[str], Optional[Exception]]: Путь к файлу бэкапа и исключение (если произошла ошибка)
"""
date_formatted = datetime.now().strftime("%Y-%m-%d-%H%M%S")
backup_dir = Path(BACK_DIR)
backup_dir.mkdir(parents=True, exist_ok=True)
filename = backup_dir / f"{DB_NAME}-backup-{date_formatted}.sql"
os.environ["PGPASSWORD"] = DB_PASSWORD
try:
os.environ["PGPASSWORD"] = DB_PASSWORD
subprocess.run(
[
"pg_dump",
"-U",
DB_USER,
USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
HOST,
"-F",
"c",
"-f",
str(filename),
BACKUP_FILE,
DB_NAME,
],
check=True,
capture_output=True,
text=True,
)
logger.info(f"Бэкап базы данных создан: {filename}")
return str(filename), None
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
return BACKUP_FILE
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при выполнении pg_dump: {e.stderr}")
return None, e
except Exception as e:
logger.error(f"Непредвиденная ошибка при создании бэкапа: {e}")
return None, e
finally:
if "PGPASSWORD" in os.environ:
del os.environ["PGPASSWORD"]
def _cleanup_old_backups() -> Exception | None:
"""
Удаляет бэкапы старше 3 дней.
Returns:
Optional[Exception]: Исключение в случае ошибки или None при успешном выполнении
"""
try:
backup_dir = Path(BACK_DIR)
if not backup_dir.exists():
return None
cutoff_date = datetime.now() - timedelta(days=3)
for backup_file in backup_dir.glob("*.sql"):
if backup_file.is_file():
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
backup_file.unlink()
logger.info(f"Удален старый бэкап: {backup_file}")
logger.info("Очистка старых бэкапов завершена")
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
return None
except Exception as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
return e
finally:
del os.environ["PGPASSWORD"]
async def create_backup_and_send_to_admins(client) -> None:
"""
Создает бэкап и отправляет администраторам через переданный клиент.
Args:
client: Клиент для работы с базой данных
"""
await client.login()
await client.database.export()
async def _send_backup_to_admins(backup_file_path: str) -> None:
"""
Отправляет файл бэкапа всем администраторам через Telegram.
Args:
backup_file_path: Путь к файлу бэкапа
Raises:
Exception: При ошибке отправки файла
"""
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
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}")
async def _send_backup_to_admin(bot, backup_file_path):
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
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(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен в канал: {channel_id} (топик: {thread_id})")
except Exception as 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()
with open(backup_file_path, "rb") as backup_file:
backup_input_file = BufferedInputFile(
backup_file.read(), filename=os.path.basename(backup_file_path)
)
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
for id in admin_ids:
await bot.send_document(id, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {id}")
else:
logger.error(f"Неизвестный BACKUP_SEND_MODE: {BACKUP_SEND_MODE}, fallback на default")
await send_default()
await bot.send_document(admin_ids, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа: {e}")
raise
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
def _cleanup_old_backups():
try:
subprocess.run(
[
"find",
BACK_DIR,
"-type",
"f",
"-name",
"*.sql",
"-mtime",
"+3",
"-exec",
"rm",
"{}",
";",
],
check=True,
)
logger.info("Старые бэкапы удалены.")
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
async def create_backup_and_send_to_admins(xui):
await xui.login()
await xui.database.export()
+26 -188
View File
@@ -1,206 +1,44 @@
import traceback
import os
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
import subprocess
from aiogram.types import ErrorEvent
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from config import API_TOKEN
from logger import logger
from middlewares.admin import AdminMiddleware
from middlewares.database import DatabaseMiddleware
from middlewares.delete import DeleteMessageMiddleware
from middlewares.logging import LoggingMiddleware
from middlewares.user import UserMiddleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
def get_git_commit_number() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
dp.message.middleware(AdminMiddleware())
dp.callback_query.middleware(AdminMiddleware())
dp.message.middleware(UserMiddleware())
dp.callback_query.middleware(UserMiddleware())
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
dp.message.middleware(DatabaseMiddleware())
dp.callback_query.middleware(DatabaseMiddleware())
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 ""
dp.message.outer_middleware(DeleteMessageMiddleware())
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
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:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid"
in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
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 Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
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"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
@dp.error()
async def error_handler(event: ErrorEvent):
logger.error(
"Ошибка в боте:\n"
f"Исключение: {event.exception}\n"
f"Тип: {type(event.exception)}\n"
f"Update: {event.update}\n"
f"Трассировка:\n{traceback.format_exc()}"
)
-469
View File
@@ -1,469 +0,0 @@
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()
+115
View File
@@ -0,0 +1,115 @@
import py3xui
from config import LIMIT_IP
from logger import logger
async def add_client(
xui,
client_id: str,
email: str,
tg_id: str,
limit_ip: int,
total_gb: int,
expiry_time: int,
enable: bool,
flow: str,
inbound_id: int,
):
"""
Adds a client to the server via 3x-ui.
"""
try:
await xui.login()
client = py3xui.Client(
id=client_id,
email=email.lower(),
limit_ip=limit_ip,
total_gb=total_gb,
expiry_time=expiry_time,
enable=enable,
tg_id=tg_id,
sub_id=email,
flow=flow,
)
response = await xui.client.add(inbound_id, [client])
logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.")
return response if response else {"status": "failed"}
except Exception as e:
logger.error(f"Ошибка при добавлении клиента {email}: {e}")
return {"status": "failed", "error": str(e)}
async def extend_client_key(
xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int
):
"""
Функция для обновления срока действия ключа клиента по email.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} не найден.")
return
if not client.id:
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
return
logger.info(
f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}"
)
client.id = client_id
client.expiry_time = new_expiry_time
client.flow = "xtls-rprx-vision"
client.sub_id = email
client.total_gb = total_gb
client.enable = True
client.limit_ip = LIMIT_IP
client.inbound_id = inbound_id
await xui.client.update(client.id, client)
await xui.client.reset_stats(inbound_id, email)
logger.info(
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
)
except Exception as e:
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
async def delete_client(
xui,
inbound_id: int,
email: str,
client_id: str,
) -> bool:
"""
Функция для удаления клиента с сервера 3x-ui.
Возвращает True при успешном удалении, иначе False.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
return False
client.id = client_id
await xui.client.delete(inbound_id, client.id)
logger.info(f"Клиент с ID {client_id} был удален успешно.")
return True
except Exception as e:
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
return False
+1269
View File
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
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
@@ -1,14 +0,0 @@
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
@@ -1,121 +0,0 @@
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
@@ -1,12 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,7 +0,0 @@
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
@@ -1,197 +0,0 @@
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
@@ -1,201 +0,0 @@
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
@@ -1,148 +0,0 @@
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
@@ -1,48 +0,0 @@
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
@@ -1,278 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,169 +0,0 @@
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
@@ -1,130 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,209 +0,0 @@
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()
+5 -1
View File
@@ -1,3 +1,4 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
@@ -7,6 +8,9 @@ from config import ADMIN_ID
class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool:
try:
return message.from_user.id in ADMIN_ID
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
return message.from_user.id in admin_ids
return message.from_user.id == admin_ids
except Exception:
return False
-8
View File
@@ -1,8 +0,0 @@
from aiogram.enums import ChatType
from aiogram.filters import BaseFilter
from aiogram.types import Chat, TelegramObject
class IsPrivateFilter(BaseFilter):
async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
return event_chat.type == ChatType.PRIVATE
-4
View File
@@ -3,7 +3,6 @@ __all__ = ("router",)
from aiogram import Router
from .admin import router as admin_router
from .captcha import router as captcha_router
from .coupons import router as coupons_router
from .donate import router as donate_router
from .instructions import router as instructions_router
@@ -12,14 +11,12 @@ 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")
router.include_routers(
start_router,
captcha_router,
profile_router,
pay_router,
donate_router,
@@ -29,5 +26,4 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+5 -23
View File
@@ -2,34 +2,16 @@ __all__ = ("router",)
from aiogram import Router
from .ads import router as ads_router
from .backups import router as backups_router
from .bans import router as bans_router
from .clusters import router as clusters_router
from .coupons import router as coupons_router
from .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
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
from .admin_servers import router as servers_router
from .admin_user_editor import router as user_editor_router
router = Router(name="admins_main_router")
router.include_routers(
panel_router,
management_router,
servers_router,
clusters_router,
users_router,
stats_router,
backups_router,
sender_router,
coupons_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
user_editor_router,
)
+164
View File
@@ -0,0 +1,164 @@
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 InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import create_coupon, delete_coupon_from_db, get_all_coupons
from filters.admin import IsAdminFilter
from logger import logger
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
router = Router()
@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
async def show_coupon_management_menu(
callback_query: types.CallbackQuery, state: FSMContext
):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=" Создать купон", callback_data="create_coupon")
)
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"🛠 Меню управления купонами:", reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "coupons", IsAdminFilter())
async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
try:
coupons = await get_all_coupons(session)
if not coupons:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
"❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
reply_markup=builder.as_markup(),
)
return
coupon_list = "📜 Список всех купонов:\n\n"
builder = InlineKeyboardBuilder()
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\n"
)
builder.row(
InlineKeyboardButton(
text=f"❌ Удалить {coupon['code']}",
callback_data=f"delete_coupon_{coupon['code']}",
)
)
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
coupon_list, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
coupon_code = callback_query.data[len("delete_coupon_") :]
try:
result = await delete_coupon_from_db(coupon_code, session)
if result:
await show_coupon_list(callback_query, session)
else:
await callback_query.message.answer(
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.",
)
await show_coupon_list(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
@router.callback_query(F.data == "create_coupon", IsAdminFilter())
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
await callback_query.message.answer(
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n",
reply_markup=builder.as_markup(),
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
if len(parts) != 3:
await message.answer(
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈",
reply_markup=builder.as_markup(),
)
return
try:
coupon_code = parts[0]
coupon_amount = float(parts[1])
usage_limit = int(parts[2])
except ValueError:
await message.answer(
"⚠️ <b>Проверьте правильность введенных данных!</b>\n"
"💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
reply_markup=builder.as_markup(),
)
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
result_message = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан! 🎉\n"
f"Сумма: <b>{coupon_amount} рублей</b> 💰\n"
f"Лимит использования: <b>{usage_limit} раз</b> 🔢."
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await message.answer(result_message, reply_markup=builder.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
+391
View File
@@ -0,0 +1,391 @@
import subprocess
from datetime import datetime
from io import BytesIO
from typing import Any
from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from backup import backup_database
from bot import bot
from filters.admin import IsAdminFilter
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
displaying_user_info = State()
waiting_for_restart_confirmation = State()
waiting_for_message = State()
@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"), F.data == "admin", IsAdminFilter())
async def handle_admin_message(message: types.Message, state: FSMContext):
await state.clear()
BOT_VERSION = "3.2.4-beta" # Укажите текущую версию бота
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📊 Статистика пользователей", callback_data="user_stats"
)
)
builder.row(
InlineKeyboardButton(
text="👥 Управление пользователями", callback_data="user_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🖥️ Управление серверами", callback_data="servers_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🎟️ Управление купонами", callback_data="coupons_editor"
)
)
builder.row(
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
)
builder.row(
InlineKeyboardButton(
text="🤖 Управление Ботом", callback_data="bot_management"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await message.answer(
f"🤖 Панель администратора\n\nВерсия бота: <b>{BOT_VERSION}</b>",
reply_markup=builder.as_markup(),
parse_mode="HTML"
)
@router.callback_query(F.data == "bot_management")
async def handle_bot_management(callback_query: types.CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
)
builder.row(
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
)
builder.row(
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin")
)
await callback_query.message.answer(
"🤖 Управление ботом",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "user_stats", IsAdminFilter())
async def user_stats_menu(callback_query: CallbackQuery, session: Any):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM connections")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
)
total_payments_week = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
total_payments_month = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
total_payments_all_time = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments"
)
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
stats_message = (
f"📊 <b>Подробная статистика проекта:</b>\n\n"
f"👥 Пользователи:\n"
f" 🌐 Зарегистрировано: <b>{total_users}</b>\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"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить пользователей в CSV",
callback_data="export_users_csv",
)
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
)
)
builder.row(
InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
)
await callback_query.message.answer(
stats_message, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
async def export_users_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
users = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
u.language_code,
u.is_bot,
c.balance,
c.trial
FROM users u
LEFT JOIN connections c ON u.tg_id = c.tg_id
"""
)
if not users:
await callback_query.message.answer(
"📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n"
for user in users:
csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
await callback_query.message.answer_document(
file,
caption="📥 Экспорт пользователей в CSV",
reply_markup=builder.as_markup(),
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте пользователей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
async def export_payments_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
payments = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
p.amount,
p.payment_system,
p.status,
p.created_at
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
"""
)
if not payments:
await callback_query.message.answer(
"📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
for payment in payments:
csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
await callback_query.message.answer_document(
file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте платежей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам 📢🌐:",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_message)
@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
async def process_message_to_all(
message: types.Message, state: FSMContext, session: Any
):
text_message = message.text
try:
tg_ids = await session.fetch("SELECT tg_id FROM connections")
total_users = len(tg_ids)
success_count = 0
error_count = 0
for record in tg_ids:
tg_id = record["tg_id"]
try:
await bot.send_message(chat_id=tg_id, text=text_message)
success_count += 1
except Exception as e:
error_count += 1
logger.error(
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
)
await message.answer(
f"📤 Рассылка завершена:\n"
f"👥 Всего пользователей: {total_users}\n"
f"✅ Успешно отправлено: {success_count}\n"
f"❌ Не доставлено: {error_count}"
)
except Exception as e:
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
await state.clear()
@router.callback_query(F.data == "backups", IsAdminFilter())
async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"💾 Инициализация резервного копирования базы данных..."
)
await backup_database()
await callback_query.message.answer(
"✅ Резервная копия успешно создана и отправлена администратору."
)
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_restart_confirmation)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, перезапустить", callback_data="confirm_restart"
),
InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
await callback_query.message.answer(
"🤔 Вы уверены, что хотите перезапустить бота?",
reply_markup=builder.as_markup(),
)
@router.callback_query(
F.data == "confirm_restart",
UserEditorState.waiting_for_restart_confirmation,
IsAdminFilter(),
)
async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await state.clear()
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except subprocess.CalledProcessError:
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except Exception as e:
await callback_query.message.answer(
f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "user_editor", IsAdminFilter())
async def user_editor_menu(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔍 Поиск по названию ключа",
callback_data="search_by_key_name",
)
)
builder.row(
InlineKeyboardButton(
text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Поиск по Username", callback_data="search_by_username"
)
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
await callback_query.message.answer(
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
)
+531
View File
@@ -0,0 +1,531 @@
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 InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
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, get_servers_from_db
from filters.admin import IsAdminFilter
router = Router()
class UserEditorState(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()
@router.callback_query(F.data == "servers_editor", IsAdminFilter())
async def handle_servers_editor(callback_query: types.CallbackQuery):
servers = await get_servers_from_db()
builder = InlineKeyboardBuilder()
for cluster_name, cluster_servers in servers.items():
builder.row(
InlineKeyboardButton(
text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data="add_cluster")
)
builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
await callback_query.message.answer(
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "add_cluster", IsAdminFilter())
async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя кластера должно быть уникальным и на английском языке.</b>\n"
"<i>Пример:</i> <code>cluster2</code> или <code>us_east_1</code>",
parse_mode="HTML",
)
await state.set_state(UserEditorState.waiting_for_cluster_name)
@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: types.Message, state: FSMContext):
cluster_name = message.text.strip()
if cluster_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not cluster_name:
await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
return
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: types.Message, state: FSMContext):
server_name = message.text.strip()
if server_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not server_name:
await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
return
server_unique = await check_unique_server_name(server_name)
if not server_unique:
await message.answer(
"❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await state.update_data(server_name=server_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
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",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_api_url)
@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: types.Message, state: FSMContext):
api_url = message.text.strip()
if api_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not api_url.startswith("https://"):
await message.answer(
"❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
api_url = api_url.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)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
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 в информации о клиенте.",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_subscription_url)
@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: types.Message, state: FSMContext):
subscription_url = message.text.strip()
if subscription_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not subscription_url.startswith("https://"):
await message.answer(
"❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
subscription_url = subscription_url.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)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_inbound_id)
@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: types.Message, state: FSMContext):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
"❌ inbound_id должен быть числовым значением. Попробуйте снова."
)
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()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад к кластерам", callback_data="servers_editor"
)
)
await message.answer(
f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
reply_markup=builder.as_markup(),
)
await state.clear()
@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=f"manage_server|{server['server_name']}",
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер", callback_data=f"add_server|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность серверов",
callback_data=f"server_availability|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап кластера",
callback_data=f"backup_cluster|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🔧 Управление серверами для кластера {cluster_name}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
async def handle_check_server_availability(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
return
in_progress_message = await callback_query.message.answer(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
availability_message = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
)
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())
availability_message += (
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
)
except Exception as e:
availability_message += f"{server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await in_progress_message.edit_text(
availability_message, reply_markup=builder.as_markup()
)
await callback_query.answer()
@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
server = None
cluster_name = None
for cluster, cluster_servers in servers.items():
server = next(
(s for s in cluster_servers if s["server_name"] == server_name), None
)
if server:
cluster_name = cluster
break
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await callback_query.message.answer(
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}",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
else:
await callback_query.message.answer("❌ Сервер не найден.")
@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
),
InlineKeyboardButton(
text="❌ Нет", callback_data=f"manage_server|{server_name}"
),
)
await callback_query.message.answer(
f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
async def handle_confirm_delete_server(
callback_query: types.CallbackQuery, state: FSMContext
):
server_name = callback_query.data.split("|")[1]
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
DELETE FROM servers WHERE server_name = $1
""",
server_name,
)
await conn.close()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await callback_query.message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
async def handle_backup_cluster(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
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)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
await callback_query.message.answer(
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await callback_query.answer()
+712
View File
@@ -0,0 +1,712 @@
import asyncio
from datetime import datetime
from typing import Any
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import TOTAL_GB
from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
delete_key_from_cluster,
delete_key_from_db,
renew_key_in_cluster,
)
from handlers.utils import sanitize_key_name
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
waiting_for_username = State()
displaying_user_info = State()
waiting_for_new_balance = State()
waiting_for_key_name = State()
waiting_for_expiry_time = State()
waiting_for_message_text = State()
@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_tg_id)
@router.callback_query(F.data == "search_by_username", IsAdminFilter())
async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Username клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_username)
@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
async def handle_username_input(
message: types.Message, state: FSMContext, session: Any
):
# Extract the username from a message text by removing leading '@' and the Telegram URL prefix
username = message.text.strip().lstrip('@').replace('https://t.me/', '')
user_record = await session.fetchrow(
"SELECT tg_id FROM users WHERE username = $1", username
)
if not user_record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🔍 Пользователь с указанным username не найден. 🚫",
reply_markup=builder.as_markup(),
)
await state.clear()
return
tg_id = user_record["tg_id"]
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(
InlineKeyboardButton(
text="✉️ Отправить сообщение",
callback_data=f"send_message_{tg_id}"
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("send_message_"))
async def handle_send_message(callback_query: types.CallbackQuery, state: FSMContext):
tg_id = callback_query.data.split("_")[2]
await state.update_data(target_tg_id=tg_id)
await callback_query.message.answer(
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю."
)
await state.set_state(UserEditorState.waiting_for_message_text)
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
async def process_send_message(message: types.Message, state: FSMContext, bot: Bot):
data = await state.get_data()
target_tg_id = data.get("target_tg_id")
if not target_tg_id:
await message.answer("🚫 Ошибка: ID пользователя не найден.")
await state.clear()
return
try:
await bot.send_message(chat_id=target_tg_id, text=message.text)
await message.answer("✅ Сообщение успешно отправлено.")
except Exception as e:
await message.answer(f"❌ Не удалось отправить сообщение: {e}")
await state.clear()
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
tg_id = int(message.text)
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(
InlineKeyboardButton(
text="✉️ Отправить сообщение",
callback_data=f"send_message_{tg_id}"
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
await restore_trial(tg_id, session)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора", callback_data="admin"
)
)
await callback_query.message.answer(
"✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
tg_id = int(callback_query.data.split("_")[2])
await state.update_data(tg_id=tg_id)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_new_balance)
@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
async def handle_new_balance_input(
message: types.Message, state: FSMContext, session: Any
):
if not message.text.isdigit() or int(message.text) < 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, введите корректную сумму для изменения баланса.",
reply_markup=builder.as_markup(),
)
return
new_balance = int(message.text)
user_data = await state.get_data()
tg_id = user_data.get("tg_id")
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
new_balance,
tg_id,
)
response_message = f"✅ Баланс успешно изменен на <b>{new_balance}</b>."
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора",
callback_data="admin",
)
)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.clear()
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT 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
servers = await get_servers_from_db()
cluster_name = "Неизвестный кластер"
for cluster_name, cluster_servers in servers.items():
if any(
server["inbound_id"] == record["server_id"] for server in cluster_servers
):
cluster_name = cluster_name
break
expiry_date = datetime.utcfromtimestamp(record["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": record["key"],
"expiry_date": expiry_date.strftime("%d %B %Y года"),
"days_left_message": days_left_message,
"server_name": cluster_name,
"balance": record["balance"],
"tg_id": record["tg_id"],
}
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
async def process_key_edit(callback_query: CallbackQuery, session: Any):
email = callback_query.data.split("_", 2)[2]
key_details = await get_key_details(email, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 <b>Информация о ключе не найдена.</b> 🚫",
reply_markup=builder.as_markup(),
)
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Кластер: <b>{key_details['server_name']}</b>"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{email}",
)
)
builder.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{email}",
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔑 Введите имя ключа:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_key_name)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(
message: types.Message, state: FSMContext, session: Any
):
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(key_name, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным именем ключа не найден.",
reply_markup=builder.as_markup(),
)
await state.clear()
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Сервер: <b>{key_details['server_name']}</b>"
)
key_buttons = InlineKeyboardBuilder()
key_buttons.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{key_name}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{key_name}",
)
)
key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(response_message, reply_markup=key_buttons.as_markup())
await state.clear()
@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
email = callback_query.data.split("|")[1]
await callback_query.message.answer(
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:"
)
await state.update_data(email=email)
await state.set_state(UserEditorState.waiting_for_expiry_time)
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
async def handle_expiry_time_input(
message: types.Message, state: FSMContext, session: Any
):
user_data = await state.get_data()
email = user_data.get("email")
if not email:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
)
await state.clear()
return
try:
expiry_time_str = message.text
expiry_time = int(
datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").timestamp() * 1000
)
client_id = await get_client_id_by_email(email)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
f"🚫 Клиент с email {email} не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
record = await session.fetchrow(
"SELECT server_id FROM keys WHERE client_id = $1", client_id
)
if not record:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
"🚫 Клиент не найден в базе данных. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
clusters = await get_servers_from_db()
async def update_key_on_all_servers():
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
asyncio.create_task(
renew_key_in_cluster(
cluster_name,
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
)
)
)
await asyncio.gather(*tasks)
await update_key_on_all_servers()
await update_key_expiry(client_id, expiry_time)
response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await message.answer(response_message, reply_markup=builder.as_markup())
except ValueError:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(e)
await state.clear()
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
async def process_callback_delete_key(
callback_query: types.CallbackQuery, session: Any
):
email = callback_query.data.split("|")[1]
client_id = await session.fetchval(
"SELECT client_id FROM keys WHERE email = $1", email
)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
)
return
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=f"confirm_delete_admin|{client_id}",
)
)
builder.row(
types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
)
await callback_query.message.answer(
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
async def process_callback_confirm_delete(
callback_query: types.CallbackQuery, session: Any
):
client_id = callback_query.data.split("|")[1]
record = await session.fetchrow(
"SELECT email FROM keys WHERE client_id = $1", client_id
)
if record:
email = record["email"]
response_message = "✅ Ключ успешно удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
clusters = await get_servers_from_db()
async def delete_key_from_servers(email, client_id):
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
delete_key_from_cluster(cluster_name, email, client_id)
)
await asyncio.gather(*tasks)
await delete_key_from_servers(email, client_id)
await delete_key_from_db(client_id, session)
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
else:
response_message = "🚫 Ключ не найден или уже удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
async def handle_user_info(
callback_query: types.CallbackQuery, state: FSMContext, session: Any
):
tg_id = int(callback_query.data.split("|")[1])
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"))
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
async def confirm_delete_user(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = int(callback_query.data.split("_")[3])
confirmation_markup = InlineKeyboardMarkup(
row_width=2,
inline_keyboard=[
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}")],
[InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
],
)
await callback_query.message.answer(
f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=confirmation_markup
)
@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
async def delete_user(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
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_from_db()
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)
back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await callback_query.message.answer(f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.answer(
f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .ads_handler import router
-191
View File
@@ -1,191 +0,0 @@
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
@@ -1,96 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .backups_handler import router
-35
View File
@@ -1,35 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .bans_handler import router
-156
View File
@@ -1,156 +0,0 @@
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
@@ -1,31 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .clusters_handler import router
File diff suppressed because it is too large Load Diff
-205
View File
@@ -1,205 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .coupons_handler import router
-349
View File
@@ -1,349 +0,0 @@
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
@@ -1,92 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .management_handler import router
-43
View File
@@ -1,43 +0,0 @@
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()
@@ -1,103 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .panel_handler import router
-94
View File
@@ -1,94 +0,0 @@
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
@@ -1,59 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .restart_handler import router
-48
View File
@@ -1,48 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .sender_handler import router
-68
View File
@@ -1,68 +0,0 @@
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()
-165
View File
@@ -1,165 +0,0 @@
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, 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 logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
class AdminSender(StatesGroup):
waiting_for_message = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
@router.callback_query(
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
await callback_query.message.edit_text(
text="✍️ Введите текст сообщения для рассылки:",
reply_markup=build_admin_back_kb("sender"),
)
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
await state.set_state(AdminSender.waiting_for_message)
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
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),
)
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
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
)
if not text_message and message.caption:
text_message = message.caption
if not text_message and not photo and not photo_url:
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
return
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":
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
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")
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:
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
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 or photo_url,
caption=text_message,
)
else:
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}")
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 state.clear()
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .servers_handler import router
-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,355 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .stats_handler import router
-30
View File
@@ -1,30 +0,0 @@
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
@@ -1,301 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .tariffs_handler import router
-162
View File
@@ -1,162 +0,0 @@
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
@@ -1,506 +0,0 @@
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
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .users_handler import router
-395
View File
@@ -1,395 +0,0 @@
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
@@ -1,90 +0,0 @@
# Основные кнопки меню:
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 = "🤖 Бот для покупки звезд"
+5
View File
@@ -0,0 +1,5 @@
GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
PROFILE = "👤 Личный кабинет"
BACK = "🔙 Назад"
GIFTS_ABOUT = "<b>Дарите подарки и следите, чтобы они дошли до адресата! 🎄</b>"
+8
View File
@@ -0,0 +1,8 @@
ADD_SUB = " Устройство"
MY_SUBS = "📱 Мои устройства"
PAYMENT = "💳 Пополнить баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
MAIN_MENU = "⬅️ Главное меню"
+3
View File
@@ -0,0 +1,3 @@
PAY = "Пополнить"
BACK = "⬅️ Назад"
CUSTOM_SUM = "💰 Ввести свою сумму"
-104
View File
@@ -1,104 +0,0 @@
import random
import secrets
from typing import Any
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
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
)
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
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:
builder.button(text=emoji, callback_data=f"captcha_{emoji}")
builder.adjust(2, 2)
return {
"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 process_start_logic
selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji")
original_text = state_data.get("original_text")
user_data = state_data.get("user_data")
target_message = callback.message
if selected_emoji == correct_emoji:
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"],
)
+66 -261
View File
@@ -1,292 +1,97 @@
import html
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
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, Message
from aiogram.types import InlineKeyboardButton
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 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
from database import update_balance
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
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
callback_query: types.CallbackQuery, state: FSMContext
):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
await edit_or_send_message(
target_message=target_message,
text=COUPON_INPUT_PROMPT,
await callback_query.message.answer(
"<b>🎫 Введите код купона:</b>\n\n"
"📝 Пожалуйста, введите действующий код купона, который вы хотите активировать. 🔑",
reply_markup=builder.as_markup(),
media_path=None,
)
await state.set_state(CouponActivationState.waiting_for_coupon_code)
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
async def process_coupon_code(message: types.Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
await activate_coupon(message, state, session, coupon_code=coupon_code)
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
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()
@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
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
tg_id = callback_query.from_user.id
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
usage = await check_coupon_usage(session, coupon.id, tg_id)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
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
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key.expiry_time
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
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
await message.answer(
activation_result, reply_markup=builder.as_markup(), parse_mode="HTML"
)
await state.clear()
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
coupon_record = await session.fetchrow(
"""
SELECT id, usage_limit, usage_count, is_used, amount
FROM coupons
WHERE code = $1 AND (usage_count < usage_limit OR usage_limit = 0) AND is_used = FALSE
""",
coupon_code,
)
if not coupon_record:
return "<b>❌ Купон не найден</b> 🚫 или его использование ограничено. 🔒 Пожалуйста, проверьте код и попробуйте снова. 🔍"
usage_exists = await session.fetchrow(
"""
SELECT 1 FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2
""",
coupon_record["id"],
user_id,
)
if usage_exists:
return "<b>❌ Вы уже активировали этот купон.</b> 🚫 Купоны могут быть активированы только один раз. 🔒"
coupon_amount = coupon_record["amount"]
async with session.transaction():
await session.execute(
"""
UPDATE coupons
SET usage_count = usage_count + 1,
is_used = CASE WHEN usage_count + 1 >= usage_limit AND usage_limit > 0 THEN TRUE ELSE FALSE END
WHERE id = $1
""",
coupon_record["id"],
)
await session.execute(
"""
INSERT INTO coupon_usages (coupon_id, user_id, used_at)
VALUES ($1, $2, $3)
""",
coupon_record["id"],
user_id,
datetime.utcnow(),
)
await update_balance(user_id, coupon_amount)
return f"<b>✅ Купон успешно активирован! 🎉</b>\n\nНа ваш баланс добавлено <b>{coupon_amount} рублей</b> 💰."
+19 -34
View File
@@ -1,21 +1,12 @@
from aiogram import F, Router
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,
LabeledPrice,
Message,
PreCheckoutQuery,
)
from aiogram.types import InlineKeyboardButton, LabeledPrice, 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
class DonateState(StatesGroup):
entering_donate_amount = State()
@@ -27,7 +18,7 @@ router = Router()
@router.callback_query(F.data == "donate")
async def process_donate(callback_query: CallbackQuery, state: FSMContext):
async def process_donate(callback_query: types.CallbackQuery, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
@@ -42,38 +33,30 @@ async def process_donate(callback_query: CallbackQuery, state: FSMContext):
callback_data="enter_custom_donate_amount",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
text = (
"🌟 Поддержите наш проект! 💪\n\n"
await callback_query.message.answer(
text="🌟 Поддержите наш проект! 💪\n\n"
"💖 Каждый донат помогает развивать и улучшать сервис. "
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡"
)
await edit_or_send_message(
target_message=callback_query.message,
text=text,
"🤝 Мы ценим вашу поддержку и работаем над тем, чтобы сделать наш продукт еще лучше. 🚀💡",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "enter_custom_donate_amount")
async def process_enter_donate_amount(callback_query: CallbackQuery, state: FSMContext):
async def process_enter_donate_amount(
callback_query: types.CallbackQuery, state: FSMContext
):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
text = "💸 Введите сумму доната в рублях:"
await edit_or_send_message(
target_message=callback_query.message,
text=text,
reply_markup=builder.as_markup(),
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
await callback_query.message.answer(
"💸 Введите сумму доната в рублях:", reply_markup=builder.as_markup()
)
await state.set_state(DonateState.entering_donate_amount)
@router.message(DonateState.entering_donate_amount)
async def process_donate_amount_input(message: Message, state: FSMContext):
async def process_donate_amount_input(message: types.Message, state: FSMContext):
if message.text.isdigit():
amount = int(message.text)
if amount // RUB_TO_XTR <= 0:
@@ -88,7 +71,7 @@ async def process_donate_amount_input(message: Message, state: FSMContext):
try:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="Задонатить", pay=True))
builder.row(InlineKeyboardButton(text=BACK, callback_data="donate"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="donate"))
await message.answer_invoice(
title=f"Донат проекту {amount} рублей",
@@ -112,11 +95,13 @@ async def on_pre_checkout_query(pre_checkout_query: PreCheckoutQuery):
@router.message(F.successful_payment, DonateState.waiting_for_donate_payment)
async def on_successful_donate(message: Message, state: FSMContext):
async def on_successful_donate(message: types.Message, state: FSMContext):
try:
amount = float(message.successful_payment.invoice_payload.split("_")[0])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await message.answer(
text=f"🙏 Спасибо за донат {amount} рублей! Ваша поддержка очень важна для нас. 💖",
reply_markup=builder.as_markup(),
-21
View File
@@ -1,21 +0,0 @@
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(),
)
+117 -142
View File
@@ -1,182 +1,157 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
import asyncpg
from aiogram import F, Router, types
from aiogram.types import BufferedInputFile, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
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,
SUBSCRIPTION_DETAILS_TEXT,
)
from handlers.utils import edit_or_send_message
from config import CONNECT_MACOS, CONNECT_WINDOWS, DATABASE_URL, SUPPORT_CHAT_URL
from handlers.texts import CONNECT_TV_TEXT, INSTRUCTION_PC, INSTRUCTIONS, KEY_MESSAGE, SUBSCRIPTION_DETAILS_TEXT
router = Router()
@router.callback_query(F.data == "instructions")
@router.message(F.text == "/instructions")
async def send_instructions(callback_query_or_message: CallbackQuery | Message):
async def send_instructions(callback_query_or_message: types.CallbackQuery | types.Message):
instructions_message = INSTRUCTIONS
image_path = os.path.join("img", "instructions.jpg")
builder = InlineKeyboardBuilder()
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
else:
target_message = callback_query_or_message
await edit_or_send_message(
target_message=target_message,
text=instructions_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@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(session, key_name)
if not record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍",
reply_markup=builder.as_markup(),
media_path=None,
)
if not os.path.isfile(image_path):
if isinstance(callback_query_or_message, types.CallbackQuery):
await callback_query_or_message.message.answer("Файл изображения не найден.")
else:
await callback_query_or_message.answer("Файл изображения не найден.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
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,
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"),
)
if isinstance(callback_query_or_message, types.CallbackQuery):
send_photo = callback_query_or_message.message.answer_photo
else:
send_photo = callback_query_or_message.answer_photo
@router.callback_query(F.data.startswith("windows_menu|"))
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
with open(image_path, "rb") as image_from_buffer:
await send_photo(
BufferedInputFile(image_from_buffer.read(), filename="instructions.jpg"),
caption=instructions_message,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: types.CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
record = await session.fetchrow(
"""
SELECT k.key
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
key_name,
)
if not record:
await callback_query.message.answer(
"❌ <b>Ключ не найден. Проверьте имя ключа.</b> 🔍"
)
return
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
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,
text=instruction_message,
reply_markup=builder.as_markup(),
media_path=None,
)
@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, session: Any):
key_name = callback_query.data.split("|")[1]
key_message = KEY_MESSAGE.format(key)
instruction_message = f"{key_message}{INSTRUCTION_PC}"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=TV_CONTINUE, callback_data=f"continue_tv|{key_name}")
InlineKeyboardButton(
text="💻 Подключить Windows", url=f"{CONNECT_WINDOWS}{key}"
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(
InlineKeyboardButton(text="💻 Подключить MacOS", url=f"{CONNECT_MACOS}{key}")
)
builder.row(InlineKeyboardButton(text="🆘 Поддержка", url=f"{SUPPORT_CHAT_URL}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=CONNECT_TV_TEXT,
await callback_query.message.answer(
instruction_message,
reply_markup=builder.as_markup(),
media_path=None,
parse_mode="HTML",
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("connect_tv|"))
async def process_connect_tv(callback_query: types.CallbackQuery):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
subscription_link = record.get("key") or record.get("remnawave_link")
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="▶ Продолжить", callback_data=f"continue_tv|{key_name}"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await callback_query.message.answer(
text=CONNECT_TV_TEXT,
reply_markup=builder.as_markup(),
parse_mode="HTML",
disable_web_page_preview=True
)
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: types.CallbackQuery):
key_name = callback_query.data.split("|")[1]
tg_id = callback_query.from_user.id
print(f"tg_id: {tg_id}, key_name: {key_name}") # Отладочный вывод
conn = await asyncpg.connect(DATABASE_URL)
try:
record = await conn.fetchrow(
"""
SELECT k.key
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
key_name,
)
print(f"Query result: {record}") # Отладочный вывод результата
finally:
await conn.close()
subscription_link = record['key']
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(
InlineKeyboardButton(
text="📖 Полная инструкция", url="https://vpn4tv.com/quick-guide.html"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await edit_or_send_message(
target_message=callback_query.message,
await callback_query.message.answer(
text=message_text,
reply_markup=builder.as_markup(),
media_path=None,
)
parse_mode="HTML"
)
+2 -10
View File
@@ -2,20 +2,12 @@ __all__ = ("router",)
from aiogram import 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 .key_management import router as management_router
from .keys import router as keys_router
router = Router(name="keys_main_router")
router.include_routers(
keys_router,
view_router,
renew_router,
freeze_router,
connect_router,
key_mode_router,
management_router,
)
-262
View File
@@ -1,262 +0,0 @@
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
@@ -1,245 +0,0 @@
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}")
+196
View File
@@ -0,0 +1,196 @@
import asyncio
import uuid
from datetime import datetime, timedelta
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, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
)
from database import get_balance, get_trial, store_key, update_balance
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import DISCOUNTS, key_message_success
from handlers.utils import generate_random_email, get_least_loaded_cluster
from logger import logger
router = Router()
class Form(StatesGroup):
waiting_for_server_selection = State()
waiting_for_key_name = State()
viewing_profile = State()
waiting_for_message = State()
@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
logger.info(f"User {tg_id} confirmed creation of a new key.")
logger.info(f"Balance for user {tg_id} is sufficient. Proceeding with key creation.")
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.utcnow()
trial_status = await get_trial(tg_id, session)
if trial_status == 0:
expiry_time = current_time + timedelta(days=TRIAL_TIME)
logger.info(f"Assigned 1-day trial to user {tg_id}.")
await session.execute("UPDATE connections SET trial = 1 WHERE tg_id = $1", tg_id)
await create_key(tg_id, expiry_time, state, session, message_or_query)
else:
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"))
await message_or_query.message.answer(
"💳 Выберите тарифный план для создания нового ключа:", 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_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, state: FSMContext, 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:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💳 Пополнить баланс", callback_data="pay"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await callback_query.message.answer(
"💳 Недостаточно средств для создания подписки. Пополните баланс в личном кабинете.",
reply_markup=builder.as_markup(),
)
await state.clear()
return
await update_balance(tg_id, -plan_price)
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
await create_key(tg_id, expiry_time, state, session, callback_query)
async def create_key(
tg_id: int, expiry_time: datetime, state: FSMContext, session: Any, message_or_query: Message | CallbackQuery
):
"""Создаёт ключ с заданным сроком действия."""
while True:
key_name = generate_random_email()
logger.info(f"Generated random key name for user {tg_id}: {key_name}")
existing_key = await session.fetchrow(
"SELECT * FROM keys WHERE email = $1 AND tg_id = $2",
key_name,
tg_id,
)
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:
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,
)
)
]
await asyncio.gather(*tasks)
logger.info(f"Key created on cluster {least_loaded_cluster} for user {tg_id}.")
await store_key(tg_id, client_id, email, expiry_timestamp, public_link, least_loaded_cluster, session)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id} on cluster: {e}")
if isinstance(message_or_query, Message):
await message_or_query.answer("❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова.")
elif isinstance(message_or_query, CallbackQuery):
await message_or_query.message.answer(
"❌ Произошла ошибка при создании ключа. Пожалуйста, попробуйте снова."
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="💬 Поддержка", url=SUPPORT_CHAT_URL))
builder.row(
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
InlineKeyboardButton(text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text="🍏 Подключить на iOS", url=f"{CONNECT_IOS}{public_link}"),
InlineKeyboardButton(text="🤖 Подключить на Android", url=f"{CONNECT_ANDROID}{public_link}"),
)
builder.row(InlineKeyboardButton(text="💻 Windows/Linux/MacOS", callback_data=f"connect_pc|{email}"))
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
remaining_time = expiry_time - datetime.utcnow()
days = remaining_time.days
key_message = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
if isinstance(message_or_query, Message):
await message_or_query.answer(key_message, reply_markup=builder.as_markup())
elif isinstance(message_or_query, CallbackQuery):
await message_or_query.message.answer(key_message, reply_markup=builder.as_markup())
await state.clear()
-12
View File
@@ -1,12 +0,0 @@
__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
@@ -1,234 +0,0 @@
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
@@ -1,655 +0,0 @@
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
@@ -1,243 +0,0 @@
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,
)
@@ -1,99 +0,0 @@
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
@@ -1,364 +0,0 @@
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}")
+167 -1011
View File
File diff suppressed because it is too large Load Diff
-452
View File
@@ -1,452 +0,0 @@
import html
import os
import re
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, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HWID_RESET_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
from database.models import Key
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
FREEZE,
HWID_BUTTON,
MAIN_MENU,
PC_BUTTON,
QR,
RENEW,
RENEW_FULL,
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import FROZEN_SUBSCRIPTION_MSG, NO_SUBSCRIPTIONS_MSG, key_message
from handlers.utils import (
edit_or_send_message,
format_days,
format_hours,
format_minutes,
format_months,
get_russian_month,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(
callback_query_or_message: Message | CallbackQuery, session: Any
):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
tg_id = callback_query_or_message.from_user.id
try:
records = await get_keys(session, tg_id)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message,
text=response_message,
reply_markup=inline_keyboard,
media_path=image_path,
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = "<b>🔑 Список ваших подписок:</b>\n\n<blockquote>"
for record in records:
alias = record.alias
email = record.email
client_id = record.client_id
expiry_time = record.expiry_time
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(
expiry_time / 1000, tz=moscow_tz
)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(
text=f"🔑 {key_display}", callback_data=f"view_key|{email}"
)
rename_button = InlineKeyboardButton(
text=ALIAS, callback_data=f"rename_key|{client_id}"
)
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += (
"</blockquote>\n\n<i>Нажмите на ✏️, чтобы переименовать подписку.</i>"
)
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys")
)
await edit_or_send_message(
target_message=callback.message,
text="✏️ Введите новое имя подписки (до 10 символов):",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
async def cancel_and_back(
callback: CallbackQuery, state: FSMContext, session: AsyncSession
):
await state.clear()
await callback.answer()
await process_callback_or_message_view_keys(callback, session)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(
message: Message, state: FSMContext, session: AsyncSession
):
alias = message.text.strip()
if len(alias) > 10:
await message.answer(
"❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод."
)
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer(
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
)
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
update(Key)
.where(Key.tg_id == message.chat.id, Key.client_id == client_id)
.values(alias=alias)
)
await session.commit()
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
async def render_key_info(
message: Message, session: Any, key_name: str, image_path: str
):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
record = await get_key_details(session, key_name)
if not record:
await message.answer("<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
record["email"]
client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link")
key = record.get("key")
final_link = key or remnawave_link
builder = InlineKeyboardBuilder()
if is_frozen:
builder.row(
InlineKeyboardButton(
text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
if time_left.total_seconds() <= 0:
days_left_message = "<b>🕒 Статус подписки:</b>\n🔴 Истекла"
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = f"⏳ Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
hwid_count = 0
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and client_id:
servers = await get_servers(session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
None,
)
if remna_server:
api = RemnawaveAPI(remna_server["api_url"])
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
devices = await api.get_user_hwid_devices(client_id)
hwid_count = len(devices or [])
tariff_name = ""
traffic_limit = 0
device_limit = 0
tariff = None
if record.get("tariff_id"):
tariff = await get_tariff_by_id(session, record["tariff_id"])
if tariff:
tariff_name = tariff["name"]
traffic_limit = tariff.get("traffic_limit", 0)
device_limit = tariff.get("device_limit", 0)
tariff_duration = ""
if tariff and tariff.get("duration_days", 0) > 0:
duration_days = tariff["duration_days"]
if duration_days >= 30:
months = duration_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(duration_days)
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
hwid_count=hwid_count if device_limit is not None else 0,
tariff_name=tariff_duration,
traffic_limit=traffic_limit,
device_limit=device_limit
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
if is_full_remnawave and final_link:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)
)
)
builder.row(
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}")
)
else:
if 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|{key_name}"
),
InlineKeyboardButton(
text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"
),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"
)
)
if HWID_RESET_BUTTON and hwid_count > 0:
builder.row(
InlineKeyboardButton(
text=HWID_BUTTON,
callback_data=f"reset_hwid|{key_name}",
)
)
if QRCODE:
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=RENEW, callback_data=f"renew_key|{key_name}"),
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
else:
builder.row(
InlineKeyboardButton(text=RENEW_FULL, callback_data=f"renew_key|{key_name}")
)
if USE_COUNTRY_SELECTION:
builder.row(
InlineKeyboardButton(
text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"
)
)
if TOGGLE_CLIENT:
builder.row(
InlineKeyboardButton(
text=FREEZE, callback_data=f"freeze_subscription|{key_name}"
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@router.callback_query(F.data.startswith("reset_hwid|"))
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
if not record:
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
return
client_id = record.get("client_id")
if not client_id:
await callback_query.answer(
"❌ У ключа отсутствует client_id.", show_alert=True
)
return
servers = await get_servers(session=session)
remna_server = next(
(
srv
for cl in servers.values()
for srv in cl
if srv.get("panel_type") == "remnawave"
),
None,
)
if not remna_server:
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
return
api = RemnawaveAPI(remna_server["api_url"])
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await callback_query.answer(
"❌ Авторизация в Remnawave не удалась.", show_alert=True
)
return
devices = await api.get_user_hwid_devices(client_id)
if not devices:
await callback_query.answer("✅ Устройства не были привязаны.", show_alert=True)
else:
deleted = 0
for device in devices:
if await api.delete_user_hwid_device(client_id, device["hwid"]):
deleted += 1
await callback_query.answer(
f"✅ Устройства сброшены ({deleted})", show_alert=True
)
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
+522 -53
View File
@@ -1,114 +1,583 @@
import asyncio
import locale
import os
from datetime import datetime, timedelta
from typing import Any
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.types import BufferedInputFile, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import delete_key, get_key_details, get_servers
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import delete_key_from_cluster, update_subscription
from handlers.keys.key_view import process_callback_view_key
from handlers.texts import DELETE_KEY_CONFIRM_MSG, KEY_DELETED_MSG_SIMPLE
from handlers.utils import edit_or_send_message, handle_error
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
PUBLIC_LINK,
RENEWAL_PLANS,
TOTAL_GB,
)
from database import (
delete_key,
get_balance,
get_servers_from_db,
store_key,
update_balance,
update_key_expiry,
)
from handlers.keys.key_utils import (
delete_key_from_cluster,
delete_key_from_db,
renew_key_in_cluster,
update_key_on_cluster,
)
from handlers.texts import (
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
SUCCESS_RENEWAL_MSG,
key_message,
)
from handlers.utils import get_least_loaded_cluster, handle_error
from logger import logger
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
router = Router()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(
callback_query_or_message: types.Message | types.CallbackQuery, session: Any
):
if isinstance(callback_query_or_message, types.CallbackQuery):
chat_id = callback_query_or_message.message.chat.id
send_message = callback_query_or_message.message.answer
send_photo = callback_query_or_message.message.answer_photo
else:
chat_id = callback_query_or_message.chat.id
send_message = callback_query_or_message.answer
send_photo = callback_query_or_message.answer_photo
try:
records = await session.fetch(
"""
SELECT email, client_id FROM keys WHERE tg_id = $1
""",
chat_id,
)
if records:
inline_keyboard, response_message = build_keys_response(records)
else:
inline_keyboard, response_message = build_no_keys_response()
image_path = os.path.join("img", "pic_keys.jpg")
await send_with_optional_image(
send_message, send_photo, image_path, response_message, inline_keyboard
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await send_message(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру, если у пользователя есть устройства.
"""
builder = InlineKeyboardBuilder()
for record in records:
key_name = record["email"]
builder.row(
InlineKeyboardButton(
text=f"🔑 {key_name}", callback_data=f"view_key|{key_name}"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
inline_keyboard = builder.as_markup()
response_message = (
"<b>🔑 Список ваших устройств</b>\n\n"
"<i>👇 Выберите устройство для управления подпиской:</i>"
)
return inline_keyboard, response_message
def build_no_keys_response():
"""
Формирует сообщение и клавиатуру, если у пользователя нет устройств.
"""
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=" Создать подписку", callback_data="create_key"
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
inline_keyboard = builder.as_markup()
response_message = (
"<b>❌ У вас пока нет активных устройств</b>\n\n"
"<i>Нажмите кнопку ниже, чтобы создать устройство:</i>"
)
return inline_keyboard, response_message
async def send_with_optional_image(send_message, send_photo, image_path, text, keyboard):
"""
Отправляет сообщение с изображением, если файл существует. В противном случае отправляет только текст.
"""
if os.path.isfile(image_path):
with open(image_path, "rb") as image_file:
await send_photo(
photo=BufferedInputFile(image_file.read(), filename=os.path.basename(image_path)),
caption=text,
reply_markup=keyboard,
)
else:
await send_message(
text=text,
reply_markup=keyboard,
)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: types.CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await session.fetchrow(
"""
SELECT k.expiry_time, k.server_id, k.key
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
key_name,
)
if record:
key = record["key"]
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = (
"<b>🕒 Статус подписки:</b>\n🔴 Истекла\nОсталось часов: 0"
)
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>"
formatted_expiry_date = expiry_date.strftime("%d %B %Y года")
response_message = key_message(
key, formatted_expiry_date, days_left_message, server_name
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🍏 Скачать для iOS", url=DOWNLOAD_IOS),
InlineKeyboardButton(
text="🤖 Скачать для Android", url=DOWNLOAD_ANDROID
),
)
builder.row(
InlineKeyboardButton(
text="🍏 Подключить на iOS", url=f"{CONNECT_IOS}{key}"
),
InlineKeyboardButton(
text="🤖 Подключить на Android", url=f"{CONNECT_ANDROID}{key}"
),
)
builder.row(
InlineKeyboardButton(
text="💻 Компьютеры", callback_data=f"connect_pc|{key_name}"
),
InlineKeyboardButton(
text="📺 Андроид TV", callback_data=f"connect_tv|{key_name}"
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Продлить", callback_data=f"renew_key|{key_name}"
),
InlineKeyboardButton(
text="❌ Удалить", callback_data=f"delete_key|{key_name}"
),
)
if not key.startswith(PUBLIC_LINK):
builder.row(
InlineKeyboardButton(
text="🔄 Обновить подписку",
callback_data=f"update_subscription|{key_name}",
)
)
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
keyboard = builder.as_markup()
image_path = os.path.join("img", "pic_view.jpg")
if not os.path.isfile(image_path):
await callback_query.message.answer("Файл изображения не найден.")
return
with open(image_path, "rb") as image_file:
await callback_query.message.answer_photo(
photo=BufferedInputFile(image_file.read(), filename="pic_view.jpg"),
caption=response_message,
reply_markup=keyboard,
)
else:
await callback_query.message.answer(
text="<b>Информация о подписке не найдена.</b>",
)
except Exception as e:
await handle_error(
tg_id,
callback_query,
f"Ошибка при получении информации о ключе: {e}",
)
@router.callback_query(F.data.startswith("update_subscription|"))
async def process_callback_update_subscription(
callback_query: CallbackQuery, session: AsyncSession
callback_query: types.CallbackQuery, session: Any
):
tg_id = callback_query.message.chat.id
email = callback_query.data.split("|")[1]
try:
try:
await callback_query.message.delete()
except TelegramBadRequest as e:
if "message can't be deleted" not in str(e):
raise
record = await session.fetchrow(
"""
SELECT k.key, k.expiry_time, k.email, k.server_id, k.client_id
FROM keys k
WHERE k.tg_id = $1 AND k.email = $2
""",
tg_id,
email,
)
await update_subscription(tg_id, email, session)
await process_callback_view_key(callback_query, session)
if record:
expiry_time = record["expiry_time"]
client_id = record["client_id"]
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
await session.execute(
"""
DELETE FROM keys
WHERE tg_id = $1 AND email = $2
""",
tg_id,
email,
)
except Exception as delete_error:
await callback_query.message.answer(
f"Ошибка при удалении старой подписки: {delete_error}",
)
return
least_loaded_cluster_id = await get_least_loaded_cluster()
await asyncio.gather(
update_key_on_cluster(
tg_id,
client_id,
email,
expiry_time,
least_loaded_cluster_id,
)
)
await store_key(
tg_id,
client_id,
email,
expiry_time,
public_link,
server_id=least_loaded_cluster_id,
session=session,
)
await process_callback_view_key(callback_query, session)
else:
await callback_query.message.answer("<b>Ключ не найден в базе данных.</b>")
except Exception as e:
logger.error(f"Ошибка при обновлении ключа {email} пользователем: {e}")
await handle_error(
tg_id, callback_query, f"Ошибка при обновлении подписки: {e}"
)
@router.callback_query(F.data.startswith("delete_key|"))
async def process_callback_delete_key(callback_query: CallbackQuery):
async def process_callback_delete_key(callback_query: types.CallbackQuery):
client_id = callback_query.data.split("|")[1]
try:
confirmation_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=APPLY, callback_data=f"confirm_delete|{client_id}"
text="✅ Да, удалить",
callback_data=f"confirm_delete|{client_id}",
)
],
[
types.InlineKeyboardButton(
text="❌ Нет, отменить", callback_data="view_keys"
)
],
[types.InlineKeyboardButton(text=CANCEL, callback_data="view_keys")],
]
)
if callback_query.message.caption:
await callback_query.message.edit_caption(
caption=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
else:
await callback_query.message.edit_text(
text=DELETE_KEY_CONFIRM_MSG, reply_markup=confirmation_keyboard
)
await callback_query.message.answer(
text="<b>Вы уверены, что хотите удалить ключ?</b>",
reply_markup=confirmation_keyboard,
)
except Exception as e:
logger.error(f"Ошибка при подготовке удаления ключа {client_id}: {e}")
logger.error(e)
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: types.CallbackQuery, session: Any):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await session.fetchrow(
"""
SELECT client_id, expiry_time
FROM keys
WHERE email = $1
""",
key_name,
)
if record:
client_id = record["client_id"]
expiry_time = record["expiry_time"]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=f'📅 1 месяц ({RENEWAL_PLANS["1"]["price"]} руб.)',
callback_data=f"renew_plan|1|{client_id}",
)
)
builder.row(
InlineKeyboardButton(
text=f'📅 3 месяца ({RENEWAL_PLANS["3"]["price"]} руб.) {DISCOUNTS["3"]}% скидка',
callback_data=f"renew_plan|3|{client_id}",
)
)
builder.row(
InlineKeyboardButton(
text=f'📅 6 месяцев ({RENEWAL_PLANS["6"]["price"]} руб.) {DISCOUNTS["6"]}% скидка',
callback_data=f"renew_plan|6|{client_id}",
)
)
builder.row(
InlineKeyboardButton(
text=f'📅 12 месяцев ({RENEWAL_PLANS["12"]["price"]} руб.) ({DISCOUNTS["12"]}% 🔥)',
callback_data=f"renew_plan|12|{client_id}",
)
)
back_button = InlineKeyboardButton(
text="🔙 Назад", callback_data="view_keys"
)
builder.row(back_button)
balance = await get_balance(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 callback_query.message.answer(
text=response_message,
reply_markup=builder.as_markup(),
)
else:
await callback_query.message.answer("<b>Ключ не найден.</b>")
except Exception as e:
logger.error(e)
@router.callback_query(F.data.startswith("confirm_delete|"))
async def process_callback_confirm_delete(
callback_query: CallbackQuery, session: AsyncSession
callback_query: types.CallbackQuery, session: Any
):
email = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, email)
record = await session.fetchrow(
"SELECT client_id FROM keys WHERE email = $1", email
)
if record:
client_id = record["client_id"]
server_id = record["server_id"]
response_message = KEY_DELETED_MSG_SIMPLE
response_message = "Ключ успешно удален."
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
text="Назад", callback_data="view_keys"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await delete_key(session, client_id)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
await delete_key(client_id)
await callback_query.message.answer(
response_message,
reply_markup=keyboard,
)
try:
await delete_key_from_cluster(server_id, email, client_id, session)
except Exception as e:
logger.error(
f"Ошибка при удалении ключа {client_id} с сервера {server_id}: {e}"
)
servers = await get_servers_from_db()
async def delete_key_from_servers():
try:
tasks = []
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"Ошибка при удалении ключа {client_id}: {e}")
asyncio.create_task(delete_key_from_servers())
await delete_key_from_db(client_id, session)
else:
response_message = "Ключ не найден или уже удален."
back_button = types.InlineKeyboardButton(
text=BACK, callback_data="view_keys"
text="Назад", callback_data="view_keys"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
await callback_query.message.answer(
response_message,
reply_markup=keyboard,
)
except Exception as e:
logger.error(f"Ошибка при подтверждении удаления ключа: {e}")
await handle_error(callback_query.message.chat.id, callback_query, str(e))
logger.error(e)
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(
callback_query: types.CallbackQuery, session: Any
):
tg_id = callback_query.message.chat.id
plan, client_id = (
callback_query.data.split("|")[1],
callback_query.data.split("|")[2],
)
days_to_extend = 30 * int(plan)
gb_multiplier = {"1": 1, "3": 3, "6": 6, "12": 12}
total_gb = TOTAL_GB * gb_multiplier.get(plan, 1) if TOTAL_GB > 0 else 0
try:
record = await session.fetchrow(
"SELECT email, expiry_time FROM keys WHERE client_id = $1",
client_id,
)
if record:
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=days_to_extend).total_seconds() * 1000
)
else:
new_expiry_time = int(
expiry_time + timedelta(days=days_to_extend).total_seconds() * 1000
)
cost = RENEWAL_PLANS[plan]["price"]
balance = await get_balance(tg_id)
if balance < cost:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="Пополнить баланс", callback_data="pay")
)
builder.row(
InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="profile"
)
)
await callback_query.message.answer(
INSUFFICIENT_FUNDS_MSG,
reply_markup=builder.as_markup(),
)
return
response_message = SUCCESS_RENEWAL_MSG.format(
months=RENEWAL_PLANS[plan]["months"]
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile")
)
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
servers = await get_servers_from_db()
async def renew_key_on_servers():
tasks = []
for cluster_id in servers:
task = asyncio.create_task(
renew_key_in_cluster(
cluster_id,
email,
client_id,
new_expiry_time,
total_gb,
)
)
tasks.append(task)
await asyncio.gather(*tasks)
await update_balance(tg_id, -cost)
await update_key_expiry(client_id, new_expiry_time)
await renew_key_on_servers()
else:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
except Exception as e:
logger.error(e)
+169 -263
View File
@@ -1,308 +1,214 @@
import asyncio
import base64
import random
import re
import time
import urllib.parse
from datetime import datetime
import aiohttp
import asyncpg
from aiohttp import web
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
PROJECT_NAME,
RANDOM_SUBSCRIPTIONS,
SUPERNODE,
SUPPORT_CHAT_URL,
USE_COUNTRY_SELECTION,
USERNAME_BOT,
)
from database import get_key_details, get_servers
from database.models import Server
from handlers.utils import convert_to_bytes
from config import DATABASE_URL, PROJECT_NAME, SUB_MESSAGE, TRANSITION_DATE_STR
from database import get_servers_from_db
from logger import logger
async def fetch_url_content(
url: str, identifier: str
) -> tuple[list[str], dict[str, str]]:
async def fetch_url_content(url, tg_id):
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
logger.info(f"Получение URL: {url} для tg_id: {tg_id}")
async with aiohttp.ClientSession() as session:
async with session.get(url, ssl=False) as response:
if response.status == 200:
content = await response.text()
lines = base64.b64decode(content).decode("utf-8").split("\n")
headers = {k.lower(): v for k, v in response.headers.items()}
logger.debug(
f"Fetched {url}: {len(lines)} lines, headers: {headers}"
logger.info(f"Успешно получен контент с {url} для tg_id: {tg_id}")
return base64.b64decode(content).decode("utf-8").split("\n")
else:
logger.error(
f"Не удалось получить {url} для tg_id: {tg_id}, статус: {response.status}"
)
return lines, headers
return [], {}
return []
except Exception as e:
logger.error(f"Error fetching URL {url}: {e}")
return [], {}
logger.error(f"Ошибка при получении {url} для tg_id: {tg_id}: {e}")
return []
async def combine_unique_lines(
urls: list[str], identifier: str, query_string: str
) -> tuple[list[str], list[dict[str, str]]]:
if SUPERNODE:
logger.info(
f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}"
)
if not urls:
return [], []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
lines, headers = await fetch_url_content(url_with_query, identifier)
return lines, [headers]
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set()
all_headers = []
for result in results:
if isinstance(result, tuple):
lines, headers = result
all_lines.update(filter(None, lines))
all_headers.append(headers)
return list(all_lines), all_headers
async def get_subscription_urls(
server_id: str, email: str, session: AsyncSession, include_remnawave_key: str = None
) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
result = await session.execute(
select(Server.subscription_url).where(Server.server_name == server_id)
)
server_data = result.scalar()
if server_data:
urls.append(f"{server_data}/{email}")
else:
servers = await get_servers(session)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
urls.append(f"{url}/{email}")
if include_remnawave_key:
urls.append(include_remnawave_key)
return urls
def calculate_traffic(
cleaned_subscriptions: list[str],
expiry_time_ms: int | None,
headers_list: list[dict[str, str]],
) -> str:
logger.debug(
f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}"
async def combine_unique_lines(urls, tg_id, query_string):
all_lines = []
logger.info(
f"Начинаем объединение подписок для tg_id: {tg_id}, запрос: {query_string}"
)
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
upload = 0
download = 0
total = 0
for headers in headers_list:
userinfo = headers.get("subscription-userinfo", "")
if userinfo:
parts = userinfo.split(";")
for part in parts:
part = part.strip()
if part.startswith("upload="):
upload += int(part.split("=")[1])
elif part.startswith("download="):
download += int(part.split("=")[1])
elif part.startswith("total="):
total += int(part.split("=")[1])
logger.debug(f"Processed Subscription-Userinfo: {userinfo}")
urls_with_query = [f"{url}?{query_string}" for url in urls]
logger.info(f"Составлены URL-адреса: {urls_with_query}")
country_remaining = {}
for line in cleaned_subscriptions:
if "#" not in line:
continue
try:
_, meta = line.split("#", 1)
except ValueError:
continue
parts = meta.split("-")
country = parts[0].strip()
remaining_str = parts[1].strip() if len(parts) == 2 else ""
if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
logger.debug(f"Found traffic: {value}{unit} for {country}")
for url in urls_with_query:
lines = await fetch_url_content(url, tg_id)
all_lines.extend(lines)
consumed_traffic_bytes = (
total - sum(country_remaining.values()) if country_remaining else download
all_lines = list(set(filter(None, all_lines)))
logger.info(
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для tg_id: {tg_id}"
)
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
download = max(download, consumed_traffic_bytes)
if download == 0 and total == 0 and not country_remaining:
download = 1
result = f"upload={upload}; download={download}; total={total}; expire={expire_timestamp}"
logger.debug(f"Calculated subscription-userinfo: {result}")
return result
return all_lines
def clean_subscription_line(line: str) -> str:
if "#" not in line:
return line
transition_date = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
transition_timestamp_ms_adjusted = transition_timestamp_ms - (3 * 60 * 60 * 1000)
logger.info(
f"Время перехода (с поправкой на часовой пояс): {transition_timestamp_ms_adjusted}"
)
async def handle_old_subscription(request):
email = request.match_info.get("email")
if not email:
logger.warning("Получен запрос без email")
return web.Response(
text="❌ Неверные параметры запроса. Требуется email.",
status=400,
)
logger.info(f"Обработка запроса для старого клиента с email: {email}")
conn = await asyncpg.connect(DATABASE_URL)
try:
base, meta = line.split("#", 1)
except ValueError:
return line
parts = meta.split("-")
country = parts[0].strip() if parts else ""
traffic = ""
for part in parts[1:]:
part_decoded = urllib.parse.unquote(part).strip()
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
traffic = part_decoded
break
meta_clean = f"{country} - {traffic}" if traffic else country
return base + "#" + meta_clean
key_info = await conn.fetchrow(
"SELECT created_at, server_id FROM keys WHERE email = $1", email
)
if not key_info:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(
text="❌ Клиент с таким email не найден.",
status=404,
)
def format_time_left(expiry_time_ms: int | None) -> str:
if not expiry_time_ms:
return "N/A"
now_ms = int(time.time() * 1000)
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
days = int(remaining_sec // 86400)
hours = int((remaining_sec % 86400) // 3600)
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
created_at_ms = key_info["created_at"]
cluster_name = key_info.get("server_id")
if not cluster_name:
logger.warning(f"У клиента с email {email} отсутствует cluster_name.")
return web.Response(
text="❌ Устаревшие данные. Обратитесь в поддержку.",
status=400,
)
logger.info(f"Значение created_at для клиента с email {email}: {created_at_ms}, кластер: {cluster_name}")
def prepare_headers(
user_agent: str,
project_name: str,
subscription_info: str,
subscription_userinfo: str,
) -> dict[str, str]:
if "Happ" in user_agent:
encoded_project_name = f"{project_name}"
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
return {
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
logger.info(
f"Время создания клиента в формате datetime (UTC): {created_at_datetime}"
)
if created_at_ms >= transition_timestamp_ms_adjusted:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(
text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.",
status=400,
)
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
logger.info(f"Сервера в кластере: {cluster_servers}")
urls = []
for server in cluster_servers:
server_subscription_url = f"{server['subscription_url']}/{email}"
urls.append(server_subscription_url)
combined_subscriptions = await combine_unique_lines(urls, email, "")
base64_encoded = base64.b64encode(
"\n".join(combined_subscriptions).encode("utf-8")
).decode("utf-8")
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
headers = {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"support-url": SUPPORT_CHAT_URL,
"announce": "base64:"
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"profile-web-page-url": f"https://t.me/{USERNAME_BOT}",
"subscription-userinfo": subscription_userinfo,
}
elif "Hiddify" in user_agent:
parts = subscription_info.split(" - ")[0].split(": ")
key_info = parts[1] if len(parts) > 1 else parts[0]
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
return {
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
encoded_project_name = f"{project_name}\n{subscription_info}"
announce_str = "🔑 Выберите сервер ⬇️ | 💬 Поддержка ➡️"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"update-always": "true",
"announce": "base64:"
+ base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce-url": f"{SUPPORT_CHAT_URL}",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "3",
"profile-title": "base64:"
+ base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"profile-update-interval": "7",
"profile-title": "base64:" + base64.b64encode(
encoded_project_name.encode("utf-8")
).decode("utf-8"),
}
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
async def handle_subscription(request: web.Request) -> web.Response:
finally:
await conn.close()
async def handle_new_subscription(request):
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id")
if not email or not tg_id:
return web.Response(text="❌ Неверные параметры запроса.", status=400)
logger.warning("Получен запрос с отсутствующими параметрами email или tg_id")
return web.Response(
text="❌ Неверные параметры запроса. Требуются email и tg_id.",
status=400,
)
sessionmaker = request.app["sessionmaker"]
logger.info(f"Обработка запроса для нового клиента: email={email}, tg_id={tg_id}")
async with sessionmaker() as session:
try:
key = await get_key_details(session, email)
if not key:
return web.Response(
text="❌ Клиент с таким email не найден.", status=404
)
conn = await asyncpg.connect(DATABASE_URL)
try:
if int(tg_id) != int(key["tg_id"]):
return web.Response(
text="❌ Неверные данные. Получите свой ключ в боте.", status=403
)
client_data = await conn.fetchrow(
"SELECT tg_id, server_id FROM keys WHERE email = $1", email
)
expiry_time_ms = key["expiry_time"]
server_id = key["server_id"]
remnawave_link = key["remnawave_link"]
time_left = format_time_left(expiry_time_ms)
urls = await get_subscription_urls(
server_id, email, session, include_remnawave_key=remnawave_link
)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string
combined_subscriptions, headers_list = await combine_unique_lines(
urls, tg_id or email, query_string
)
if RANDOM_SUBSCRIPTIONS:
random.shuffle(combined_subscriptions)
cleaned_subscriptions = [
clean_subscription_line(line) for line in combined_subscriptions
]
base64_encoded = base64.b64encode(
"\n".join(cleaned_subscriptions).encode("utf-8")
).decode("utf-8")
subscription_info = f"📄 Подписка: {email}{time_left}"
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(
cleaned_subscriptions, expiry_time_ms, headers_list
)
headers = prepare_headers(
user_agent, PROJECT_NAME, subscription_info, subscription_userinfo
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(
text="❌ Клиент с таким email не найден.",
status=404,
)
return web.Response(text=base64_encoded, headers=headers)
stored_tg_id = client_data["tg_id"]
cluster_name = client_data["server_id"]
except Exception as e:
logger.error(f"Ошибка в handle_subscription: {e}", exc_info=True)
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
if str(tg_id) != str(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
return web.Response(
text="❌ Неверные данные. Получите свой ключ в боте.",
status=403,
)
finally:
await conn.close()
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
urls = []
for server in cluster_servers:
server_subscription_url = f"{server['subscription_url']}/{email}"
urls.append(server_subscription_url)
query_string = request.query_string
logger.info(f"Извлечен query string: {query_string}")
combined_subscriptions = await combine_unique_lines(urls, tg_id, query_string)
base64_encoded = base64.b64encode(
"\n".join(combined_subscriptions).encode("utf-8")
).decode("utf-8")
encoded_project_name = f"{PROJECT_NAME} - {SUB_MESSAGE}"
headers = {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"profile-update-interval": "7",
"profile-title": "base64:" + base64.b64encode(
encoded_project_name.encode("utf-8")
).decode("utf-8"),
}
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
from py3xui import AsyncApi
from client import add_client
from config import ADMIN_PASSWORD, ADMIN_USERNAME, PUBLIC_LINK, TOTAL_GB, TRIAL_TIME
from database import get_servers_from_db, store_key, use_trial
from handlers.texts import INSTRUCTIONS
from handlers.utils import generate_random_email, get_least_loaded_cluster
async def create_trial_key(tg_id: int, session: Any):
client_id = str(uuid.uuid4())
email = generate_random_email()
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
instructions = INSTRUCTIONS
result = {"key": public_link, "instructions": instructions, "email": email}
current_time = datetime.utcnow()
expiry_time = current_time + timedelta(days=TRIAL_TIME)
expiry_timestamp = int(expiry_time.timestamp() * 1000)
clusters = await get_servers_from_db()
least_loaded_cluster = await get_least_loaded_cluster()
if least_loaded_cluster not in clusters:
raise ValueError(f"Кластер {least_loaded_cluster} не найден в базе данных.")
servers_in_cluster = clusters[least_loaded_cluster]
tasks = []
for server_info in servers_in_cluster:
tasks.append(
add_client(
AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
),
client_id,
email,
tg_id,
limit_ip=1,
total_gb=TOTAL_GB,
expiry_time=expiry_timestamp,
enable=True,
flow="xtls-rprx-vision",
inbound_id=int(server_info["inbound_id"]),
)
)
await asyncio.gather(*tasks)
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
server_id=least_loaded_cluster,
session=session,
)
await use_trial(tg_id, session)
return result
+415
View File
@@ -0,0 +1,415 @@
import asyncio
from datetime import datetime, timedelta
import asyncpg
from aiogram import Bot, Router, types
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
DATABASE_URL,
DEV_MODE,
RENEWAL_PLANS,
TOTAL_GB,
TRIAL_TIME,
)
from database import (
add_notification,
check_notification_time,
delete_key,
get_balance,
get_servers_from_db,
update_balance,
update_key_expiry,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.texts import KEY_EXPIRY_10H, KEY_EXPIRY_24H, KEY_RENEWED
from logger import logger
router = Router()
async def notify_expiring_keys(bot: Bot):
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
logger.info("Подключение к базе данных успешно.")
current_time = int(datetime.utcnow().timestamp() * 1000)
threshold_time_10h = int(
(datetime.utcnow() + timedelta(hours=10)).timestamp() * 1000
)
threshold_time_24h = int(
(datetime.utcnow() + timedelta(days=1)).timestamp() * 1000
)
logger.info("Начало обработки уведомлений.")
await notify_inactive_trial_users(bot, conn)
await asyncio.sleep(1)
await check_online_users()
await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h)
await asyncio.sleep(1)
await notify_24h_keys(bot, conn, current_time, threshold_time_24h)
await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time)
await asyncio.sleep(1)
except Exception as e:
logger.error(f"Ошибка при отправке уведомлений: {e}")
finally:
if conn:
await conn.close()
logger.info("Соединение с базой данных закрыто.")
async def is_bot_blocked(bot: Bot, chat_id: int) -> bool:
if DEV_MODE:
return False
try:
member = await bot.get_chat_member(chat_id, bot.id)
blocked = member.status == "left"
logger.info(
f"Статус бота для пользователя {chat_id}: {'заблокирован' if blocked else 'активен'}"
)
return blocked
except Exception as e:
logger.warning(
f"Не удалось проверить статус бота для пользователя {chat_id}: {e}"
)
return False
async def notify_10h_keys(
bot: Bot,
conn: asyncpg.Connection,
current_time: float,
threshold_time_10h: float,
):
records = await conn.fetch(
"""
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified = FALSE
""",
threshold_time_10h,
current_time,
)
logger.info(f"Найдено {len(records)} ключей для уведомления за 10 часов.")
for record in records:
tg_id = record["tg_id"]
email = record["email"]
expiry_time = record["expiry_time"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "Ключ истек"
elif time_left.days > 0:
days_left_message = f"{time_left.days}"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"{hours_left}"
message = KEY_EXPIRY_10H.format(
email=email,
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
days_left_message=days_left_message,
price=RENEWAL_PLANS["1"]["price"],
)
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
try:
keyboard = InlineKeyboardBuilder()
keyboard.button(
text="🔄 Продлить VPN", callback_data=f"renew_key|{email}"
)
keyboard.button(text="💳 Пополнить баланс", callback_data="pay")
keyboard.button(text="👤 Личный кабинет", callback_data="profile")
keyboard.adjust(1)
keyboard = keyboard.as_markup()
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"Уведомление отправлено пользователю {tg_id}.")
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления пользователю {tg_id}: {e}"
)
continue
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE client_id = $1",
record["client_id"],
)
logger.info(f"Обновлено поле notified для клиента {record['client_id']}.")
await asyncio.sleep(1)
async def notify_24h_keys(
bot: Bot,
conn: asyncpg.Connection,
current_time: float,
threshold_time_24h: float,
):
logger.info("Проверка истекших ключей...")
records_24h = await conn.fetch(
"""
SELECT tg_id, email, expiry_time, client_id, server_id FROM keys
WHERE expiry_time <= $1 AND expiry_time > $2 AND notified_24h = FALSE
""",
threshold_time_24h,
current_time,
)
logger.info(f"Найдено {len(records_24h)} ключей для уведомления за 24 часа.")
for record in records_24h:
tg_id = record["tg_id"]
email = record["email"]
expiry_time = record["expiry_time"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "Ключ истек"
elif time_left.days > 0:
days_left_message = f"{time_left.days}"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"{hours_left}"
message_24h = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
expiry_date=expiry_date.strftime("%Y-%m-%d %H:%M:%S"),
)
if not await is_bot_blocked(bot, tg_id) and not DEV_MODE:
try:
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🔄 Продлить VPN",
callback_data=f"renew_key|{email}",
)
)
builder.row(
types.InlineKeyboardButton(
text="💳 Пополнить баланс",
callback_data="pay",
)
)
builder.row(
types.InlineKeyboardButton(
text="👤 Личный кабинет",
callback_data="profile",
)
)
keyboard = builder.as_markup()
await bot.send_message(tg_id, message_24h, reply_markup=keyboard)
logger.info(f"Уведомление за 24 часа отправлено пользователю {tg_id}.")
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления за 24 часа пользователю {tg_id}: {e}"
)
continue
await conn.execute(
"UPDATE keys SET notified_24h = TRUE WHERE client_id = $1",
record["client_id"],
)
logger.info(
f"Обновлено поле notified_24h для клиента {record['client_id']}."
)
await asyncio.sleep(1)
async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
logger.info("Проверка пользователей, не активировавших пробный период...")
inactive_trial_users = await conn.fetch(
"""
SELECT tg_id, username FROM users
WHERE tg_id IN (
SELECT tg_id FROM connections
WHERE trial = 0
) AND tg_id NOT IN (
SELECT DISTINCT tg_id FROM keys
)
"""
)
logger.info(f"Найдено {len(inactive_trial_users)} неактивных пользователей.")
for user in inactive_trial_users:
tg_id = user["tg_id"]
username = user.get("username", "Пользователь")
try:
can_notify = await check_notification_time(
tg_id, "inactive_trial", hours=24, session=conn
)
if can_notify and not await is_bot_blocked(bot, tg_id):
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="🚀 Активировать пробный период",
callback_data="create_key",
)
)
builder.row(
types.InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="profile"
)
)
keyboard = builder.as_markup()
message = (
f"👋 Привет, {username}!\n\n"
f"🎉 У тебя есть бесплатный пробный период на {TRIAL_TIME} дней!\n"
"🕒 Не упусти возможность попробовать наш VPN прямо сейчас.\n\n"
"💡 Нажми на кнопку ниже, чтобы активировать пробный доступ."
)
await bot.send_message(tg_id, message, reply_markup=keyboard)
logger.info(f"Отправлено уведомление неактивному пользователю {tg_id}.")
await add_notification(tg_id, "inactive_trial", session=conn)
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления неактивному пользователю {tg_id}: {e}"
)
await asyncio.sleep(1)
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: float):
logger.info("Проверка истекших ключей...")
expiring_keys = await conn.fetch(
"""
SELECT tg_id, client_id, expiry_time, email FROM keys
WHERE expiry_time <= $1
""",
current_time,
)
logger.info(f"current_time {current_time}")
logger.info(f"Найдено {len(expiring_keys)} истекающих ключей.")
await asyncio.gather(*[process_key(record, bot, conn) for record in expiring_keys])
async def process_key(record, bot, conn):
tg_id = record["tg_id"]
client_id = record["client_id"]
email = record["email"]
balance = await get_balance(tg_id)
expiry_time = record["expiry_time"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
logger.info(
f"Время истечения ключа: {expiry_time} (дата: {expiry_date}), Текущее время: {current_date}, Оставшееся время: {time_left}"
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="👤 Личный кабинет", callback_data="profile"
)
]
]
)
try:
if balance >= RENEWAL_PLANS["1"]["price"]:
await update_balance(tg_id, -RENEWAL_PLANS["1"]["price"])
new_expiry_time = int(
(datetime.utcnow() + timedelta(days=30)).timestamp() * 1000
)
await update_key_expiry(client_id, new_expiry_time)
servers = await get_servers_from_db()
for cluster_id in servers:
await renew_key_in_cluster(
cluster_id, email, client_id, new_expiry_time, TOTAL_GB
)
logger.info(
f"Ключ для пользователя {tg_id} успешно продлен в кластере {cluster_id}."
)
await conn.execute(
"""
UPDATE keys
SET notified = FALSE, notified_24h = FALSE
WHERE client_id = $1
""",
client_id,
)
logger.info(
f"Флаги notified и notified_24 сброшены для клиента с ID {client_id}."
)
try:
await bot.send_message(tg_id, text=KEY_RENEWED, reply_markup=keyboard)
logger.info(
f"Уведомление об успешном продлении отправлено клиенту {tg_id}."
)
except Exception as e:
logger.error(f"Ошибка при отправке уведомления клиенту {tg_id}: {e}")
else:
message_expired = "Ваша подписка истекла и была удалена. Получите новую через личный кабинет"
try:
await bot.send_message(
tg_id, text=message_expired, reply_markup=keyboard
)
logger.info(
f"Уведомление об истечении подписки и удалении ключа отправлено пользователю {tg_id}."
)
except Exception as e:
logger.error(
f"Ошибка при отправке уведомления об истечении подписки пользователю {tg_id}: {e}"
)
servers = await get_servers_from_db()
for cluster_id in servers:
await delete_key_from_cluster(cluster_id, email, client_id)
logger.info(f"Клиент {client_id} удален из кластера {cluster_id}.")
await delete_key(client_id)
logger.info(f"Ключ для клиента с ID {client_id} удален из базы данных.")
except Exception as e:
logger.error(f"Ошибка при обработке ключа для клиента {tg_id}: {e}")
async def check_online_users():
servers = await get_servers_from_db()
for cluster_id, cluster in servers.items():
for server_id, server in enumerate(cluster):
xui = AsyncApi(
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
)
await xui.login()
try:
online_users = len(await xui.client.online())
logger.info(
f"Сервер '{server['server_name']}' доступен, текущее количество активных пользователей: {online_users}."
)
except Exception as e:
logger.error(
f"Не удалось проверить пользователей на сервере {server_id}: {e}"
)

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