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
196 changed files with 114907 additions and 23063 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> • Heleket (Физ. лица) [@JustYay](https://github.com/JustYay) <br> • Wata (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Kassai (Физ. лица) [@JustYay](https://github.com/JustYay)|
| **Безопасность и стабильность** | • Периодические бэкапы <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)
-32
View File
@@ -1,32 +0,0 @@
import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
-25
View File
@@ -1,25 +0,0 @@
from fastapi import FastAPI
from api.routes import users, keys, coupons, servers, tariffs, gifts, referrals, misc
app = FastAPI(
title="SoloBot API (preAlpha)",
version="0.2.0",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json"
)
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
app.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
app.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
app.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
app.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
app.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
app.include_router(misc.router, prefix="/api")
@app.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API. Docs: /api/docs"}
-147
View File
@@ -1,147 +0,0 @@
from typing import Any, Type, Union
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.models import Admin
def _cast_identifier_type(field: InstrumentedAttribute, value: int | str):
column_type = type(field.property.columns[0].type).__name__
if column_type in ("Integer", "BigInteger"):
return int(value)
return value
def generate_crud_router(
*,
model: type,
schema_response: type,
schema_create: type,
schema_update: type,
identifier_field: str = "tg_id",
parameter_name: str = "tg_id",
extra_get_by_email: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model))
return result.scalars().all()
if "get_by_email" in enabled_methods and extra_get_by_email:
@router.get("/by_email", response_model=schema_response)
async def get_by_email(
email: str = Query(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(model).where(model.email == email))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Not found by email")
return obj
if "get_one" in enabled_methods:
@router.get(f"/{{{parameter_name}}}", response_model=schema_response)
async def get_one(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return obj
if "get_all_by_field" in enabled_methods:
@router.get(f"/all/{{{parameter_name}}}", response_model=list[schema_response])
async def get_all_by_field(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
return objs
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: schema_create, # type: ignore
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = payload.dict(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.commit()
await session.refresh(obj)
return obj
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: schema_update, # type: ignore
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for k, v in payload.dict(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return obj
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
field = getattr(model, identifier_field)
casted = _cast_identifier_type(field, value)
result = await session.execute(select(model).where(field == casted))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
await session.delete(obj)
await session.commit()
return {"detail": f"{model.__name__} deleted"}
return router
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-48
View File
@@ -1,48 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
-141
View File
@@ -1,141 +0,0 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.key_utils import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.dict(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
await session.commit()
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
-141
View File
@@ -1,141 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database.models import (
Admin,
BlockedUser,
ManualBan,
Notification,
Payment,
Referral,
TemporaryData,
TrackingSource,
)
router = APIRouter()
router.include_router(
generate_crud_router(
model=Payment,
schema_response=PaymentResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/payments",
tags=["Payments"],
dependencies=[Depends(verify_admin_token)],
)
@router.get("/payments/by_tg_id/{tg_id}", response_model=list[PaymentResponse], tags=["Payments"])
async def get_payments_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Payment).where(Payment.tg_id == tg_id))
payments = result.scalars().all()
if not payments:
raise HTTPException(status_code=404, detail="Payments not found")
return payments
router.include_router(
generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referred_tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/referrals",
tags=["Referrals"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_admin_token)],
)
router.include_router(
generate_crud_router(
model=TrackingSource,
schema_response=TrackingSourceResponse,
schema_create=None,
schema_update=None,
identifier_field="id",
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
-36
View File
@@ -1,36 +0,0 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import ReferralResponse
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-16
View File
@@ -1,16 +0,0 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
-54
View File
@@ -1,54 +0,0 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.key_utils import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
-15
View File
@@ -1,15 +0,0 @@
from .coupons import CouponBase, CouponResponse, CouponUpdate, CouponUsageResponse
from .gifts import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from .keys import KeyDetailsResponse, KeyResponse
from .misc import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from .referrals import ReferralResponse
from .servers import ServerBase, ServerResponse, ServerUpdate
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
-57
View File
@@ -1,57 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, model_validator
class CouponBase(BaseModel):
code: str
amount: int | None = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: int | None = Field(default=None)
@model_validator(mode="after")
def check_exactly_one_of_amount_or_days(self) -> "CouponBase":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
if has_amount and has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
if not has_amount and not has_days:
raise ValueError("Coupon must have exactly one of: 'amount' or 'days'")
return self
class CouponResponse(CouponBase):
id: int
class Config:
from_attributes = True
class CouponUpdate(BaseModel):
code: str | None = None
amount: int | None = None
usage_limit: int | None = None
usage_count: int | None = None
is_used: bool | None = None
days: int | None = Field(default=None)
@model_validator(mode="after")
def validate_amount_or_days(self) -> "CouponUpdate":
if self.amount is None and self.days is None:
return self
if self.amount is not None and self.days is not None:
raise ValueError("Specify only one of: 'amount' or 'days'")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
-47
View File
@@ -1,47 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class GiftUpdate(BaseModel):
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
tariff_id: int | None = None
class Config:
from_attributes = True
-78
View File
@@ -1,78 +0,0 @@
from typing import Optional
from pydantic import BaseModel, Field
class KeyBase(BaseModel):
tg_id: int
client_id: str
email: str | None = None
created_at: int | None = None
expiry_time: int
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = False
alias: str | None = None
notified: bool | None = False
notified_24h: bool | None = False
class KeyResponse(KeyBase):
class Config:
from_attributes = True
class KeyDetailsResponse(BaseModel):
key: str | None
remnawave_link: str | None
server_id: str | None
created_at: int | None
expiry_time: int | None
client_id: str
tg_id: int
email: str | None
is_frozen: bool
balance: float
alias: str | None
expiry_date: str
days_left_message: str
link: str | None
cluster_name: str | None
location_name: str | None
tariff_id: int | None
class Config:
from_attributes = True
class KeyUpdate(BaseModel):
email: str | None = None
expiry_time: int | None = None
key: str | None = None
server_id: str | None = None
remnawave_link: str | None = None
tariff_id: int | None = None
is_frozen: bool | None = None
alias: str | None = None
notified: bool | None = None
notified_24h: bool | None = None
class Config:
from_attributes = True
class KeyCreateRequest(BaseModel):
tg_id: int = Field(..., description="Telegram ID пользователя")
cluster_id: str = Field(..., description="Имя кластера или сервера")
tariff_id: int = Field(..., description="ID тарифа из базы данных")
client_id: str = Field(..., description="UUID клиента (уникальный)")
expiry_timestamp: int = Field(..., description="Срок окончания в миллисекундах")
email: str | None = Field(None, description="Условное имя подписки")
alias: str | None = Field(None, description="пользовательское имя")
remnawave_link: str | None = Field(None, description="Ссылка на подписку Remnawave")
hwid_limit: int | None = Field(None, description="Ограничение по HWID")
traffic_limit_bytes: int | None = Field(None, description="Ограничение трафика в байтах")
is_trial: bool | None = Field(False, description="Флаг триального ключа")
-106
View File
@@ -1,106 +0,0 @@
from datetime import datetime
from typing import Literal, Optional, Union
from pydantic import BaseModel
class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: Literal["success", "pending", "failed"]
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
tg_id: int
banned_at: datetime
reason: str
banned_by: int
until: datetime | None = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
tg_id: int
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
tg_id: int
class Config:
from_attributes = True
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
class Config:
from_attributes = True
-10
View File
@@ -1,10 +0,0 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
-37
View File
@@ -1,37 +0,0 @@
from typing import Optional
from pydantic import BaseModel
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: str | None = None
inbound_id: str
panel_type: str
max_keys: int | None = None
tariff_group: str | None = ""
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: str | None = None
server_name: str | None = None
api_url: str | None = None
subscription_url: str | None = None
inbound_id: str | None = None
panel_type: str | None = None
max_keys: int | None = None
tariff_group: str | None = None
enabled: bool | None = None
class Config:
from_attributes = True
-38
View File
@@ -1,38 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
class TariffResponse(TariffBase):
id: int
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: str | None = None
group_code: str | None = None
duration_days: int | None = None
price_rub: int | None = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
class Config:
from_attributes = True
-38
View File
@@ -1,38 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class UserBase(BaseModel):
tg_id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float = 0.0
trial: int = 0
source_code: str | None = None
class UserResponse(UserBase):
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = None
balance: float | None = None
trial: int | None = None
source_code: str | None = None
class Config:
from_attributes = True
+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 -184
View File
@@ -1,219 +1,100 @@
import os
import subprocess
from datetime import datetime
from datetime import datetime, timedelta
from pathlib import Path
import aiofiles
from aiogram import Bot
from aiogram.types import BufferedInputFile
from bot import bot
from config import (
ADMIN_ID,
BACKUP_CAPTION,
BACKUP_CHANNEL_ID,
BACKUP_CHANNEL_THREAD_ID,
BACKUP_OTHER_BOT_TOKEN,
BACKUP_SEND_MODE,
BACK_DIR,
DB_NAME,
DB_PASSWORD,
DB_USER,
PG_HOST,
PG_PORT,
)
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
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 = {"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 = {"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()
+28 -202
View File
@@ -1,218 +1,44 @@
import os
import subprocess
import time
import traceback
from functools import lru_cache
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from 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())
_last_check_time = 0
_last_git_info = ""
dp.message.middleware(AdminMiddleware())
dp.callback_query.middleware(AdminMiddleware())
dp.message.middleware(UserMiddleware())
dp.callback_query.middleware(UserMiddleware())
dp.message.middleware(DatabaseMiddleware())
dp.callback_query.middleware(DatabaseMiddleware())
dp.message.outer_middleware(DeleteMessageMiddleware())
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
def _get_git_commit_number_uncached() -> str:
repo_url = "https://github.com/Vladless/Solo_bot"
cwd = os.path.abspath(os.path.dirname(__file__))
if not os.path.isdir(os.path.join(cwd, ".git")):
cwd = "/root/Prod/Solo_bot"
logger.info(f"[Git] .git не найден в текущем каталоге, используем {cwd}")
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()
)
branch = "main" if describe.startswith("v") or "release" in describe.lower() else "dev"
except Exception:
branch = "dev"
except Exception as e:
logger.error(f"[Git] Ошибка при получении локального коммита: {e}")
return f"\n(Требуется обновление через CLI (команда <code>sudo solobot</code>): {e})"
try:
subprocess.check_output(["git", "fetch", "origin"], cwd=cwd, env=env)
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:
logger.info("[Git] Локальная версия актуальна")
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 as e:
logger.error(f"[Git] Ошибка при получении удалённого коммита: {e}")
return "\n(Требуется обновление через CLI, команда <code>sudo solobot</code>)"
@lru_cache(maxsize=1)
def _cached_git_info() -> str:
return _get_git_commit_number_uncached()
def get_git_commit_number() -> str:
global _last_check_time, _last_git_info
now = time.time()
if now - _last_check_time > 3600:
_last_check_time = now
_cached_git_info.cache_clear()
_last_git_info = _cached_git_info()
return _last_git_info
def get_version() -> str:
return f"v4.4-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()}"
)
-491
View File
@@ -1,491 +0,0 @@
import os
import re
import shutil
import subprocess
import sys
from time import sleep
import requests
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm, Prompt
from rich.table import Table
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
if not os.environ.get("LC_ALL", "").endswith("UTF-8"):
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("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
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_lines = [
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
]
with Live(refresh_per_second=10) as live:
display = []
for line in logo_lines:
display.append(f"[bold cyan]{line}[/bold cyan]")
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
live.update(panel)
sleep(0.07)
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def 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 auto_update_cli():
"""Обновляет CLI, если отличается от последней версии. Перезапускает при необходимости."""
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code != 200:
console.print("[red]Не удалось получить обновление CLI[/red]")
return
latest_text = response.text
current_path = os.path.realpath(__file__)
with open(current_path, encoding="utf-8") as f:
current_text = f.read()
if current_text != latest_text:
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text)
os.chmod(current_path, 0o755)
console.print("[green]CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path])
else:
console.print("[green]CLI уже актуален[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
def fix_permissions():
"""Устанавливает корректные права на все файлы и папки проекта"""
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
try:
user = os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip()
console.log(f"[cyan]Используем пользователь: {user}[/cyan]")
for root, dirs, files in os.walk(PROJECT_DIR):
for dir in dirs:
if dir == "__pycache__":
pycache_path = os.path.join(root, dir)
subprocess.run(["sudo", "rm", "-rf", pycache_path], check=True)
for file in files:
if file.endswith(".pyc"):
pyc_path = os.path.join(root, file)
subprocess.run(["sudo", "rm", "-f", pyc_path], check=True)
console.log("[blue]Изменение владельца на весь проект...[/blue]")
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
console.log("[blue]Изменение прав доступа (u=rwX,go=rX)...[/blue]")
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
launcher_path = os.path.join(PROJECT_DIR, "cli_launcher.py")
if os.path.exists(launcher_path):
console.log("[blue]Установка флага +x для cli_launcher.py...[/blue]")
subprocess.run(["chmod", "+x", launcher_path], check=True)
console.print(f"[green]Все права восстановлены для пользователя [bold]{user}[/bold][/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
def install_rsync_if_needed():
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
console.print("[blue]Установка rsync...[/blue]")
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]")
python312_path = shutil.which("python3.12")
if not python312_path:
console.print("[red]Не найден python3.12 в системе[/red]")
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
sys.exit(1)
with Progress(
SpinnerColumn(style="green"),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
try:
if os.path.exists("venv"):
shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]")
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
progress.update(task_id, description="Установка зависимостей...")
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
check=True,
)
progress.update(task_id, description="Установка завершена")
except subprocess.CalledProcessError as e:
progress.update(task_id, description="❌ Ошибка при установке")
console.print(f"[red]❌ Ошибка: {e}[/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():
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
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])
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]Обновление с ветки dev завершено.[/green]")
def update_from_release():
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])
install_dependencies()
fix_permissions()
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.8", 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", "Выход")
console.print(table)
def main():
os.chdir(PROJECT_DIR)
auto_update_cli()
print_logo()
try:
while True:
show_menu()
choice = Prompt.ask(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 9)],
show_choices=False,
)
if choice == "1":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if 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":
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 *
-10
View File
@@ -1,10 +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()
-109
View File
@@ -1,109 +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()
-11
View File
@@ -1,11 +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, pool_size=20, max_overflow=30, pool_timeout=15)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
Base = declarative_base()
-43
View File
@@ -1,43 +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,
is_unlimited: bool = False,
max_usages: int | None = None,
):
try:
stmt = insert(Gift).values(
gift_id=gift_id,
sender_tg_id=sender_tg_id,
recipient_tg_id=None,
selected_months=selected_months,
expiry_time=expiry_time,
gift_link=gift_link,
created_at=datetime.utcnow(),
is_used=False,
tariff_id=tariff_id,
is_unlimited=is_unlimited,
max_usages=max_usages,
)
await session.execute(stmt)
await session.commit()
logger.info(f"🎁 Подарок {gift_id} сохранён (tariff_id={tariff_id}, max_usages={max_usages})")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении подарка {gift_id}: {e}")
await session.rollback()
return False
-23
View File
@@ -1,23 +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.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(~Payment.tg_id.in_(subquery))
)
result = await session.execute(stmt)
return [row.tg_id for row in result]
-121
View File
@@ -1,121 +0,0 @@
import json
import sqlite3
import time
from datetime import datetime
from itertools import cycle
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import USE_COUNTRY_SELECTION
from database.models import Key, Server, User
async def import_keys_from_3xui_db(db_path: str, session: AsyncSession) -> tuple[int, int]:
imported = 0
skipped = 0
if USE_COUNTRY_SELECTION:
result = await session.execute(select(Server.name).where(Server.enabled is True, Server.panel_type == "3x-ui"))
else:
result = await session.execute(
select(Server.cluster_name)
.where(Server.enabled is True, Server.panel_type == "3x-ui", Server.cluster_name.isnot(None))
.distinct()
)
server_ids = [row[0] for row in result.fetchall()]
if not server_ids:
raise RuntimeError("❌ Не найдено доступных серверов или кластеров для 3x-ui")
server_cycle = cycle(server_ids)
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT id, remark, settings FROM inbounds")
inbounds = cursor.fetchall()
except Exception as e:
raise RuntimeError(f"Не удалось прочитать SQLite: {e}")
finally:
conn.close()
parsed_clients = []
for inbound_id, _remark, settings_raw in inbounds:
try:
settings = json.loads(settings_raw)
clients = settings.get("clients", [])
for c in clients:
expiry = c.get("expiryTime")
c["expiryTime"] = int(float(expiry)) if expiry else 0
c["limitIp"] = int(c.get("limitIp", 0) or 0)
c["inbound_id"] = inbound_id
parsed_clients.append(c)
except Exception:
continue
now_ts = int(time.time() * 1000)
for c in parsed_clients:
tg_id = c.get("tgId")
client_id = str(c.get("id"))
email = c.get("email")
expiry_time = int(c.get("expiryTime") or now_ts)
created_at = now_ts
server_id = next(server_cycle)
if not tg_id or not client_id:
continue
user_exists = await session.execute(select(User).where(User.tg_id == tg_id))
if not user_exists.scalar():
try:
session.add(
User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
except SQLAlchemyError:
continue
key_exists = await session.execute(select(Key).where(Key.client_id == client_id))
if key_exists.scalar():
skipped += 1
continue
try:
session.add(
Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_time,
key="",
server_id=server_id,
remnawave_link=None,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
)
imported += 1
except SQLAlchemyError:
continue
await session.commit()
return imported, skipped
-36
View File
@@ -1,36 +0,0 @@
from datetime import datetime
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.tg_id == 0))
if not result.scalar_one_or_none():
session.add(
User(
tg_id=0,
username="system",
first_name="System",
is_bot=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
for tg_id in ADMIN_ID:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if not result.scalar_one_or_none():
session.add(
Admin(
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
)
)
await session.commit()
-171
View File
@@ -1,171 +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,
alias: str = 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,
alias=alias,
)
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}")
-218
View File
@@ -1,218 +0,0 @@
import secrets
import uuid
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", ondelete="SET NULL"))
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)
subgroup_title = Column(String, nullable=True)
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
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", ondelete="CASCADE"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
is_unlimited = Column(Boolean, default=False)
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
class GiftUsage(DictLikeMixin, Base):
__tablename__ = "gift_usages"
gift_id = Column(String, ForeignKey("gifts.gift_id"), primary_key=True)
tg_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class ManualBan(DictLikeMixin, Base):
__tablename__ = "manual_bans"
tg_id = Column(BigInteger, primary_key=True)
banned_at = Column(DateTime(timezone=True), default=datetime.utcnow)
reason = Column(Text)
banned_by = Column(BigInteger)
until = Column(DateTime(timezone=True), nullable=True)
class TemporaryData(DictLikeMixin, Base):
__tablename__ = "temporary_data"
tg_id = Column(BigInteger, primary_key=True)
state = Column(String)
data = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow)
class BlockedUser(DictLikeMixin, Base):
__tablename__ = "blocked_users"
tg_id = Column(BigInteger, primary_key=True)
class TrackingSource(DictLikeMixin, Base):
__tablename__ = "tracking_sources"
id = Column(Integer, primary_key=True)
name = Column(String)
code = Column(String, unique=True)
type = Column(String)
created_by = Column(BigInteger)
created_at = Column(DateTime, default=datetime.utcnow)
class Admin(Base):
__tablename__ = "admins"
tg_id = Column(BigInteger, primary_key=True)
token = Column(String, unique=True, nullable=True)
description = Column(String, nullable=True)
role = Column(String, nullable=False, default="admin")
added_at = Column(DateTime, default=datetime.utcnow)
@staticmethod
def generate_token() -> str:
return secrets.token_urlsafe(32)
-136
View File
@@ -1,136 +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 select
from database.models import BlockedUser, Notification
try:
now = datetime.utcnow()
subq_last_notification = (
select(Notification.tg_id, func.max(Notification.last_notification_time).label("last_notification_time"))
.where(Notification.notification_type == notification_type)
.group_by(Notification.tg_id)
.subquery()
)
stmt = (
select(
User.tg_id,
Key.email,
User.username,
User.first_name,
User.last_name,
subq_last_notification.c.last_notification_time,
)
.outerjoin(Key, Key.tg_id == User.tg_id)
.outerjoin(subq_last_notification, subq_last_notification.c.tg_id == User.tg_id)
)
if notification_type == "inactive_trial":
stmt = stmt.where(
and_(
User.trial.in_([0, -1]),
~User.tg_id.in_(select(BlockedUser.tg_id)),
~User.tg_id.in_(select(Key.tg_id.distinct())),
)
)
if tg_ids:
stmt = stmt.where(User.tg_id.in_(tg_ids))
if emails:
stmt = stmt.where(Key.email.in_(emails))
result = await session.execute(stmt)
users = []
for row in result:
last_time = row.last_notification_time
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
if can_notify:
users.append({
"tg_id": row.tg_id,
"email": row.email,
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": (int(last_time.timestamp() * 1000) if last_time else None),
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
return users
except Exception as e:
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
return []
-44
View File
@@ -1,44 +0,0 @@
from datetime import datetime
from pytz import timezone
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
MOSCOW_TZ = timezone("Europe/Moscow")
async def add_payment(session: AsyncSession, tg_id: int, amount: float, payment_system: str):
try:
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
stmt = insert(Payment).values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status="success",
created_at=now_moscow,
)
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 []
-241
View File
@@ -1,241 +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()]
-219
View File
@@ -1,219 +0,0 @@
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Server
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
async def get_server_by_name(session: AsyncSession, server_name: str) -> dict | None:
try:
stmt = select(Server).where(Server.server_name == server_name)
result = await session.execute(stmt)
server = result.scalar_one_or_none()
if server:
return {
"id": server.id,
"cluster_name": server.cluster_name,
"server_name": server.server_name,
"api_url": server.api_url,
"subscription_url": server.subscription_url,
"inbound_id": server.inbound_id,
"panel_type": server.panel_type,
"enabled": server.enabled,
"max_keys": server.max_keys,
"tariff_group": server.tariff_group,
}
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении сервера {server_name}: {e}")
return None
async def update_server_field(session: AsyncSession, server_name: str, field: str, value: any) -> bool:
try:
stmt = update(Server).where(Server.server_name == server_name).values(**{field: value})
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Поле {field} сервера {server_name} обновлено на {value}")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении поля {field} сервера {server_name}: {e}")
await session.rollback()
return False
async def update_server_name_with_keys(session: AsyncSession, old_name: str, new_name: str) -> bool:
try:
from sqlalchemy import update
from database.models import Key
if not await check_unique_server_name(session, new_name):
logger.error(f"❌ Сервер с именем {new_name} уже существует")
return False
stmt_server = update(Server).where(Server.server_name == old_name).values(server_name=new_name)
await session.execute(stmt_server)
stmt_keys = update(Key).where(Key.server_id == old_name).values(server_id=new_name)
await session.execute(stmt_keys)
await session.commit()
logger.info(f"✅ Сервер переименован с {old_name} на {new_name}")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при переименовании сервера {old_name}: {e}")
await session.rollback()
return False
async def get_available_clusters(session: AsyncSession) -> list[str]:
try:
stmt = select(Server.cluster_name).distinct().order_by(Server.cluster_name)
result = await session.execute(stmt)
return [row[0] for row in result.all()]
except SQLAlchemyError as e:
logger.error(f"Ошибка при получении списка кластеров: {e}")
return []
async def update_server_cluster(session: AsyncSession, server_name: str, new_cluster: str) -> bool:
try:
server_data = await get_server_by_name(session, server_name)
if not server_data:
return False
old_cluster = server_data["cluster_name"]
stmt_remaining = select(func.count()).where(
(Server.cluster_name == old_cluster) & (Server.server_name != server_name)
)
result = await session.execute(stmt_remaining)
remaining_servers = result.scalar_one()
if remaining_servers == 0:
stmt_update_keys = update(Key).where(Key.server_id == old_cluster).values(server_id=new_cluster)
await session.execute(stmt_update_keys)
stmt_new_cluster = select(Server.tariff_group).where(Server.cluster_name == new_cluster).limit(1)
result = await session.execute(stmt_new_cluster)
new_tariff_group = result.scalar_one_or_none()
stmt_update = (
update(Server)
.where(Server.server_name == server_name)
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
)
await session.execute(stmt_update)
await session.commit()
logger.info(f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы")
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
await session.rollback()
return False
-135
View File
@@ -1,135 +0,0 @@
from datetime import date, datetime
from sqlalchemy import and_, exists, func, not_, select
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", "cashback"]))
)
)
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", "cashback"]),
)
)
)
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", "cashback"])
)
)
return round(float(result), 2)
async def count_hot_leads(session: AsyncSession) -> int:
subquery_active_keys = (
select(Key.tg_id).where(Key.expiry_time > int(datetime.utcnow().timestamp() * 1000)).distinct()
)
stmt = (
select(Payment.tg_id)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.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()
-138
View File
@@ -1,138 +0,0 @@
import hashlib
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
def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
if not subgroup_title:
return ""
unique_key = f"{subgroup_title}:{group_code}"
hash_object = hashlib.md5(unique_key.encode("utf-8"))
return hash_object.hexdigest()[:8]
async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group_code: str) -> str | None:
result = await session.execute(
select(Tariff.subgroup_title)
.where(Tariff.group_code == group_code, Tariff.subgroup_title.isnot(None))
.distinct()
)
subgroups = [row[0] for row in result.fetchall()]
for subgroup_title in subgroups:
if create_subgroup_hash(subgroup_title, group_code) == subgroup_hash:
return subgroup_title
return None
async def get_tariffs(session: AsyncSession, tariff_id: int = None, group_code: str = None):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
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:
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
return False
-42
View File
@@ -1,42 +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}")
-139
View File
@@ -1,139 +0,0 @@
from sqlalchemy import func, insert, not_, select
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:
source_result = await session.execute(select(TrackingSource.created_at).where(TrackingSource.code == code))
created_at_row = source_result.first()
if not created_at_row:
return None
created_at = created_at_row[0]
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.created_at >= created_at))
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(["coupon", "referral", "cashback"]))
& (Payment.created_at >= created_at)
)
.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", "cashback"]))
& (Payment.created_at >= created_at)
)
.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),
}
-204
View File
@@ -1,204 +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,
GiftUsage,
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))
result = await session.execute(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id))
gift_ids = [row[0] for row in result.all()]
if gift_ids:
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
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:
await session.rollback()
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
raise
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
+9 -10
View File
@@ -1,17 +1,16 @@
from aiogram.filters import BaseFilter
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from database.db import async_session_maker
from database.models import Admin
from aiogram.filters import BaseFilter
from aiogram.types import Message
from config import ADMIN_ID
class IsAdminFilter(BaseFilter):
async def __call__(self, event: Message | CallbackQuery) -> bool:
async def __call__(self, message: Message) -> bool:
try:
async with async_session_maker() as session:
result = await session.execute(select(Admin).where(Admin.tg_id == event.from_user.id))
admin = result.scalar_one_or_none()
return admin is not None
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
-5
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,15 +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,
@@ -30,5 +26,4 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+5 -26
View File
@@ -2,37 +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 .gifts import router as gifts_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,
gifts_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
-194
View File
@@ -1,194 +0,0 @@
import re
from datetime import datetime
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import USERNAME_BOT
from database import create_tracking_source, get_tracking_source_stats
from database.models import TrackingSource, User
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminAdsCallback,
build_ads_delete_confirm_kb,
build_ads_kb,
build_ads_list_kb,
build_ads_stats_kb,
build_cancel_input_kb,
)
router = Router()
class AdminAdsState(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_code = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminAdsState.waiting_for_new_name)
await callback_query.message.edit_text(
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
async def handle_ads_name_input(message: Message, state: FSMContext):
name = message.text.strip()
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.\n\n💡 <b>Правила:</b> только латинские буквы и цифры",
reply_markup=build_cancel_input_kb(),
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(message: Message, state: FSMContext, session: AsyncSession):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
if not re.match(r"^[a-zA-Z0-9]+$", code):
await message.answer(
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
reply_markup=build_cancel_input_kb(),
)
return
code_with_prefix = f"utm_{code}"
try:
await create_tracking_source(
name=name,
code=code_with_prefix,
type_="utm",
created_by=message.from_user.id,
session=session,
)
stats = await get_tracking_source_stats(session, code_with_prefix)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await message.answer(
text=msg,
reply_markup=build_ads_stats_kb(code_with_prefix),
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
await message.answer("❌ Произошла ошибка при создании ссылки.")
finally:
await state.clear()
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession, callback_data: AdminAdsCallback):
try:
result = await session.execute(select(TrackingSource).order_by(TrackingSource.created_at.desc()))
ads = result.scalars().all()
items_per_page = 6
if callback_data.code and callback_data.code.isdigit():
current_page = int(callback_data.code)
else:
current_page = 1
total_pages = (len(ads) + items_per_page - 1) // items_per_page
reply_markup = build_ads_list_kb(ads, current_page, total_pages)
await callback_query.message.edit_text(
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_ads_view(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
stats = await get_tracking_source_stats(session, code)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(text=msg, reply_markup=build_ads_stats_kb(code))
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
reply_markup=build_ads_delete_confirm_kb(code),
)
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
await session.execute(update(User).where(User.source_code == code).values(source_code=None))
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
await session.commit()
await callback_query.message.edit_text(
f"🗑️ Ссылка <code>{code}</code> удалена.",
reply_markup=build_ads_kb(),
)
except Exception as e:
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
def format_ads_stats(stats: dict, username_bot: str) -> str:
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
update_time = now.strftime("%d.%m.%y %H:%M:%S")
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
f"📌 <b>Название:</b> {stats['name']}\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
f"💡 <b>Активность:</b>\n"
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
f"💰 <b>Финансовая информация:</b>\n"
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
-102
View File
@@ -1,102 +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()
items_per_page = 6
start = (current_page - 1) * items_per_page
end = start + items_per_page
page_ads = ads[start:end]
row = []
for i, ad in enumerate(page_ads, 1):
row.append(
InlineKeyboardButton(
text=f"📎 {ad['name']}",
callback_data=AdminAdsCallback(action="view", code=ad["code"]).pack(),
)
)
if i % 2 == 0 or i == len(page_ads):
builder.row(*row)
row = []
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminAdsCallback(action="list", code=f"{current_page - 1}").pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminAdsCallback(action="list", code=f"{current_page + 1}").pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("ads"))
return builder.as_markup()
def build_ads_stats_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔄 Обновить",
callback_data=AdminAdsCallback(action="view", code=code).pack(),
)
builder.button(
text="🗑️ Удалить",
callback_data=AdminAdsCallback(action="delete_confirm", code=code).pack(),
)
builder.row(build_admin_back_btn("ads"))
builder.adjust(1)
return builder.as_markup()
def build_ads_delete_confirm_kb(code: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, удалить",
callback_data=AdminAdsCallback(
action="delete",
code=code,
).pack(),
)
builder.button(
text="❌ Отмена",
callback_data=AdminAdsCallback(action="view", code=code).pack(),
)
builder.adjust(1)
return builder.as_markup()
def build_cancel_input_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="❌ Отмена",
callback_data=AdminAdsCallback(action="cancel_input", code="none").pack(),
)
return builder.as_markup()
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .backups_handler import router
-36
View File
@@ -1,36 +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
-199
View File
@@ -1,199 +0,0 @@
import csv
import io
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from sqlalchemy import delete, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import 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()
class PreemptiveBanStates(StatesGroup):
waiting_for_preemptive_ids = State()
@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("❌ Ошибка при удалении вручную забаненных пользователей.")
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>"
)
@router.message(PreemptiveBanStates.waiting_for_preemptive_ids, IsAdminFilter())
async def handle_preemptive_ids_input(message: Message, state: FSMContext, session: AsyncSession):
lines = message.text.strip().splitlines()
tg_ids = set()
for line in lines:
line = line.strip()
if line.isdigit():
tg_ids.add(int(line))
if not tg_ids:
await message.answer("❌ Не найдено ни одного корректного Telegram ID.")
return
now = datetime.now(timezone.utc)
stmt = (
pg_insert(ManualBan)
.values([
{
"tg_id": tg_id,
"reason": "shadow",
"banned_by": message.from_user.id,
"until": None,
"banned_at": now,
}
for tg_id in tg_ids
])
.on_conflict_do_update(
index_elements=[ManualBan.tg_id],
set_={
"reason": "shadow",
"until": None,
"banned_by": message.from_user.id,
"banned_at": now,
},
)
)
await session.execute(stmt)
await session.commit()
await message.answer(f"✅ Успешно добавлено в теневой бан: <b>{len(tg_ids)}</b> пользователей.")
await state.clear()
-36
View File
@@ -1,36 +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_preemptive").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
-169
View File
@@ -1,169 +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
-326
View File
@@ -1,326 +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)
-86
View File
@@ -1,86 +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 .gifts_handler import router
-275
View File
@@ -1,275 +0,0 @@
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
router = Router()
class GiftCreationState(StatesGroup):
waiting_for_gift_limit = State()
waiting_for_limit_input_or_unlimited = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "gifts"))
async def admin_gift_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🎁 <b>Подарки</b>\nВыберите, что хотите сделать:", reply_markup=build_admin_gifts_kb()
)
@router.callback_query(F.data == "admin_gift_create")
async def admin_create_gift_step1(callback: CallbackQuery, session: AsyncSession):
stmt = select(Tariff).where(Tariff.group_code == "gifts", Tariff.is_active.is_(True)).order_by(Tariff.duration_days)
result = await session.execute(stmt)
tariffs = result.scalars().all()
if not tariffs:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
from collections import defaultdict
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.subgroup_title].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
if t.duration_days % 30 == 0:
duration_text = format_months(t.duration_days // 30)
else:
duration_text = format_days(t.duration_days)
builder.button(text=f"{t.name} {duration_text}", callback_data=f"admin_gift_select|{t.id}")
for subgroup in sorted(k for k in grouped_tariffs if k):
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
builder.row(
types.InlineKeyboardButton(
text=subgroup,
callback_data=f"admin_gift_subgroup|{subgroup_hash}",
)
)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text("🎁 Выберите тариф для подарка:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("admin_gift_subgroup|"))
async def admin_gift_show_tariffs_in_subgroup(callback: CallbackQuery, session: AsyncSession):
try:
subgroup_hash = callback.data.split("|", 1)[1]
subgroup = await find_subgroup_by_hash(session, subgroup_hash, "gifts")
if not subgroup:
await callback.message.edit_text("❌ Подгруппа не найдена.")
return
stmt = (
select(Tariff)
.where(Tariff.group_code == "gifts", Tariff.is_active.is_(True))
.order_by(Tariff.duration_days)
)
result = await session.execute(stmt)
tariffs = result.scalars().all()
filtered = [t for t in tariffs if t.subgroup_title == subgroup]
if not filtered:
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
return
builder = InlineKeyboardBuilder()
for t in filtered:
if t.duration_days % 30 == 0:
duration_text = format_months(t.duration_days // 30)
else:
duration_text = format_days(t.duration_days)
builder.row(
types.InlineKeyboardButton(
text=f"{t.name} {duration_text}",
callback_data=f"admin_gift_select|{t.id}",
)
)
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_gift_create"))
await edit_or_send_message(
target_message=callback.message,
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[ADMIN_GIFT_SUBGROUP] Ошибка при отображении подгруппы: {e}")
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
@router.callback_query(F.data.startswith("admin_gift_select|"))
async def handle_tariff_selection(callback: CallbackQuery, state: FSMContext):
tariff_id = int(callback.data.split("|")[1])
await state.update_data(tariff_id=tariff_id)
await state.set_state(GiftCreationState.waiting_for_limit_input_or_unlimited)
kb = InlineKeyboardBuilder()
kb.button(text="🔙 Назад", callback_data="admin_gift_create")
await callback.message.edit_text(
"🔢 Введите максимальное количество активаций подарка:", reply_markup=kb.as_markup()
)
@router.callback_query(F.data == "gift_limit_unlimited")
async def handle_unlimited_gift(callback: CallbackQuery, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
data = await state.get_data()
session: AsyncSession = callback.bot["session"]
await state.clear()
await finalize_gift(callback.message, session, bot, data, is_unlimited=True)
@router.message(GiftCreationState.waiting_for_limit_input_or_unlimited)
async def handle_limited_gift_input(message: types.Message, session: AsyncSession, state: FSMContext, bot: Bot):
from handlers.payments.gift import finalize_gift
try:
max_usages = int(message.text.strip())
if max_usages <= 0:
raise ValueError
except ValueError:
await message.answer("❌ Введите корректное положительное число.")
return
data = await state.get_data()
data["max_usages"] = max_usages
await state.clear()
await finalize_gift(message, session, bot, data, is_unlimited=False)
@router.callback_query(F.data == "admin_gifts_all")
async def show_gifts_page(callback: CallbackQuery, session: AsyncSession):
await show_gift_list(callback, session, page=1)
@router.callback_query(F.data.startswith("gifts_page|"))
async def paginate_gifts(callback: CallbackQuery, session: AsyncSession):
page = int(callback.data.split("|")[1])
await show_gift_list(callback, session, page)
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
limit = 10
offset = (page - 1) * limit
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(stmt)
gifts = result.scalars().all()
from aiogram.utils.keyboard import InlineKeyboardBuilder
builder = InlineKeyboardBuilder()
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
builder.inline_keyboard.extend(keyboard.inline_keyboard)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
async def show_gift_list(callback: CallbackQuery, session: AsyncSession, page: int):
limit = 10
offset = (page - 1) * limit
stmt = select(Gift).order_by(Gift.created_at.desc()).offset(offset).limit(limit)
result = await session.execute(stmt)
gifts = result.scalars().all()
builder = InlineKeyboardBuilder()
if not gifts:
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Подарки не найдены.", reply_markup=builder.as_markup())
return
keyboard = build_gifts_list_kb(gifts, page, total=len(gifts))
for row in keyboard.inline_keyboard:
builder.row(*row)
await callback.message.edit_text(f"🎁 <b>Список подарков</b>\nСтраница {page}:", reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("gift_view|"))
async def view_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
await callback.message.edit_text("❌ Подарок не найден.")
return
usage_result = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.gift_id == gift_id)
)
used_count = usage_result.scalar_one()
usage_text = f"{used_count}/{gift.max_usages}" if gift.max_usages else ""
duration_days = (gift.expiry_time.date() - gift.created_at.date()).days
if duration_days % 30 == 0:
duration_text = format_months(duration_days // 30)
else:
duration_text = format_days(duration_days)
text = (
f"🎁 <b>Подарок</b>\n"
f"ID: <code>{gift.gift_id}</code>\n"
f"Срок: <b>{duration_text}</b>\n"
f"Активаций: <b>{usage_text}</b>\n"
f"Истекает: <i>{gift.expiry_time.strftime('%d.%m.%Y')}</i>\n"
f"<b>Ссылка для активации:</b>\n<blockquote>{gift.gift_link}</blockquote>"
)
builder = InlineKeyboardBuilder()
builder.button(text="🗑 Удалить", callback_data=f"gift_delete|{gift_id}")
builder.button(text="🔙 Назад", callback_data="admin_gifts_all")
await callback.message.edit_text(text, reply_markup=builder.as_markup())
@router.callback_query(F.data.startswith("gift_delete|"))
async def delete_gift(callback: CallbackQuery, session: AsyncSession):
gift_id = callback.data.split("|")[1]
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.execute(delete(Gift).where(Gift.gift_id == gift_id))
await session.commit()
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад к списку", callback_data="admin_gifts_all")
await callback.message.edit_text("✅ Подарок удалён.", reply_markup=builder.as_markup())
-69
View File
@@ -1,69 +0,0 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.models import Gift, Tariff
from handlers.buttons import BACK
from handlers.utils import format_days, format_months, get_plural_form
from ..panel.keyboard import AdminPanelCallback
def build_admin_gifts_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🎁 Создать подарок", callback_data="admin_gift_create"))
builder.row(
InlineKeyboardButton(text="📦 Все подарки", callback_data="admin_gifts_all"),
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="admin"))
return builder.as_markup()
def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.button(
text=f"{tariff.name}{tariff.duration_days // 30} мес.",
callback_data=f"admin_gift_confirm|{tariff.id}",
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
builder.adjust(1)
return builder.as_markup()
def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row = []
for i, gift in enumerate(gifts):
if gift.selected_months > 0:
duration_text = format_months(gift.selected_months)
else:
days = (gift.expiry_time.date() - gift.created_at.date()).days
duration_text = format_days(days)
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
row.append(
InlineKeyboardButton(
text=button_text,
callback_data=f"gift_view|{gift.gift_id}",
)
)
if len(row) == 2 or i == len(gifts) - 1:
builder.row(*row)
row = []
nav = []
if page > 1:
nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"gifts_page|{page - 1}"))
if len(gifts) == 10:
nav.append(InlineKeyboardButton(text="➡️ Далее", callback_data=f"gifts_page|{page + 1}"))
if nav:
builder.row(*nav)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
return builder.as_markup()
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .management_handler import router
-161
View File
@@ -1,161 +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(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if admin_role == "superadmin":
builder.button(
text="👑 Управление админами",
callback_data=AdminPanelCallback(action="admins").pack(),
)
builder.button(
text="🗄 Управление БД",
callback_data=AdminPanelCallback(action="database").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(),
)
builder.button(
text="📤 Загрузить файл",
callback_data=AdminPanelCallback(action="upload_file").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()
def build_database_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="💾 Создать резервную копию",
callback_data=AdminPanelCallback(action="backups").pack(),
)
builder.button(
text="♻️ Восстановить БД из бэкапа",
callback_data=AdminPanelCallback(action="restore_db").pack(),
)
builder.button(
text="📤 Получить данные БД из панели",
callback_data=AdminPanelCallback(action="export_db").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
return builder.as_markup()
def build_export_db_sources_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌀 Remnawave", callback_data=AdminPanelCallback(action="export_remnawave").pack())
builder.button(text="🧩 3x-ui", callback_data=AdminPanelCallback(action="request_3xui_file").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for tg_id, role in admins:
builder.button(
text=f"🧑 {tg_id} ({role})", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
)
builder.button(text=" Добавить админа", callback_data=AdminPanelCallback(action="add_admin").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
if role == "superadmin":
builder.button(
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
return builder.as_markup()
def build_post_import_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔁 Перевыпустить подписки", callback_data=AdminPanelCallback(action="resync_after_import").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
@@ -1,688 +0,0 @@
import hashlib
import json
import os
import subprocess
import sys
import time
import traceback
from asyncio import sleep
from datetime import datetime
from tempfile import NamedTemporaryFile
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from dateutil import parser
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Admin, Key, Server, User
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import update_subscription
from logger import logger
from middlewares import maintenance
from panels.remnawave import RemnawaveAPI
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminPanelCallback,
build_admin_back_kb_to_admins,
build_admins_kb,
build_back_to_db_menu,
build_database_kb,
build_export_db_sources_kb,
build_management_kb,
build_post_import_kb,
build_role_selection_kb,
build_single_admin_menu,
build_token_result_kb,
)
router = Router()
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
class Import3xuiStates(StatesGroup):
waiting_for_file = State()
class FileUploadState(StatesGroup):
waiting_for_file = State()
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
tg_id = callback_query.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
return
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(admin.role),
)
@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()
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}"
try:
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
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, Key.remnawave_link).limit(1))
example = sample.fetchone()
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, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.answer("❌ Админ не найден.", show_alert=True)
return
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(admin.role))
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
async def show_admins(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Admin.tg_id, Admin.role))
admins = result.all()
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
)
await state.set_state(AdminState.waiting_for_tg_id)
@router.message(AdminState.waiting_for_tg_id)
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
try:
tg_id = int(message.text.strip())
except ValueError:
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if result.scalar_one_or_none():
await message.answer("⚠️ Такой админ уже существует.")
else:
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
await session.commit()
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
role = admin or "moderator"
await callback.message.edit_text(
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
token = Admin.generate_token()
token_hash = hashlib.sha256(token.encode()).hexdigest()
admin.token = token_hash
await session.commit()
msg = await callback.message.edit_text(
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
f"<code>{token}</code>\n\n"
f"⚠️ Это сообщение исчезнет через 5 минут.",
reply_markup=build_token_result_kb(token),
)
await sleep(300)
try:
await msg.delete()
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
tg_id = int(callback_data.action.split("|")[1])
await callback.message.edit_text(
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
try:
_, tg_id_str, role = callback_data.action.split("|")
tg_id = int(tg_id_str)
if role not in ("superadmin", "moderator"):
raise ValueError
except Exception:
await callback.message.edit_text("❌ Неверный формат.")
return
if tg_id == callback.from_user.id:
await callback.message.edit_text(
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
)
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
admin.role = role
await session.commit()
await callback.message.edit_text(
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
await session.commit()
await callback.message.edit_text(
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
async def handle_database_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🗄 <b>Управление базой данных</b>",
reply_markup=build_database_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
"⚠️ Все текущие данные будут перезаписаны.",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(DatabaseState.waiting_for_backup_file)
@router.message(DatabaseState.waiting_for_backup_file)
async def restore_database(message: Message, state: FSMContext, bot: Bot):
document = message.document
if not document or not document.file_name.endswith(".sql"):
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
return
try:
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
tmp_path = tmp_file.name
await bot.download(document, destination=tmp_path)
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
is_custom_dump = False
with open(tmp_path, "rb") as f:
signature = f.read(5)
if signature == b"PGDMP":
is_custom_dump = True
subprocess.run(
[
"sudo",
"-u",
"postgres",
"psql",
"-d",
"postgres",
"-c",
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
check=True,
)
logger.info("[Restore] База данных пересоздана")
os.environ["PGPASSWORD"] = DB_PASSWORD
if is_custom_dump:
result = subprocess.run(
[
"pg_restore",
f"--dbname={DB_NAME}",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"--no-owner",
"--exit-on-error",
tmp_path,
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[
"psql",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-d",
DB_NAME,
"-f",
tmp_path,
],
capture_output=True,
text=True,
)
del os.environ["PGPASSWORD"]
if result.returncode != 0:
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
await message.answer(
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
)
return
await message.answer(
"✅ База данных восстановлена.",
reply_markup=build_back_to_db_menu(),
)
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
await state.clear()
sys.exit(0)
except Exception as e:
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
await message.answer(
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
)
finally:
try:
os.remove(tmp_path)
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
async def handle_export_db(callback: CallbackQuery):
await callback.message.edit_text(
"📤 Выберите панель, с которой требуется получить данные:\n\n"
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
reply_markup=build_export_db_sources_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
async def back_to_database_menu(callback: CallbackQuery):
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
await callback.answer()
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
servers = result.scalars().all()
if not servers:
await callback.message.edit_text(
"❌ Нет доступных Remnawave-серверов.",
reply_markup=build_back_to_db_menu(),
)
return
server = servers[0]
api = RemnawaveAPI(base_url=server.api_url)
users = await api.get_all_users_time(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
)
if not users:
await callback.message.edit_text(
"📭 На панели нет клиентов.",
reply_markup=build_back_to_db_menu(),
)
return
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
added_users = await import_remnawave_users(session, users)
server_id = server.cluster_name or server.server_name
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
preview = ""
for i, user in enumerate(users[:3], 1):
email = user.get("email") or user.get("username") or "-"
expire = user.get("expireAt", "")[:10]
preview += f"{i}. {email} — до {expire}\n"
await callback.message.edit_text(
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
f"<b>Первые 3:</b>\n{preview}",
reply_markup=build_back_to_db_menu(),
)
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
if not tg_id:
continue
exists = await session.execute(select(User).where(User.tg_id == tg_id))
if exists.scalar():
continue
try:
new_user = User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
session.add(new_user)
added += 1
except SQLAlchemyError as e:
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
continue
await session.commit()
return added
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
client_id = user.get("uuid")
email = user.get("email") or user.get("username")
remnawave_link = user.get("subscriptionUrl")
expire_at = user.get("expireAt")
created_at = user.get("createdAt")
if not tg_id or not client_id:
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
continue
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
if exists_stmt.scalar():
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
continue
try:
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_ts,
expiry_time=expire_ts,
key="",
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
session.add(new_key)
added += 1
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
except Exception as e:
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
await session.commit()
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
return added
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(Import3xuiStates.waiting_for_file)
@router.message(Import3xuiStates.waiting_for_file, F.document)
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
file = message.document
if not file.file_name.endswith(".db"):
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
return
file_path = f"/tmp/{file.file_name}"
await message.bot.download(file, destination=file_path)
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
try:
from database.importer import import_keys_from_3xui_db
imported, skipped = await import_keys_from_3xui_db(file_path, session)
await processing_message.edit_text(
f"✅ Восстановление завершено:\n"
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
reply_markup=build_post_import_kb(),
)
except Exception as e:
logger.error(f"[Import 3x-ui] Ошибка: {e}")
await processing_message.edit_text(
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
reply_markup=build_back_to_db_menu(),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
await callback.answer("🔁 Начинаю перевыпуск подписок...")
result = await session.execute(select(Key.tg_id, Key.email))
keys = result.all()
success = 0
failed = 0
for tg_id, email in keys:
try:
await update_subscription(tg_id=tg_id, email=email, session=session)
success += 1
except Exception as e:
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
failed += 1
await callback.message.edit_text(
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
reply_markup=build_back_to_db_menu(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📤 <b>Загрузка файла</b>\n\n"
"Вы можете заменить файл в корневой директории бота.\n\n"
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
"как у уже существующего файла. Он будет автоматически заменён.",
reply_markup=build_admin_back_kb("management"),
)
await state.set_state(FileUploadState.waiting_for_file)
@router.message(FileUploadState.waiting_for_file, F.document)
async def handle_admin_file_upload(message: Message, state: FSMContext):
document = message.document
file_name = document.file_name
if not file_name or "." not in file_name:
await message.answer("❌ У файла должно быть имя с расширением.")
return
dest_path = os.path.abspath(f"./{file_name}")
try:
await message.bot.download(document, destination=dest_path)
await message.answer(
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
reply_markup=build_admin_back_kb("management"),
)
except Exception as e:
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
await message.answer(
f"❌ Не удалось сохранить файл: {e}",
reply_markup=build_admin_back_kb("management"),
)
await state.clear()
-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(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="👤 Поиск пользователя",
callback_data=AdminPanelCallback(action="search_user").pack(),
)
builder.button(
text="🔑 Поиск по подписке",
callback_data=AdminPanelCallback(action="search_key").pack(),
)
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").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()),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
)
if admin_role == "superadmin":
builder.button(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").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, 1, 2, 2, 1, 2 if admin_role == "superadmin" else 0, 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())
-74
View File
@@ -1,74 +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 sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import get_version
from database.models import Admin
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, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
result = await session.execute(select(Admin.role).where(Admin.tg_id == callback_query.from_user.id))
role = result.scalar_one_or_none() or "admin"
markup = build_panel_kb(admin_role=role)
if callback_query.message.text:
try:
await callback_query.message.edit_text(
text=text,
reply_markup=markup,
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=markup,
disable_web_page_preview=True,
)
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query_simple(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await handle_admin_callback_query(callback_query, state, session)
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
result = await session.execute(select(Admin.role).where(Admin.tg_id == message.from_user.id))
role = result.scalar_one_or_none() or "admin"
await message.answer(
text=text,
reply_markup=build_panel_kb(admin_role=role),
disable_web_page_preview=True,
)
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .restart_handler import router
-46
View File
@@ -1,46 +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()
-310
View File
@@ -1,310 +0,0 @@
import asyncio
import json
import re
from datetime import datetime
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, 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()
async def send_broadcast_batch(bot, messages, batch_size=15):
results = []
for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size]
tasks = []
for msg in batch:
tg_id = msg["tg_id"]
text = msg["text"]
photo = msg.get("photo")
keyboard = msg.get("keyboard")
if photo:
task = bot.send_photo(
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
)
else:
task = bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
tasks.append(task)
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for result in batch_results:
if isinstance(result, Exception):
logger.error(f"❌ Ошибка отправки: {result}")
results.append(False)
else:
results.append(True)
if i + batch_size < len(messages):
await asyncio.sleep(1.0)
return results
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
if "BUTTONS:" not in text:
return text, None
parts = text.split("BUTTONS:", 1)
clean_text = parts[0].strip()
buttons_text = parts[1].strip()
if not buttons_text:
return clean_text, None
buttons = []
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
for line in button_lines:
try:
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
button_data = json.loads(cleaned_line)
if not isinstance(button_data, dict) or "text" not in button_data:
logger.warning(f"Неверный формат кнопки: {line}")
continue
text_btn = button_data["text"]
if "callback" in button_data:
callback_data = button_data["callback"]
if len(callback_data) > 64:
logger.warning(f"Callback слишком длинный: {callback_data}")
continue
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
elif "url" in button_data:
url = button_data["url"]
button = InlineKeyboardButton(text=text_btn, url=url)
else:
logger.warning(f"Кнопка без действия: {line}")
continue
buttons.append([button])
except json.JSONDecodeError as e:
logger.warning(f"Ошибка парсинга JSON кнопки: {line} - {e}")
continue
except Exception as e:
logger.error(f"Ошибка создания кнопки: {line} - {e}")
continue
if not buttons:
return clean_text, None
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery):
try:
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
else:
raise
@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=(
"✍️ Введите текст сообщения для рассылки\n\n"
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только <b>текст</b>\n"
"• Только <b>картинку</b>\n"
"• <b>Текст + картинку</b>\n"
"• <b>Сообщение + кнопки</b> (см. формат ниже)\n\n"
"<b>📋 Пример формата кнопок:</b>\n"
"<code>Ваше сообщение</code>\n\n"
"<code>BUTTONS:</code>\n"
'<code>{"text": "👤 Личный кабинет", "callback": "profile"}</code>\n'
'<code>{"text": " Купить подписку", "callback": "buy"}</code>\n'
'<code>{"text": "🎁 Забрать купон", "url": "https://t.me/cupons"}</code>\n'
'<code>{"text": "📢 Канал", "url": "https://t.me/channel"}</code>'
),
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):
original_text = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
clean_text, keyboard = parse_message_buttons(original_text)
max_len = 1024 if photo else 4096
if len(clean_text) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
await state.update_data(text=clean_text, photo=photo, keyboard=keyboard.model_dump() if keyboard else None)
await state.set_state(AdminSender.preview)
if photo:
await message.answer_photo(photo=photo, caption=clean_text, parse_mode="HTML", reply_markup=keyboard)
else:
await message.answer(text=clean_text, parse_mode="HTML", reply_markup=keyboard)
await message.answer(
"👀 Это предпросмотр рассылки.\nОтправить?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_message"),
]
]
),
)
@router.callback_query(F.data == "send_message", IsAdminFilter())
async def handle_send_confirm(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
data = await state.get_data()
text_message = data.get("text")
photo = data.get("photo")
keyboard_data = data.get("keyboard")
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
now_ms = int(datetime.utcnow().timestamp() * 1000)
keyboard = None
if keyboard_data:
try:
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
except Exception as e:
logger.error(f"Ошибка восстановления клавиатуры: {e}")
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) & User.trial.in_([0, -1]))
elif send_to == "cluster":
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 callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
messages = []
for tg_id in tg_ids:
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15)
success_count = sum(1 for result in results if result)
await callback_query.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()
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .servers_handler import router
-103
View File
@@ -1,103 +0,0 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, 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=f"edit_server|{server_name}",
)
builder.button(
text="🔙 Назад",
callback_data=f"cluster_servers|{cluster_name}",
)
builder.adjust(1)
return builder.as_markup()
def build_edit_server_fields_kb(server_name: str, server_data: dict) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="📝 Имя сервера", callback_data=f"edit_server_field|{server_name}|server_name")
)
builder.row(InlineKeyboardButton(text="🗂 Кластер", callback_data=f"edit_server_field|{server_name}|cluster_name"))
builder.row(InlineKeyboardButton(text="🌐 API URL", callback_data=f"edit_server_field|{server_name}|api_url"))
if server_data.get("subscription_url"):
builder.row(
InlineKeyboardButton(
text="📡 Subscription URL", callback_data=f"edit_server_field|{server_name}|subscription_url"
)
)
builder.row(InlineKeyboardButton(text="🔑 Inbound ID", callback_data=f"edit_server_field|{server_name}|inbound_id"))
builder.row(InlineKeyboardButton(text="⚙️ Тип панели", callback_data=f"select_panel_type|{server_name}"))
builder.row(
InlineKeyboardButton(
text="⬅️ Назад", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
)
return builder.as_markup()
def build_panel_type_selection_kb(server_name: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="🌐 3x-ui", callback_data=f"set_panel_type|{server_name}|3x-ui")],
[InlineKeyboardButton(text="🌀 remnawave", callback_data=f"set_panel_type|{server_name}|remnawave")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}")],
]
)
def build_cluster_selection_kb(server_name: str, clusters: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for cluster in clusters:
builder.row(InlineKeyboardButton(text=cluster, callback_data=f"set_cluster|{server_name}|{cluster}"))
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"edit_server|{server_name}"))
return builder.as_markup()
def build_cancel_edit_kb(server_name: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="❌ Отменить", callback_data=f"edit_server|{server_name}")]]
)
-455
View File
@@ -1,455 +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 database.servers import (
get_available_clusters,
get_server_by_name,
update_server_cluster,
update_server_field,
update_server_name_with_keys,
)
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminServerCallback,
build_cancel_edit_kb,
build_cluster_selection_kb,
build_edit_server_fields_kb,
build_manage_server_kb,
build_panel_type_selection_kb,
)
router = Router()
class ServerLimitState(StatesGroup):
waiting_for_limit = State()
class ServerEditState(StatesGroup):
choosing_field = State()
editing_value = 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"]
panel_type = server.get("panel_type") or "не указан"
max_keys = server.get("max_keys")
limit_display = f"{max_keys}" if max_keys else "не задан"
result = await session.execute(select(func.count()).where(Key.server_id == server_name))
subscription_count = result.scalar() or 0
text = (
f"<b>🔧 Информация о сервере {server_name}:</b>\n"
f"<blockquote>"
f"🗂 Кластер: <b>{cluster_name}</b>\n"
f"📡 API URL: <b>{api_url}</b>\n"
)
if subscription_url:
text += f"🌐 Subscription URL: <b>{subscription_url}</b>\n"
text += (
f"🔑 Inbound ID: <b>{inbound_id}</b>\n"
f"⚙️ Тип панели: <b>{panel_type}</b>\n"
f"📈 Лимит ключей: <b>{limit_display}</b>\n"
)
if subscription_count > 0:
text += f"🔑 Подписок на сервере: <b>{subscription_count}</b>\n"
text += "</blockquote>"
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, include_enabled=True)
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 = без лимита)")
@router.callback_query(F.data.startswith("edit_server|"), IsAdminFilter())
async def start_edit_server(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
server_name = callback.data.split("|")[1]
await state.clear()
server_data = await get_server_by_name(session, server_name)
if not server_data:
await callback.message.edit_text("❌ Сервер не найден.")
return
await callback.message.edit_text(
f"<b>✏️ Редактирование сервера: {server_name}</b>\n\nВыберите поле для редактирования:",
reply_markup=build_edit_server_fields_kb(server_name, server_data),
)
@router.callback_query(F.data.startswith("edit_server_field|"), IsAdminFilter())
async def ask_new_field_value(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
_, server_name, field = callback.data.split("|")
if field == "cluster_name":
clusters = await get_available_clusters(session)
await callback.message.edit_text(
f"<b>🗂 Выберите кластер для сервера {server_name}:</b>",
reply_markup=build_cluster_selection_kb(server_name, clusters),
)
return
await state.update_data(server_name=server_name, field=field)
await state.set_state(ServerEditState.editing_value)
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID",
}
await callback.message.edit_text(
f"✏️ Введите новое значение для <b>{field_names.get(field, field)}</b>:",
reply_markup=build_cancel_edit_kb(server_name),
)
@router.callback_query(F.data.startswith("select_panel_type|"), IsAdminFilter())
async def select_panel_type(callback: CallbackQuery):
server_name = callback.data.split("|")[1]
await callback.message.edit_text(
f"<b>⚙️ Выберите тип панели для сервера {server_name}:</b>",
reply_markup=build_panel_type_selection_kb(server_name),
)
@router.callback_query(F.data.startswith("set_panel_type|"), IsAdminFilter())
async def set_panel_type(callback: CallbackQuery, session: AsyncSession):
_, server_name, panel_type = callback.data.split("|")
success = await update_server_field(session, server_name, "panel_type", panel_type)
if success:
await callback.message.edit_text(
f"✅ Тип панели сервера {server_name} изменен на {panel_type}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении типа панели")
@router.callback_query(F.data.startswith("set_cluster|"), IsAdminFilter())
async def set_cluster(callback: CallbackQuery, session: AsyncSession):
_, server_name, new_cluster = callback.data.split("|")
success = await update_server_cluster(session, server_name, new_cluster)
if success:
await callback.message.edit_text(
f"✅ Кластер сервера {server_name} изменен на {new_cluster}",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await callback.message.edit_text("❌ Ошибка при изменении кластера")
@router.message(ServerEditState.editing_value, IsAdminFilter())
async def apply_field_edit(message: types.Message, state: FSMContext, session: AsyncSession):
data = await state.get_data()
server_name = data["server_name"]
field = data["field"]
value = message.text.strip()
if field == "server_name":
success = await update_server_name_with_keys(session, server_name, value)
if success:
server_name = value
else:
await message.answer("❌ Ошибка при изменении имени сервера. Возможно, такое имя уже существует.")
return
else:
success = await update_server_field(session, server_name, field, value)
if success:
field_names = {
"server_name": "имя сервера",
"api_url": "API URL",
"subscription_url": "Subscription URL",
"inbound_id": "Inbound ID",
}
await message.answer(
f"{field_names.get(field, field).capitalize()} изменено",
reply_markup=InlineKeyboardBuilder()
.button(
text="⬅️ Назад к серверу", callback_data=AdminServerCallback(action="manage", data=server_name).pack()
)
.as_markup(),
)
else:
await message.answer("❌ Ошибка при изменении поля")
await state.clear()
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .stats_handler import router
-28
View File
@@ -1,28 +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()
-291
View File
@@ -1,291 +0,0 @@
from collections import Counter
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 bot import bot
from config import ADMIN_ID
from database import (
count_active_keys,
count_hot_leads,
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_durations,
get_tariff_groups,
get_tariff_names,
sum_payments_between,
sum_payments_since,
sum_total_payments,
)
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:
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
today = now.date()
total_users = await count_total_users(session)
today_start = moscow_tz.localize(datetime.combine(today, datetime.min.time()))
today_start_utc = today_start.astimezone(pytz.UTC).replace(tzinfo=None)
users_updated_today = await count_users_updated_today(session, today_start_utc)
registrations_today = await count_users_registered_since(session, today_start_utc)
yesterday_date = today - timedelta(days=1)
yesterday_start = moscow_tz.localize(datetime.combine(yesterday_date, datetime.min.time()))
yesterday_end = moscow_tz.localize(datetime.combine(today, datetime.min.time()))
yesterday_start_utc = yesterday_start.astimezone(pytz.UTC).replace(tzinfo=None)
yesterday_end_utc = yesterday_end.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_yesterday = await count_users_registered_between(session, yesterday_start_utc, yesterday_end_utc)
week_start_date = today - timedelta(days=today.weekday())
week_start = moscow_tz.localize(datetime.combine(week_start_date, datetime.min.time()))
week_start_utc = week_start.astimezone(pytz.UTC).replace(tzinfo=None)
month_start_date = today.replace(day=1)
month_start = moscow_tz.localize(datetime.combine(month_start_date, datetime.min.time()))
month_start_utc = month_start.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_week = await count_users_registered_since(session, week_start_utc)
registrations_month = await count_users_registered_since(session, month_start_utc)
last_month_start_date = (today.replace(day=1) - timedelta(days=1)).replace(day=1)
this_month_start_date = today.replace(day=1)
last_month_start = moscow_tz.localize(datetime.combine(last_month_start_date, datetime.min.time()))
last_month_end = moscow_tz.localize(datetime.combine(this_month_start_date, datetime.min.time()))
last_month_start_utc = last_month_start.astimezone(pytz.UTC).replace(tzinfo=None)
last_month_end_utc = last_month_end.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_last_month = await count_users_registered_between(
session, last_month_start_utc, last_month_end_utc
)
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(now.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_start.replace(tzinfo=None))
total_payments_yesterday = await sum_payments_between(
session, yesterday_start.replace(tzinfo=None), yesterday_end.replace(tzinfo=None)
)
total_payments_week = await sum_payments_since(session, week_start.replace(tzinfo=None))
total_payments_month = await sum_payments_since(session, month_start.replace(tzinfo=None))
total_payments_last_month = await sum_payments_between(
session, last_month_start.replace(tzinfo=None), last_month_end.replace(tzinfo=None)
)
total_payments_all_time = await sum_total_payments(session)
hot_leads_count = await count_hot_leads(session)
update_time = now.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:
moscow_tz = pytz.timezone("Europe/Moscow")
now_moscow = datetime.now(moscow_tz)
update_time = now_moscow.strftime("%d.%m.%y %H:%M")
report_date = now_moscow.date() - timedelta(days=1)
start = moscow_tz.localize(datetime.combine(report_date, datetime.min.time()))
end = moscow_tz.localize(datetime.combine(report_date + timedelta(days=1), datetime.min.time()))
start_utc = start.astimezone(pytz.UTC).replace(tzinfo=None)
end_utc = end.astimezone(pytz.UTC).replace(tzinfo=None)
registrations_today = await count_users_registered_between(session, start_utc, end_utc)
payments_today = await sum_payments_between(session, start.replace(tzinfo=None), end.replace(tzinfo=None))
active_keys = await count_active_keys(session)
text = (
f"🗓️ <b>Сводка за {report_date.strftime('%d.%m.%Y')} с 00:00 до 23:59 МСК</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
-166
View File
@@ -1,166 +0,0 @@
from collections import defaultdict
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database.tariffs import create_subgroup_hash
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()
row = []
for i, group in enumerate(groups):
row.append(
InlineKeyboardButton(
text=group,
callback_data=AdminTariffCallback(action=f"group|{group}").pack(),
)
)
if len(row) == 2 or i == len(groups) - 1:
builder.row(*row)
row = []
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"
grouped = defaultdict(list)
for t in tariffs:
subgroup = t.get("subgroup_title")
grouped[subgroup].append(t)
for subgroup_title, _items in grouped.items():
if subgroup_title:
subgroup_hash = create_subgroup_hash(subgroup_title, group_code)
builder.row(
InlineKeyboardButton(
text=f"{subgroup_title}", callback_data=f"view_subgroup|{subgroup_hash}|{group_code}"
)
)
for t in grouped.get(None, []):
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=f"start_subgrouping|{group_code}"))
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()
)
],
]
)
File diff suppressed because it is too large Load Diff
-3
View File
@@ -1,3 +0,0 @@
__all__ = ("router",)
from .users_handler import router
-375
View File
@@ -1,375 +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 or_, select
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.row(
InlineKeyboardButton(
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.row(
InlineKeyboardButton(
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.row(
InlineKeyboardButton(
text="✉️ Сообщение",
callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="💸 Баланс",
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🤝 Выгрузить рефералов",
callback_data=AdminUserEditorCallback(action="users_export_referrals", tg_id=tg_id).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="❌ Удалить",
callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
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())
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=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack()
)
builder.adjust(1)
return builder.as_markup()
def build_user_ban_type_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="⛔ Навсегда",
callback_data=AdminUserEditorCallback(action="users_ban_forever", tg_id=tg_id).pack(),
),
InlineKeyboardButton(
text="⏳ По сроку",
callback_data=AdminUserEditorCallback(action="users_ban_temporary", tg_id=tg_id).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="👻 Теневой бан",
callback_data=AdminUserEditorCallback(action="users_ban_shadow", tg_id=tg_id).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=True).pack(),
)
)
return builder.as_markup()
File diff suppressed because it is too large Load Diff
-101
View File
@@ -1,101 +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 = "🔐 Моя подписка"
RENEW_SUB = "🔄 Обновить подписку"
# Меню Оплат и баланса
BALANCE_HISTORY = "📊 История пополнения"
PAYMENT = "💳 Пополнить баланс"
COUPON = "🎟️ Активировать купон"
COUPON_RESTART = "🎟️ Попробовать другой купон"
YOOKASSA = "💳 ЮКасса: быстрая оплата"
YOOMONEY = "💳 ЮМани: перевод по карте"
FREEKASSA = "💰 FreeKassa: межд. платежи"
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 = "Купить VPN 🔄 Продлить VPN"
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
# Кнопки касс
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
STARS_BOT = "🤖 Бот для покупки звезд"
WATA_RU = "🇷🇺 WATA: Карты РФ"
WATA_SBP = "🏦 WATA: СБП"
WATA_INT = "🌍 WATA: Международные карты"
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
KASSAI_SBP = "🏦 KassaAI: СБП"
# Кнопки Heleket
HELEKET_CRYPTO = "₿ Heleket: Crypto"

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