324 Commits

Author SHA1 Message Date
Vladless 9c67c401b9 the idempotence fix 2025-08-01 22:22:09 +03:00
Vladless 10eca6c6ac minor fixes 2025-07-30 09:49:56 +03:00
Vladless b947a48ca2 zero-day fixes 2025-07-30 06:34:27 +03:00
Vladislav Lisitsyn 39701b1f55 Update README.md 2025-07-29 19:53:06 +03:00
Vladless 5f69a36077 formatting 2025-07-29 19:45:13 +03:00
Vladless 2556d3842b up bot version 2025-07-29 19:34:49 +03:00
Vladless ffec36fa54 antispam for create_key 2025-07-29 17:17:15 +03:00
Vladless da6815c5f1 fix gifts tariffs and format days 2025-07-28 20:33:50 +03:00
Capybara-z 76c0c468f5 fix: mass sender broadcasting / tariff group display in admin / correct device limit handling / configurable referral QR button / navigation fixes / fixed traffic notifications / fixed country shuffling 2025-07-27 21:34:04 +03:00
Capybara-z 357c8a01e7 Merge pull request #223 from JustYay/dev
Heleket, Wata, KassaAI fix
2025-07-26 13:50:46 +03:00
Boris Kovalskii 595c312e2b Heleket, Wata, KassaAI fix
Fix, when the payment is lower, than acceptable sum by merchant
2025-07-26 17:03:42 +10:00
Vladless 28435d2943 stars bot url for texts 2025-07-26 00:22:16 +03:00
Vladless 171634522e support invite links/ shadow bans/ bulk preemptive bans 2025-07-25 05:40:45 +03:00
Vladless 424cb50942 fix yoomoney router 2025-07-24 23:40:22 +03:00
Vladislav Lisitsyn 15ce453523 Merge pull request #222 from JustYay/dev
Crypto-payment via Heleket
2025-07-24 23:31:46 +03:00
Boris Kovalskii cfe8b2edcd Crypto payment via Heleket 2025-07-24 23:14:36 +10:00
Boris Kovalskii 3b64b28b86 we dont send new message 2025-07-24 20:25:01 +10:00
Boris Kovalskii 918872e4b4 Force text fix 2025-07-24 20:16:08 +10:00
Capybara-z f21b49d5fd Merge pull request #220 from JustYay/dev
Fix paymentid and success and fail urls
2025-07-24 13:03:43 +03:00
Boris Kovalskii 2bf2d98c88 Fix for old users 2025-07-24 20:01:55 +10:00
Boris Kovalskii 63cc967dfc Fix paymentid and success and fail urls 2025-07-24 19:10:14 +10:00
Vladislav Lisitsyn 9bea1d6a6c Merge pull request #219 from JustYay/dev
Back callback fixes
2025-07-24 07:27:56 +03:00
Boris Kovalskii 97234d8835 Security bug fix
Fix duplicate webhook accept
2025-07-24 13:53:33 +10:00
Boris Kovalskii c5e8917043 Custom amount and deletion fixes 2025-07-24 13:34:07 +10:00
Boris Kovalskii b39ae1a1ee Small fixes 2025-07-24 13:27:28 +10:00
Boris Kovalskii d5190ea07e Clear code 2025-07-24 13:24:21 +10:00
Boris Kovalskii 7f35ec0745 Delete message about payment from Kassa AI
Delete message about payment from Kassa AI after success payment, if the message is available
2025-07-24 13:22:45 +10:00
Boris Kovalskii 16b22101af Back callback fixes
Убрано лишнее меню из касс, при возвращении, сейчас возвращает сразу на вкладку balance
2025-07-24 12:53:49 +10:00
Vladislav Lisitsyn ccdb490b84 Merge pull request #218 from JustYay/dev
Kassa AI
2025-07-24 02:58:53 +03:00
Boris Kovalskii c12e61fb41 Clear code 2025-07-24 09:42:23 +10:00
Boris Kovalskii c877aef711 Kassa AI 2025-07-24 03:10:15 +10:00
Vladless a174b7bd81 save alias after update_sub/db pool 2025-07-21 23:03:03 +03:00
Vladless 43f4657089 minor fixes for install dependencies 2025-07-21 19:38:59 +03:00
Capybara-z 50d2d5f716 Fixed key display in admin panel / Fixed earnings calculation in statistics 2025-07-21 19:29:48 +03:00
Capybara-z 34618a3de1 fix: device limit transmission in admin key creation 2025-07-20 20:17:21 +03:00
Vladless 1b930460ca Animation of the CLI 2025-07-20 06:34:58 +03:00
Vladless fe439cd31d CLI 0.2.7/ fixed permissions 2025-07-20 03:18:51 +03:00
Vladless 81733b041a solobot command for different copies/fixes command installation errors 2025-07-20 03:05:04 +03:00
Vladless 7ac599aa18 importer 3x-ui/CLI update/upload file/clearing logs 2025-07-20 02:12:26 +03:00
Capybara-z 279409600b feat: add update time and refresh button to ads cards 2025-07-20 00:02:53 +03:00
Capybara-z d701ce31c8 fix: tariff selection in admin panel and key operation 2025-07-19 23:16:46 +03:00
Capybara-z 315459ed2e fix: key traffic display 2025-07-19 18:16:02 +03:00
Vladless 0dd3641151 bot version up 2025-07-19 01:29:09 +03:00
Vladless c610fb118e formatting/3x-ui db import/cosmetic fixes 2025-07-19 01:14:42 +03:00
Capybara-z 8f471a1e43 Fix statistics admin 2025-07-19 00:52:20 +03:00
Vladless c7b2f15131 fix state in middleware subs 2025-07-18 20:52:56 +03:00
Vladless 12079cac34 fix sub channel 2025-07-18 20:46:02 +03:00
Vladless 96b283c6cb api 0.2.0/ minor fixes 2025-07-18 20:13:39 +03:00
Capybara-z aeee2ec352 fix: broadcast callbacks and button parsing 2025-07-18 16:05:48 +03:00
Vladless 19c0db3690 stars bot link/ defolt hwid limit 0 2025-07-18 04:32:09 +03:00
Vladless ede74ef7af fix time and text/ cosmetic fixes/ description of the payment/ vat_code 2025-07-18 02:07:30 +03:00
Vladislav Lisitsyn 4607c81e7f Merge pull request #217 from TrackLine/dev
Add wata.pro payments
2025-07-17 20:45:20 +03:00
Max Shalenkov cc70ac8f14 remove comments 2025-07-16 22:49:58 +00:00
Max Shalenkov f7eb475292 final fix wata.pro 2025-07-16 22:22:40 +00:00
Max Shalenkov 38da3cca91 fix wata 2025-07-16 23:59:33 +03:00
Max Shalenkov bf1e454f0c Merge branch 'Vladless:dev' into dev 2025-07-16 23:17:46 +03:00
Vladless 36ea55a10d add change domain for remnawave link 2025-07-16 22:08:20 +03:00
Max Shalenkov 9ee2f0addf change payments 2025-07-16 19:49:49 +03:00
Capybara-z 52aa6f81e2 fix Improve server limits and disabled servers handling 2025-07-14 22:21:04 +03:00
Capybara-z b1c53d5fad Improve server limits and disabled servers handling 2025-07-13 00:52:49 +03:00
Capybara-z 52d5f784a2 Show all inbound nodes with country flags in Remnawave 2025-07-12 02:24:29 +03:00
Capybara-z cafe576478 Fix admin management 2025-07-11 19:27:36 +03:00
Capybara-z 9427cb20d9 Improve cluster tariff error handling / Fix hot leads filtering logic 2025-07-11 14:15:04 +03:00
Capybara-z 68b9092749 fix permanent ban functionality / fix statistics timezone consistency 2025-07-10 14:13:20 +03:00
Capybara-z e3887fa66a Add keyboard support for sending messages 2025-07-09 22:11:40 +03:00
Capybara-z ffd08e726d Fix Gifts API from JustYay
Fix Gifts API
2025-07-04 17:44:36 +03:00
Capybara-z 03444b65ba Improve bot version update message 2025-07-04 17:41:11 +03:00
Capybara-z b817b5f087 Add subscription purchase command/Add subscription renewal command/Improve bot version update message 2025-07-04 17:34:21 +03:00
Boris Kovalskii f93dc40f5f Fix Gifts API 2025-07-04 17:19:06 +10:00
Capybara-z 71c8014be1 update version 2025-07-03 19:57:29 +03:00
Capybara-z 33e44abef0 fix: server deletion when disabled / fix: multiple gifts receiving / add: tariff group info in subscription 2025-06-30 21:48:14 +03:00
Capybara-z e7f91a971b add temporary Remnawave API functions for user import / add UTM code validation in ads creation 2025-06-29 17:27:07 +03:00
Capybara-z 8ee133edc6 Fix SSL certificate validation / Fix tariff sorting for subscription renewals 2025-06-26 00:21:31 +03:00
Vladless 8954d3a756 expiry time for update key in api 2025-06-24 00:35:00 +03:00
Vladless 0640c4f1fd add update key for api 2025-06-23 23:18:09 +03:00
Vladless 2c9680cc8d add verify_admin_token 2025-06-23 22:10:05 +03:00
Capybara-z 9d3c0cb4b1 tariff subgroups use hash callbacks / only ungrouped tariffs when creating / add subgroup management 2025-06-23 20:57:07 +03:00
Capybara-z 6bfa94d9bb Add image for balance/payment menu 2025-06-23 00:52:31 +03:00
Vladless 58cda2e5ff add get_all_by_field method 2025-06-22 21:10:56 +03:00
Vladless 2d4cdb0e35 fix router for freekassa 2025-06-22 15:12:43 +03:00
Capybara-z 1331632bdc Improve trial period display in referral offers 2025-06-22 15:09:52 +03:00
Vladless eadcacdedc Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-22 08:36:02 +03:00
Vladless 855398d186 add freekassa_pay 2025-06-22 08:35:17 +03:00
Capybara-z 8bd09018ec fix: renew key flow with subgroups 2025-06-21 21:52:25 +03:00
Vladless 36b074b209 fix sub/captcha for gifts 2025-06-21 19:54:39 +03:00
Capybara-z 318598026c fix: butons trial profil 2025-06-20 22:38:20 +03:00
Capybara-z 755c85db2d fix: renew proper key selection 2025-06-20 16:54:13 +03:00
Capybara-z 9074eb277d fix: add error handling for HWID 2025-06-20 00:52:08 +03:00
Capybara-z 55cbe579f7 fix: trial menu logic / feat: admin cluster pagination 2025-06-20 00:48:06 +03:00
Vladless d9292840c5 subgroups for gifts in admin-panel/bug fix 2025-06-19 18:12:09 +03:00
Vladless f817110476 add requirements for setup bots 2025-06-19 06:58:21 +03:00
Vladless 4699c6a394 Sync Remnawave panel / import users & keys / restore DB / rename buttons / UI tweaks 2025-06-19 05:09:29 +03:00
Capybara-z 0abb83aae1 feat: add broadcast preview with confirmation / improve referral bonuses (percent and fixed RUB) / unify personal message formatting 2025-06-19 00:11:35 +03:00
Capybara-z d532f1211c Fix balance buttons 2025-06-18 19:03:25 +03:00
Capybara-z e4538ec0f2 Fix renewal buttons in notifications 2025-06-18 18:48:44 +03:00
Capybara-z 157f7454bd Fix 3x-ui key renewal / Add option to disable balance button / New subscription purchase/renewal menu / Add pagination for UTM tags 2025-06-18 18:27:34 +03:00
Vladless d9f6013aa1 add api routes 2025-06-16 17:20:49 +03:00
Vladless a3d8a6233a add subgroups for gifts 2025-06-16 00:09:59 +03:00
Capybara-z 5ecb5b66f0 fix sync, skip frozen subs / improve keys update / fix country switch no tariff / improve unfreeze sub / add back button in admin reset hwid 2025-06-15 23:48:33 +03:00
Vladless c9797f9b26 minor fixes 2025-06-15 22:58:44 +03:00
Vladless 19063b3592 add uvicorn for FastApi 2025-06-14 22:28:21 +03:00
Vladless f0c1f62d62 API/ remnawave 1.6.12/ admins in database and more 2025-06-14 21:16:53 +03:00
Vladless e7410ae776 minor improvements to UTM tracking accuracy 2025-06-14 07:01:42 +03:00
Vladless 606af6f52a make get_git_commit_number work under systemd 2025-06-14 06:30:09 +03:00
Vladless 3f22e20c24 CLI 0.2.0/git number for admins 2025-06-14 05:13:20 +03:00
Vladislav Lisitsyn c2110e7304 Update bot.py 2025-06-14 04:22:16 +03:00
Capybara-z c07e55a86e fix: traffic reset / coupon menu display 2025-06-14 03:45:47 +03:00
Vladless 48e15fbaad subgroups in tariffs 2025-06-14 01:58:29 +03:00
Capybara-z e280a4dc12 fix: tariff name display 2025-06-14 01:01:45 +03:00
Capybara-z 493f01d6aa fix: sync delays / tariff name display 2025-06-14 00:56:22 +03:00
Capybara-z a93855e1a6 fix: correct version check to compare against current branch 2025-06-13 22:54:25 +03:00
Capybara-z a9821aced7 add subscription count display for clusters and servers 2025-06-13 21:03:47 +03:00
Vladless 98055bc1ab add payment class for yookassa 2025-06-13 14:32:33 +03:00
Vladless 801dac4884 add payment class for yookassa 2025-06-13 14:31:10 +03:00
Capybara-z a315222df7 fix the back button in key reissue 2025-06-13 14:06:19 +03:00
Vladless 34feb87b0a fix custom amount for robokassa 2025-06-13 12:09:49 +03:00
Vladless ab04498488 id sorted for renew plans and rm cashback in statistic 2025-06-13 08:58:18 +03:00
Vladless 6f8bc8ad24 fix referrals and tariff name in key view 2025-06-13 08:05:18 +03:00
Vladless 1e6c901e43 add idempotency check to prevent duplicate payments 2025-06-13 07:55:12 +03:00
Vladless 2aade7acf9 add idempotency check to prevent duplicate payments 2025-06-13 04:00:05 +03:00
Capybara-z 9ade45347c add full server management with cluster transfer support 2025-06-12 23:47:18 +03:00
Vladless 1fb7f8d412 gifts for more users in admin-panel 2025-06-11 23:09:23 +03:00
Vladless aabfaf10ee CLI 0.1.9/SSL check for servers 2025-06-11 01:32:41 +03:00
Vladless bbc907ef93 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn e7e7de8115 Merge branch 'main' into dev 2025-06-09 22:45:13 +03:00
Vladislav Lisitsyn a65c38394f Update README.md 2025-06-09 22:43:02 +03:00
Vladless 82ad2ae8e9 daily reports and minor fixes 2025-06-09 21:41:30 +03:00
Vladless 3e251a46ea fix permissions for delete tables 2025-06-09 18:43:45 +03:00
Capybara-z c76c8d6544 fix: android tv connection for remnawave 2025-06-05 19:16:05 +03:00
Capybara-z d61c0dcb30 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-05 01:15:21 +03:00
Capybara-z e2d31525ea add: android tv connection for remnawave 2025-06-05 01:14:20 +03:00
Vladless 4dda834805 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-04 14:58:23 +03:00
Vladless 6a46f7e7ff fix custom amount input for stars pay 2025-06-04 14:58:00 +03:00
Capybara-z 05ee8ff1f4 fix remnawave sync logic
- Исправлена логика синхронизации Remnawave, если обновлять нечего пересоздаем.
2025-06-04 00:52:22 +03:00
Vladless 9f696fa9cd sort tariffs by ID 2025-06-03 03:37:25 +03:00
Vladless 9666947125 use update instead of recreate for Remnawave-only clusters 2025-06-03 02:40:56 +03:00
Vladless 0f6d09c06c fix UTC daily report and back button for bans 2025-06-03 00:35:20 +03:00
Vladless f1e3cb0c05 add cryptobot description 2025-06-02 20:41:58 +03:00
Vladless 20a4c0ab38 fix cashback in stats/back tariff/disable cluster 2025-06-02 20:11:02 +03:00
Capybara-z eef645fbcf hide device count for unlimited tariffs 2025-06-01 02:30:46 +03:00
Capybara-z e00588d468 format tariff duration in messages / format delete delay message for expired subscriptions 2025-06-01 01:30:55 +03:00
Vladless 5ab21c9e8d Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-31 20:44:16 +03:00
Vladless cb45a4d760 up bot version 2025-05-31 20:44:04 +03:00
Capybara-z 7d8d874390 group tariffs and sort by duration 2025-05-31 18:08:52 +03:00
Capybara-z 36107a32c7 group tariffs and sort by duration 2025-05-31 16:48:40 +03:00
Vladless c0dc3dcc18 delete cuopons stats for utm 2025-05-31 15:54:29 +03:00
Vladless df43ea2716 fix remna traffic for create_key 2025-05-30 22:12:16 +03:00
Capybara-z 43af9df078 fix: trial key display / show correct trial days / fix trial limits 2025-05-30 19:07:27 +03:00
Capybara-z 31b9d0abdf fix: admin balance buttons / show tariff in key / show tariff on buy/renew / backup send modes 2025-05-30 17:02:37 +03:00
Vladless 572aa88ee1 rebase subs for country_mode 2025-05-30 16:54:44 +03:00
Capybara-z 3dbec2196a fix: coupon renew uses tariff limits / fix autorenew / fix renew keyboard / show tariff info in key / key renew uses correct tariff / fix start menu display 2025-05-29 22:31:28 +03:00
Vladless 9a5cc083ca Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-29 14:38:53 +03:00
Vladless 2172aa02a8 Preserve images inside the img/ folder during updates 2025-05-29 14:37:22 +03:00
Capybara-z 4316bf2209 Fix auto-renewal logic to properly select tariff during renewal 2025-05-29 00:03:04 +03:00
Vladless 331d140b42 restore hot leads export and stats section 2025-05-28 22:49:59 +03:00
Vladless e67dce82d6 actual commit for admin-menu 2025-05-28 22:14:07 +03:00
Vladless b442e70657 CLI 0.1.8/update tariff menu 2025-05-28 20:48:11 +03:00
Vladless 4fb02063fd fix add time in admins 2025-05-28 19:10:46 +03:00
Capybara-z 96fb83f7f4 fix: key_utils.py 2025-05-28 01:18:58 +03:00
Capybara-z cc3b8a0571 Merge branch 'dev' of fix: key management in admin menu / fixed synchronization 2025-05-28 01:08:16 +03:00
Capybara-z 32fda4e8b5 fix: key management in admin menu / fixed synchronization 2025-05-28 00:44:26 +03:00
Capybara-z cd903944fa tariff lookup for country model
- Определение тарифа для режима стран при продлении ключей.
2025-05-27 00:15:36 +03:00
Vladless 3ab1007178 add commit number to bot version display 2025-05-26 23:11:04 +03:00
Vladless 0e820f62c9 add new payment flow for yoomoney/ add payment for refferals 2025-05-26 22:36:26 +03:00
Vladless 3e33513fe9 add support traffic for trial in cluster mode/country mode 2025-05-26 21:37:00 +03:00
Capybara-z daa9710579 exclude referral and coupon payments from statistics 2025-05-26 19:07:19 +03:00
Capybara-z 6b2f4f6ac5 delete accrual statistics 2025-05-26 12:24:43 +03:00
Vladless 8e44c9755b Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-25 23:56:02 +03:00
Vladless d55fbf0fed add trial limits for create clusters_mode 2025-05-25 23:54:43 +03:00
Capybara-z e05f8f8183 add to accrual statistics
- Добавлено логирование начислений купонов.
2025-05-25 23:38:31 +03:00
Capybara-z 1ae19cd351 add to accrual statistics
- Добавлено логирование начислений реферавлов.
2025-05-25 23:35:29 +03:00
Vladislav Lisitsyn d79deb307e Merge pull request #206 from Capybara-z/patch-8
Fix key expiration notification and auto-renewal handling
2025-05-25 20:38:28 +03:00
Vladless 8f494c0b98 add round for balance and less callback for tariffs 2025-05-25 20:34:16 +03:00
Capybara-z 71803174b0 Fix key expiration notification and auto-renewal handling
- Исправлена обработка уведомлений об истечении ключей.
- Добавлена корректная работа с задержкой удаления ключей и авто-продлением подписок.
2025-05-25 19:47:35 +03:00
Vladless 1c660320da cleanup_orphans_before_migrations 2025-05-25 13:13:18 +03:00
Vladless 014dbd7d73 cleanup_orphans_before_migrations 2025-05-25 12:59:42 +03:00
Vladless 22e0d610a7 cleanup_orphans_before_migrations 2025-05-25 12:49:38 +03:00
Vladless d4f77df197 cleanup_orphans_before_migrations 2025-05-25 12:36:40 +03:00
Vladless 0f7a9e58d8 fix back button in view tariff groups 2025-05-24 23:41:25 +03:00
Vladless 9ae2de2eb5 fix key create for user in admin 2025-05-24 23:25:49 +03:00
Vladless 1309c3ef43 minor fixes 2025-05-24 22:32:15 +03:00
Vladless 0edadd9592 fix gift tariff name 2025-05-24 22:01:32 +03:00
Vladless cc403fac67 session for renew_key 2025-05-24 21:53:15 +03:00
Vladless faecb7fb13 trial user in check_notification 2025-05-24 21:31:53 +03:00
Vladless 32cc047943 session in process_callback_view_profile 2025-05-24 20:49:19 +03:00
Vladless d4d2ff1867 ORM update and lots of improvements 2025-05-24 19:30:23 +03:00
Vladless d2d6ba1be9 key column null for table keys 2025-05-20 23:53:49 +03:00
Vladless 96905c0f51 sorted tariffs by id for renew key 2025-05-20 18:23:46 +03:00
Vladless fe2c70ab75 delete tarif_group for servers 2025-05-20 16:12:41 +03:00
Vladless 1174a3ff34 convert balance column 2025-05-20 13:21:31 +03:00
Vladless e66a465eee group by id 2025-05-20 13:04:37 +03:00
Vladless d6ec510055 fix add_notification for blocked users 2025-05-20 12:35:54 +03:00
Vladless 29bd962d49 balance numeric in database 2025-05-20 09:06:47 +03:00
Vladless 8fb8e203ae sudo python3 main.py 2025-05-20 08:55:18 +03:00
Vladless 78cfd35ad8 add tariff_group in get_servers 2025-05-20 08:45:17 +03:00
Vladless e6429fda9d fix delete tariff 2025-05-19 17:15:38 +03:00
Vladless d491178410 add group_code 2025-05-19 14:17:35 +03:00
Vladless b3b21d1f5f minor improvements 2025-05-19 11:08:51 +03:00
Vladless e8030c1db9 discounts tariffs/notify hot leads/change success payment 2025-05-19 09:07:32 +03:00
Vladless a1f0449e5c bot version 2025-05-19 06:03:36 +03:00
Vladless fd3e26dd47 tariff id in callback 2025-05-19 06:03:03 +03:00
Vladless ae39b6df9a statistic tariffs/database logs 2025-05-19 05:41:19 +03:00
Vladless cbd2f58499 ban middleware/ ban users 2025-05-19 03:59:26 +03:00
Vladless 8ea78943fe add daily project report 2025-05-18 00:44:17 +03:00
Vladless 8bd904af6c tariffs view in cluster menu 2025-05-18 00:27:39 +03:00
Vladless b3369d8708 tblocker router 2025-05-17 20:54:23 +03:00
Vladless 4af368549e tariff_id for payments 2025-05-17 20:44:43 +03:00
Vladless 07bbd9b177 data formatted in renew 2025-05-16 20:27:48 +03:00
Vladless 11898a5d13 fix gifts user_data 2025-05-16 18:37:41 +03:00
Vladless e662e0d06e tariffs order by id 2025-05-16 15:46:12 +03:00
Vladless dc2e1158fa ip limit for tariffs 2025-05-16 10:06:26 +03:00
Vladless 109df2354c add tariffs stats 2025-05-16 08:51:43 +03:00
Vladless 4d337e3df5 add device_limit in tariffs 2025-05-16 07:09:18 +03:00
Vladless 9a042c638a tariff customization/traffic for each tariff/ruff 2025-05-16 06:02:18 +03:00
Vladless d520f02c03 add alias for key menu in admin-panel 2025-05-13 06:45:56 +03:00
Vladless 0581327358 add more hwid stats 2025-05-13 04:36:06 +03:00
Vladless 0883cf2bf6 add more stats 2025-05-13 04:32:36 +03:00
Vladless 6067eca053 hwid management in admin-panel and key menu 2025-05-12 23:16:56 +03:00
Vladislav Lisitsyn 48d7e197a1 Merge pull request #205 from Capybara-z/patch-7
Add tblocker blocking notifications
2025-05-12 23:04:28 +03:00
Capybara-z 961514d712 Add tblocker blocking notifications
- Логика обработчки оповещений на вебхук от tblocker
2025-05-12 22:12:01 +03:00
Capybara-z 97bfc1bc43 add tblocker webhook handler
- Добавлен обработчик вебхука tblocker
2025-05-12 22:05:36 +03:00
Oleg Kraev b3b15db8c0 Merge pull request #204 from Capybara-z/patch-6
add sub info
2025-05-12 02:46:05 +03:00
Vladless ce275babc3 4.3-beta 2025-05-11 19:23:27 +03:00
Vladless 3aac6b9872 add support HWID 2025-05-11 16:55:19 +03:00
Oleg Kraev 8813664f3f Update README.md 2025-05-11 16:53:22 +03:00
Capybara-z ebd50f8b3d add sub info
- добавил хедер v2raytun для отображения травика и даты завершения подписки.
2025-05-11 16:14:35 +03:00
Vladless 62fccf2b10 add support remnawave api 1.6.x 2025-05-11 16:12:51 +03:00
Vladless 943027f17f back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless ea0a3dc962 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 2d2a11e5b6 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 55fe1342cf Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn 105f798c26 Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z 944295165d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 18be0049cc fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 26a32b6f07 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn f477094614 Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 367331d113 add state.clear 2025-05-09 20:35:50 +03:00
Vladless 51d40323e5 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 5ddf7f8242 ruff formatting 2025-05-09 01:34:11 +03:00
Vladless e4a73f9aaa set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless b99c090a84 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 15ad1a0f79 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 344e4757ef oops 2025-05-08 17:19:00 +03:00
hteppl 55461aa50b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 24928fe561 Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn f16084b941 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn caeb531445 Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn fefbf4da39 Update README.md 2025-05-08 02:36:49 +03:00
Vladless cd3c19b79d 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 6cd1dd8bc2 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn 3837f47bb6 Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z bff7350224 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless cd4c526da0 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn 38e931446a Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z 55af3bde3d fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn 6bca31f92c Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z f8858e8561 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z d3c729cdc2 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z 4c954409cd Update notification 2025-05-05 21:39:20 +03:00
Capybara-z b2dbdb60b6 Update notification 2025-05-05 21:38:11 +03:00
Oleg Kraev a26d334653 Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless ef1cf1fe26 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless 1356170fb0 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 998cf9a6e1 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 85a6be831e Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 9df5d8469d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn 3133cc7799 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 7b894300b7 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 2621d29ef0 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 76ad4e5821 fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 0056454375 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 3c15589b03 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 4849c899c2 fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless 44b9986a45 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless a9359e9dc0 update version 2025-04-26 23:30:24 +03:00
Vladless 76e730475d get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless 18d3b4246a add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 22121b9171 ruff formatting 2025-04-24 23:20:18 +03:00
Vladless ff498feb7e scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 15fcc0ac70 Update README.md 2025-04-23 22:29:54 +03:00
Vladless b1c73dc1a6 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless 888812babd fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless 023cd1728d update user middleware 2025-04-22 19:21:15 +03:00
Vladless abbd3474b4 update table for utm 2025-04-22 00:41:34 +03:00
Vladless fa17faa424 update version 2025-04-21 22:04:07 +03:00
Vladless 799c657605 refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless fa875fb3bc CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless cc628a08fa CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless fa85fb70f1 hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless 1d5980d725 rm old logic 2025-04-21 01:46:19 +03:00
Vladless 9474ccfb06 refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn c6e91c0543 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn 593206f6a0 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z ea6f5297c1 Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 471714b80d v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn a92a19b84d Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 289e0ae5b5 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless d08b410888 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless 455455d696 beta version 2025-04-18 05:01:47 +03:00
Vladless 57ad26711d fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 24428a030e add column in table servers 2025-04-18 01:34:00 +03:00
Vladless c42fee4298 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless aba9620c0f limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless dd8c87f887 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless eebc2ee1d5 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless 7bd5ecc55f remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 5d25e211df Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z 0887e28b2b Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z e04be209cb Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z d19eee8897 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 3efdeed00c add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless 9e89a2db10 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 59a23ea9bd Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z 216c8f8d81 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn d0f6511c5c Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z e1ecb6a1dc Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless 99c02874ec CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 0cd896d35e gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless ab35ce9178 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless f71e6eb226 add import renew 2025-04-10 15:02:46 +03:00
Vladless cd3b55271a add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl e71de5f3cf Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless 0fc2c535de Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
154 changed files with 17350 additions and 6959 deletions
+2 -1
View File
@@ -7,6 +7,7 @@ __pycache__/
dist/
build/
*.egg-info/
alembic/
# Virtual environments
venv/
@@ -28,6 +29,7 @@ env/
# Sensitive configuration files
config.py
config.ini
alembic.ini
.env
# Backup files
@@ -51,7 +53,6 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
/logs
-1
View File
@@ -1 +0,0 @@
3.12
+58 -77
View File
@@ -1,106 +1,87 @@
# 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности
## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
**SoloBot** реализует множество функций, включая:
## ⚙️ Основные возможности SoloBot
### Управление подписками
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
| 📌 Раздел | 💡 Возможности |
|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Мультипанель** | • Работа с панелями [3x-ui](https://github.com/MHSanaei/3x-ui) <br> • Работа с панелями [Remnawave](https://github.com/remnawave/panel) <br> • Работа в режиме совместимости (различные панели одновременно) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2RayTun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Поддержка платёжных систем** | • YooKassa (ИП / Самозанятые) <br> • YooMoney (Физ. лица) [@TrackLine](https://github.com/TrackLine) <br> • Robokassa (ИП) <br> • Cryptobot [@izzzzzi](https://github.com/izzzzzi) <br> • Telegram Stars <br> • 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> |
### Полный контроль клиента над своими ключами
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img src="https://img.shields.io/badge/Возможности_бота-перейти-ffa726?style=for-the-badge" />
</a>
### Реферальная программа
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
## 🛠 Обновления
### Интеграция платёжных систем
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### SoloBot регулярно получает новые функции и улучшения.
### Безопасность и стабильность
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
#### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
### Уведомления
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
# Наш сайт и полная версия
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
### Переходи на наш сайт!
<h3>
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ffa726?style=for-the-badge" alt="Сайт SoloNetBot">
</a>
</h3>
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
![image](https://github.com/user-attachments/assets/fb0028cd-c588-4548-b290-a4bfcc96a3e1)
### *Настраивайте как вам удобно!*
<h3>
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img src="https://img.shields.io/badge/Попробовать_SoloBot_в_Telegram-жми-ffa726?style=for-the-badge&logo=telegram" alt="Попробовать SoloBot в Telegram">
</a>
</h3>
## Отзывы пользователей:
## Список версий
#### SoloBot уже помог сотням пользователей в нашем сообществе:
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
<h3>
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img src="https://img.shields.io/badge/Читать_отзывы-на_сайте-ffa726?style=for-the-badge&logo=discourse" alt="Читать отзывы">
</a>
</h3>
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование
## 🚨 Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией
| ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|----------------------------------|----------------------------------------------------------------------|
| ⛔ **Перепродажа кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
## Участники проекта
Благодарим всех, кто помогает развивать SoloBot! 💖
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
</a>
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
+32
View File
@@ -0,0 +1,32 @@
import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,147 @@
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
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import CouponBase, CouponResponse, CouponUpdate
from database.models import Coupon
router: APIRouter = generate_crud_router(
model=Coupon,
schema_response=CouponResponse,
schema_create=CouponBase,
schema_update=CouponUpdate,
identifier_field="code",
parameter_name="code",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+48
View File
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,141 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.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
@@ -0,0 +1,141 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,78 @@
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
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
+37
View File
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,38 @@
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
@@ -0,0 +1,38 @@
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
-143
View File
@@ -1,143 +0,0 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payments
(
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL,
amount REAL NOT NULL,
payment_system TEXT NOT NULL,
status TEXT DEFAULT 'success',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS keys
(
tg_id BIGINT NOT NULL,
client_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at BIGINT NOT NULL,
expiry_time BIGINT NOT NULL,
key TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT 'cluster1',
notified BOOLEAN NOT NULL DEFAULT FALSE,
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tg_id, client_id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'is_frozen'
) THEN
ALTER TABLE keys ADD COLUMN is_frozen BOOLEAN DEFAULT FALSE;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'keys' AND column_name = 'alias'
) THEN
ALTER TABLE keys ADD COLUMN alias TEXT;
END IF;
END$$;
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
referrer_tg_id BIGINT NOT NULL,
reward_issued BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupons
(
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
days INTEGER CHECK (days > 0 OR days IS NULL),
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'coupons' AND column_name = 'days'
) THEN
ALTER TABLE coupons ADD COLUMN days INTEGER CHECK (days > 0 OR days IS NULL);
END IF;
END$$;
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (coupon_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications
(
tg_id BIGINT NOT NULL,
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
notification_type TEXT NOT NULL,
PRIMARY KEY (tg_id, notification_type)
);
CREATE TABLE IF NOT EXISTS servers
(
id SERIAL PRIMARY KEY,
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
inbound_id TEXT NOT NULL,
UNIQUE (cluster_name, server_name)
);
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS temporary_data (
tg_id BIGINT PRIMARY KEY NOT NULL,
state TEXT NOT NULL,
data JSONB NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS blocked_users (
tg_id BIGINT PRIMARY KEY,
blocked_at TIMESTAMP DEFAULT NOW()
);
+67 -6
View File
@@ -6,10 +6,24 @@ from pathlib import Path
import aiofiles
from aiogram import Bot
from aiogram.types import BufferedInputFile
from bot import bot
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT
from 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 logger import logger
@@ -141,18 +155,65 @@ async def _send_backup_to_admins(backup_file_path: str) -> None:
if not backup_file_path or not os.path.exists(backup_file_path):
raise FileNotFoundError(f"Файл бэкапа не найден: {backup_file_path}")
async def send_default():
for admin_id in ADMIN_ID:
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
try:
async with aiofiles.open(backup_file_path, "rb") as backup_file:
backup_data = await backup_file.read()
filename = os.path.basename(backup_file_path)
backup_input_file = BufferedInputFile(file=backup_data, filename=filename)
for admin_id in ADMIN_ID:
if BACKUP_SEND_MODE == "default":
await send_default()
elif BACKUP_SEND_MODE == "channel":
channel_id = BACKUP_CHANNEL_ID.strip()
thread_id = BACKUP_CHANNEL_THREAD_ID.strip()
if not channel_id:
logger.error("BACKUP_CHANNEL_ID не задан для режима 'channel', fallback на default")
await send_default()
return
send_kwargs = {"chat_id": channel_id, "document": backup_input_file}
if thread_id:
send_kwargs["message_thread_id"] = int(thread_id)
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
try:
await bot.send_document(chat_id=admin_id, document=backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {admin_id}")
await bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен в канал: {channel_id} (топик: {thread_id})")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id}: {e}")
logger.error(f"Не удалось отправить бэкап в канал {channel_id}: {e}, fallback на default")
await send_default()
elif BACKUP_SEND_MODE == "bot":
if not BACKUP_OTHER_BOT_TOKEN:
logger.error("BACKUP_OTHER_BOT_TOKEN не задан для режима 'bot', fallback на default")
await send_default()
return
other_bot = Bot(token=BACKUP_OTHER_BOT_TOKEN)
try:
for admin_id in ADMIN_ID:
try:
send_kwargs = {"chat_id": admin_id, "document": backup_input_file}
if BACKUP_CAPTION:
send_kwargs["caption"] = BACKUP_CAPTION
await other_bot.send_document(**send_kwargs)
logger.info(f"Бэкап базы данных отправлен админу через другого бота: {admin_id}")
except Exception as e:
logger.error(f"Не удалось отправить бэкап админу {admin_id} через другого бота: {e}")
await other_bot.session.close()
except Exception as e:
logger.error(f"Ошибка при отправке через другого бота: {e}, fallback на default")
await send_default()
else:
logger.error(f"Неизвестный BACKUP_SEND_MODE: {BACKUP_SEND_MODE}, fallback на default")
await send_default()
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
logger.error(f"Ошибка при отправке бэкапа: {e}")
raise
+148 -22
View File
@@ -1,5 +1,10 @@
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
@@ -12,26 +17,107 @@ from aiogram.utils.markdown import hbold
from config import ADMIN_ID, API_TOKEN
from filters.private import IsPrivateFilter
from logger import logger
from middlewares import register_middleware
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
version = "4.1-Release"
register_middleware(dp)
_last_check_time = 0
_last_git_info = ""
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:
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
@@ -45,25 +131,45 @@ async def errors_handler(
or "message to delete not found" in error_message
):
logger.warning("Отправляем стартовое меню.")
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message, state=dp.storage, session=None, admin=False, captcha=False
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
@@ -74,19 +180,39 @@ async def errors_handler(
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
try:
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
await start_command(event.update.message, state=dp.storage, session=None, admin=False, captcha=False)
elif event.update.callback_query:
await handle_start_callback_query(
event.update.callback_query, state=dp.storage, session=None, admin=False, captcha=False
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}")
from handlers.start import handle_start_callback_query, start_command
if event.update.message:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.message.chat.id,
user_id=event.update.message.from_user.id,
)
await start_command(
event.update.message,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
elif event.update.callback_query:
fsm_context = dp.fsm.get_context(
bot=bot,
chat_id=event.update.callback_query.message.chat.id,
user_id=event.update.callback_query.from_user.id,
)
await handle_start_callback_query(
event.update.callback_query,
state=fsm_context,
session=None,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Failed to send error details: {exception}")
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Unexpected error in error handler: {exception}")
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
Regular → Executable
+324 -88
View File
@@ -1,18 +1,26 @@
import os
import sys
import subprocess
import requests
from rich.console import Console
from rich.prompt import Prompt, Confirm
from rich.table import Table
from rich.text import Text
import re
import shutil
import subprocess
import sys
from time import sleep
import requests
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm, Prompt
from rich.table import Table
from config import BOT_SERVICE
try:
sys.stdin.reconfigure(encoding='utf-8')
sys.stdout.reconfigure(encoding='utf-8')
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
@@ -20,41 +28,167 @@ 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
console = Console()
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
def print_logo():
logo = Text("""
███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗
██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝
███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║
╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║
███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║
╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝
""", style="bold cyan")
console.print(logo)
logo_lines = [
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
]
with Live(refresh_per_second=10) as live:
display = []
for line in logo_lines:
display.append(f"[bold cyan]{line}[/bold cyan]")
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
live.update(panel)
sleep(0.07)
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def backup_project():
console.print("[yellow]📦 Создаётся резервная копия проекта...[/yellow]")
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]")
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]")
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]")
@@ -62,25 +196,55 @@ def install_git_if_needed():
def install_dependencies():
console.print("[blue]🔧 Установка зависимостей...[/blue]")
with console.status("[bold green]Устанавливаются зависимости...[/bold green]"):
console.print("[blue]Установка зависимостей...[/blue]")
python312_path = shutil.which("python3.12")
if not python312_path:
console.print("[red]Не найден python3.12 в системе[/red]")
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
sys.exit(1)
with Progress(
SpinnerColumn(style="green"),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
try:
subprocess.run("bash -c 'source venv/bin/activate && pip install -r requirements.txt'", shell=True, check=True)
except subprocess.CalledProcessError:
console.print("[red]❌ Ошибка при установке зависимостей.[/red]")
if os.path.exists("venv"):
shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]")
subprocess.run(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():
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(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, "r", encoding="utf-8") as f:
with open(path, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
@@ -107,48 +271,73 @@ def update_from_beta():
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(f"[cyan]🔢 Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
return
else:
console.print("[red]⚠️ Не удалось определить версии.[/red]")
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
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]📥 Клонируем репозиторий dev во временную папку...[/cyan]")
console.print("[cyan]Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
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]")
console.print("[green]Обновление с ветки dev завершено.[/green]")
def update_from_release():
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
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
)
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]
@@ -161,31 +350,58 @@ def update_from_release():
selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
)
tag_name = tag_choices[int(selected) - 1]
console.print(f"[cyan]📥 Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(f"git clone --depth 1 --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}", shell=True, check=True)
if not 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(f'rsync -a --exclude=img --exclude=handlers/buttons.py {TEMP_DIR}/ {PROJECT_DIR}/', shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
table.add_row("1", "Обновить до BETA")
table.add_row("2", "Обновить до последнего релиза")
table.add_row("2", "Обновить/откатить до релиза")
table.add_row("3", "Назад в меню")
console.print(table)
@@ -198,11 +414,9 @@ def show_update_menu():
def show_menu():
table = Table(title="Solobot CLI", title_style="bold magenta", header_style="bold blue")
table = Table(title="Solobot CLI v0.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)")
@@ -211,44 +425,66 @@ def show_menu():
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Выход")
console.print(table)
def main():
if os.geteuid() != 0:
console.print("[bold red]⛔ Требуется запуск от имени root или через sudo.[/bold red]")
sys.exit(1)
os.chdir(PROJECT_DIR)
os.chdir(PROJECT_DIR)
auto_update_cli()
print_logo()
while True:
show_menu()
choice = Prompt.ask("[bold blue]Введите номер действия[/bold blue]", choices=[str(i) for i in range(1, 9)])
if choice == "1":
os.system(f"sudo systemctl start {SERVICE_NAME}")
elif choice == "2":
if Confirm.ask("[green]Вы действительно хотите запустить main.py вручную?[/green]"):
os.system("sudo venv/bin/python main.py")
elif choice == "3":
if Confirm.ask("[yellow]Вы действительно хотите перезапустить бота?[/yellow]"):
os.system(f"sudo systemctl restart {SERVICE_NAME}")
elif choice == "4":
if Confirm.ask("[red]Вы уверены, что хотите остановить бота?[/red]"):
os.system(f"sudo systemctl stop {SERVICE_NAME}")
elif choice == "5":
os.system(f"sudo journalctl -u {SERVICE_NAME} -n 80 --no-pager")
elif choice == "6":
os.system(f"sudo systemctl status {SERVICE_NAME}")
elif choice == "7":
show_update_menu()
elif choice == "8":
console.print("[bold cyan] Выход из CLI. Удачного дня![/bold cyan]")
break
try:
while True:
show_menu()
choice = 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__":
-1779
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
from .bans import *
from .coupons import *
from .db import async_session_maker
from .gifts import *
from .hot_leads import *
from .init_db import *
from .keys import *
from .notifications import *
from .payments import *
from .referrals import *
from .servers import *
from .statistics import *
from .tariffs import *
from .temporary_data import *
from .tracking_sources import *
from .users import *
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import BlockedUser
async def create_blocked_user(session: AsyncSession, tg_id: int):
stmt = insert(BlockedUser).values(tg_id=tg_id).on_conflict_do_nothing(index_elements=[BlockedUser.tg_id])
await session.execute(stmt)
await session.commit()
+109
View File
@@ -0,0 +1,109 @@
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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,121 @@
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
@@ -0,0 +1,36 @@
from datetime import datetime
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker, engine
from database.models import Admin, Base, User
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with async_session_maker() as session:
result = await session.execute(select(User).where(User.tg_id == 0))
if not result.scalar_one_or_none():
session.add(
User(
tg_id=0,
username="system",
first_name="System",
is_bot=True,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
)
for tg_id in ADMIN_ID:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if not result.scalar_one_or_none():
session.add(
Admin(
tg_id=tg_id, role="superadmin", description="Imported from config", added_at=datetime.utcnow()
)
)
await session.commit()
+171
View File
@@ -0,0 +1,171 @@
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
@@ -0,0 +1,218 @@
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
@@ -0,0 +1,136 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,241 @@
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
@@ -0,0 +1,219 @@
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
@@ -0,0 +1,135 @@
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
@@ -0,0 +1,138 @@
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
@@ -0,0 +1,42 @@
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
@@ -0,0 +1,139 @@
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
@@ -0,0 +1,204 @@
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 -4
View File
@@ -1,12 +1,17 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from config import ADMIN_ID
from database.db import async_session_maker
from database.models import Admin
class IsAdminFilter(BaseFilter):
async def __call__(self, message: Message) -> bool:
async def __call__(self, event: Message | CallbackQuery) -> bool:
try:
return message.from_user.id in ADMIN_ID
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
except Exception:
return False
+2
View File
@@ -12,6 +12,7 @@ from .notifications import router as notifications_router
from .pay import router as pay_router
from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
@@ -29,4 +30,5 @@ router.include_routers(
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+6
View File
@@ -2,16 +2,19 @@ __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
@@ -29,4 +32,7 @@ router.include_routers(
coupons_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
gifts_router,
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .ads_handler import router
+194
View File
@@ -0,0 +1,194 @@
import re
from datetime import datetime
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from config import USERNAME_BOT
from database import create_tracking_source, get_tracking_source_stats
from database.models import TrackingSource, User
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import (
AdminAdsCallback,
build_ads_delete_confirm_kb,
build_ads_kb,
build_ads_list_kb,
build_ads_stats_kb,
build_cancel_input_kb,
)
router = Router()
class AdminAdsState(StatesGroup):
waiting_for_new_name = State()
waiting_for_new_code = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "ads"), IsAdminFilter())
async def handle_ads_menu(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
@router.callback_query(AdminAdsCallback.filter(F.action == "create"), IsAdminFilter())
async def handle_ads_create(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(AdminAdsState.waiting_for_new_name)
await callback_query.message.edit_text(
"📝 Введите <b>название</b> новой ссылки:", reply_markup=build_cancel_input_kb()
)
@router.message(AdminAdsState.waiting_for_new_name, IsAdminFilter())
async def handle_ads_name_input(message: Message, state: FSMContext):
name = message.text.strip()
await state.update_data(name=name)
await state.set_state(AdminAdsState.waiting_for_new_code)
await message.answer(
f"🔗 Введите <b>код ссылки</b> для: <code>{name}</code>.\n\n💡 <b>Правила:</b> только латинские буквы и цифры",
reply_markup=build_cancel_input_kb(),
)
@router.message(AdminAdsState.waiting_for_new_code, IsAdminFilter())
async def handle_ads_code_input(message: Message, state: FSMContext, session: AsyncSession):
code = message.text.strip()
data = await state.get_data()
name = data["name"]
if not re.match(r"^[a-zA-Z0-9]+$", code):
await message.answer(
"❌ Код может содержать только латинские буквы и цифры\nВведите код заново:",
reply_markup=build_cancel_input_kb(),
)
return
code_with_prefix = f"utm_{code}"
try:
await create_tracking_source(
name=name,
code=code_with_prefix,
type_="utm",
created_by=message.from_user.id,
session=session,
)
stats = await get_tracking_source_stats(session, code_with_prefix)
if not stats:
await message.answer("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await message.answer(
text=msg,
reply_markup=build_ads_stats_kb(code_with_prefix),
)
except Exception as e:
logger.error(f"Ошибка при создании ссылки: {e}", exc_info=True)
await message.answer("❌ Произошла ошибка при создании ссылки.")
finally:
await state.clear()
@router.callback_query(AdminAdsCallback.filter(F.action == "list"), IsAdminFilter())
async def handle_ads_list(callback_query: CallbackQuery, session: AsyncSession, callback_data: AdminAdsCallback):
try:
result = await session.execute(select(TrackingSource).order_by(TrackingSource.created_at.desc()))
ads = result.scalars().all()
items_per_page = 6
if callback_data.code and callback_data.code.isdigit():
current_page = int(callback_data.code)
else:
current_page = 1
total_pages = (len(ads) + items_per_page - 1) // items_per_page
reply_markup = build_ads_list_kb(ads, current_page, total_pages)
await callback_query.message.edit_text(
"📋 Выберите ссылку для просмотра статистики:", reply_markup=reply_markup
)
except Exception as e:
logger.error(f"Ошибка при получении списка UTM: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Произошла ошибка при получении списка.")
@router.callback_query(AdminAdsCallback.filter(F.action == "view"), IsAdminFilter())
async def handle_ads_view(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
stats = await get_tracking_source_stats(session, code)
if not stats:
await callback_query.message.edit_text("❌ Источник не найден или не содержит данных.")
return
msg = format_ads_stats(stats, USERNAME_BOT)
await callback_query.message.edit_text(text=msg, reply_markup=build_ads_stats_kb(code))
except Exception as e:
logger.error(f"Ошибка при просмотре статистики: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Ошибка при получении статистики.")
@router.callback_query(AdminAdsCallback.filter(F.action == "delete_confirm"), IsAdminFilter())
async def handle_ads_delete_confirm(callback_query: CallbackQuery, callback_data: AdminAdsCallback):
code = callback_data.code
await callback_query.message.edit_text(
text=f"Вы уверены, что хотите удалить ссылку <code>{code}</code>?",
reply_markup=build_ads_delete_confirm_kb(code),
)
@router.callback_query(AdminAdsCallback.filter(F.action == "delete"), IsAdminFilter())
async def handle_ads_delete(
callback_query: CallbackQuery,
callback_data: AdminAdsCallback,
session: AsyncSession,
):
code = callback_data.code
try:
await session.execute(update(User).where(User.source_code == code).values(source_code=None))
await session.execute(delete(TrackingSource).where(TrackingSource.code == code))
await session.commit()
await callback_query.message.edit_text(
f"🗑️ Ссылка <code>{code}</code> удалена.",
reply_markup=build_ads_kb(),
)
except Exception as e:
logger.error(f"Ошибка при удалении метки {code}: {e}", exc_info=True)
await callback_query.message.edit_text("❌ Не удалось удалить ссылку.")
@router.callback_query(AdminAdsCallback.filter(F.action == "cancel_input"), IsAdminFilter())
async def handle_ads_cancel_input(callback_query: CallbackQuery, state: FSMContext):
await state.clear()
await callback_query.message.edit_text(text="📊 <b>Аналитика рекламы:</b>", reply_markup=build_ads_kb())
def format_ads_stats(stats: dict, username_bot: str) -> str:
moscow_tz = pytz.timezone("Europe/Moscow")
now = datetime.now(moscow_tz)
update_time = now.strftime("%d.%m.%y %H:%M:%S")
return (
f"<b>📊 <u>Статистика по рекламной ссылке</u></b>\n\n"
f"📌 <b>Название:</b> {stats['name']}\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start={stats['code']}</code>\n"
f"🕓 <b>Создана:</b> {stats['created_at'].strftime('%d.%m.%Y %H:%M')}\n\n"
f"💡 <b>Активность:</b>\n"
f"└ 🆕 <b>Регистраций:</b> <b>{stats.get('registrations', 0)}</b>\n"
f"└ 🧪 <b>Триалов:</b> <b>{stats.get('trials', 0)}</b>\n\n"
f"💰 <b>Финансовая информация:</b>\n"
f"├ 💳 <b>Покупок:</b> <b>{stats.get('payments', 0)}</b>\n"
f"└ 💸 <b>Сумма:</b> <b>{round(stats.get('total_amount', 0), 2)} ₽</b>\n\n"
f"⏱️ <i>Последнее обновление:</i> <code>{update_time}</code>"
)
+102
View File
@@ -0,0 +1,102 @@
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()
+11 -6
View File
@@ -18,14 +18,19 @@ async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...", reply_markup=kb
text="💾 Инициализация резервного копирования базы данных...",
reply_markup=kb,
)
exception = await backup_database()
try:
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии: {exception}"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
if exception:
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
except Exception as e:
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
await callback_query.message.edit_text(text=text, reply_markup=kb)
+139 -34
View File
@@ -1,10 +1,20 @@
from typing import Any
import csv
import io
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.types import BufferedInputFile, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from 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
@@ -13,45 +23,41 @@ from .keyboard import build_bans_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans"),
IsAdminFilter(),
)
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 = "🚫 Заблокировавшие бота\n\nЗдесь можно просматривать и удалять пользователей, которые забанили вашего бота!"
await callback_query.message.edit_text(
text=text,
reply_markup=build_bans_kb(),
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: Any):
@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:
banned_users = await session.fetch("SELECT tg_id, blocked_at FROM blocked_users")
import csv
import io
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", "blocked_at"])
writer.writerow(["tg_id"])
for user in banned_users:
writer.writerow([user["tg_id"], user["blocked_at"]])
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",
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
)
except Exception as e:
await callback_query.message.answer(
@@ -60,16 +66,13 @@ async def handle_bans_export(callback_query: CallbackQuery, session: Any):
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "bans_delete_banned"),
IsAdminFilter(),
)
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any):
@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:
blocked_users = await session.fetch("SELECT tg_id FROM blocked_users")
blocked_ids = [record["tg_id"] for record in blocked_users]
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(
@@ -81,7 +84,11 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute("DELETE FROM blocked_users WHERE tg_id = ANY($1)", blocked_ids)
await 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)} пользователях и связанных записях.",
@@ -92,3 +99,101 @@ async def handle_bans_delete_banned(callback_query: CallbackQuery, session: Any)
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()
+27 -4
View File
@@ -4,10 +4,33 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_bans_kb() -> InlineKeyboardMarkup:
def build_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(text="📄 Выгрузить в CSV", callback_data=AdminPanelCallback(action="bans_export").pack())
builder.button(text="🗑️ Удалить из БД", callback_data=AdminPanelCallback(action="bans_delete_banned").pack())
builder.row(build_admin_back_btn("management"))
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_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()
File diff suppressed because it is too large Load Diff
+55 -18
View File
@@ -1,12 +1,8 @@
from typing import Optional
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from ..panel.keyboard import build_admin_back_btn
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
@@ -20,16 +16,23 @@ def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
cluster_names = list(servers.keys())
for i in range(0, len(cluster_names), 2):
builder.row(*[
InlineKeyboardButton(
text=f"⚙️ {name}",
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
row_buttons = []
for name in cluster_names[i : i + 2]:
servers_in_cluster = servers[name]
all_disabled = all(not s["enabled"] for s in servers_in_cluster)
label = f"{name} (отключен)" if all_disabled else f"⚙️ {name}"
row_buttons.append(
InlineKeyboardButton(
text=label,
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
)
)
for name in cluster_names[i : i + 2]
])
builder.row(*row_buttons)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data=AdminClusterCallback(action="add").pack())
InlineKeyboardButton(
text=" Добавить кластер",
callback_data=AdminClusterCallback(action="add").pack(),
)
)
builder.row(build_admin_back_btn())
@@ -57,18 +60,23 @@ def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineK
builder.row(
InlineKeyboardButton(
text="🛠 Управление",
callback_data=AdminClusterCallback(action="manage_cluster", data=cluster_name).pack(),
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📡 Серверы",
callback_data=f"cluster_servers|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
@@ -101,10 +109,11 @@ def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
@@ -130,3 +139,31 @@ def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKey
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(
text="🌀 Remnawave",
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(action="apply_tariff_group", data=f"{cluster_name}|{group_id}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
+32 -195
View File
@@ -1,6 +1,3 @@
from datetime import datetime
import html
import pytz
from typing import Any
from aiogram import F, Router
@@ -17,25 +14,19 @@ from aiogram.types import (
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import (
add_connection,
check_connection_exists,
create_coupon,
create_coupon_usage,
delete_coupon,
get_all_coupons,
get_keys,
update_key_expiry,
)
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.utils import format_days
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminCouponDeleteCallback, build_coupons_kb, build_coupons_list_kb, format_coupons_list
from .keyboard import (
AdminCouponDeleteCallback,
build_coupons_kb,
build_coupons_list_kb,
format_coupons_list,
)
router = Router()
@@ -45,7 +36,6 @@ class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
waiting_for_key_selection = State()
@router.callback_query(
@@ -129,7 +119,7 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session, days=None)
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 = (
@@ -182,7 +172,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
return
try:
await create_coupon(coupon_code, 0, usage_limit, session, days=days)
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
@@ -221,44 +211,52 @@ async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
async def handle_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
async def handle_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack()
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack(),
)
kb.button(
text="❌ Нет, отменить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack()
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack(),
)
kb.adjust(1)
await callback_query.message.edit_text(
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
reply_markup=kb.as_markup()
reply_markup=kb.as_markup(),
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
async def confirm_coupon_delete(callback_query: CallbackQuery, callback_data: AdminCouponDeleteCallback, session: Any):
async def confirm_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
confirm = callback_data.confirm
if confirm:
try:
result = await delete_coupon(coupon_code, session)
result = await delete_coupon(session, coupon_code)
if not result:
await callback_query.message.edit_text(
f"❌ Купон с кодом {coupon_code} не найден.",
reply_markup=build_admin_back_kb("coupons")
reply_markup=build_admin_back_kb("coupons"),
)
return
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при удалении купона.",
reply_markup=build_admin_back_kb("coupons")
reply_markup=build_admin_back_kb("coupons"),
)
return
@@ -303,7 +301,11 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
return
title = f"Купон {coupon['code']}"
description = f"Получи {coupon['amount']} рублей!" if coupon["amount"] > 0 else f"Продли подписку на {format_days(coupon['days'])}!"
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"
@@ -317,173 +319,8 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
id=coupon_code,
title=title,
description=description,
input_message_content=InputTextMessageContent(
message_text=message_text,
parse_mode=ParseMode.HTML
),
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
await inline_query.answer(
results=[result],
cache_time=86400,
is_personal=True
)
@router.message(F.text.regexp(r"^/start coupons_(.+)$"))
async def handle_coupon_activation(
message: Message, state: FSMContext, session: Any, admin: bool = False, text: str = None, user_id: int = None
):
coupon_text = text if text is not None else message.text
logger.info(f"Текст купона в handle_coupon_activation: {coupon_text}")
coupon_code = coupon_text.split("coupons_")[1]
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await message.answer("❌ Купон не найден.")
return
if coupon["usage_count"] >= coupon["usage_limit"] or coupon["is_used"]:
await message.answer("❌ Лимит активаций купона исчерпан.")
return
effective_user_id = user_id if user_id is not None else message.from_user.id
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon["id"],
effective_user_id
)
if usage:
await message.answer("❌ Вы уже активировали этот купон.")
return
connection_exists = await check_connection_exists(effective_user_id)
if not connection_exists:
await add_connection(tg_id=effective_user_id, session=session)
if coupon["amount"] > 0:
await session.execute(
"UPDATE connections SET balance = balance + $1 WHERE tg_id = $2",
coupon["amount"],
effective_user_id
)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], effective_user_id, session)
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon['amount']} рублей.")
await process_callback_view_profile(message, state, admin)
return
if coupon["days"] is not None and coupon["days"] > 0:
keys = await get_keys(effective_user_id, session)
active_keys = [k for k in keys if not k["is_frozen"]]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = "<b>🔑 Выберите подписку для продления:</b>\n\n<blockquote>"
for key in active_keys:
alias = key.get("alias")
email = key["email"]
client_id = key["client_id"]
expiry_time = key.get("expiry_time")
key_display = html.escape(alias.strip() if alias else email)
expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz).strftime("до %d.%m.%y, %H:%M")
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(text=key_display, callback_data=f"extend_key|{client_id}|{coupon['id']}")
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(AdminCouponsState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon["id"], user_id=effective_user_id)
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(callback_query: CallbackQuery, state: FSMContext, session: Any, admin: bool = False):
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
coupon = await session.fetchrow("SELECT * FROM coupons WHERE id = $1", coupon_id)
if not coupon or coupon["usage_count"] >= coupon["usage_limit"]:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
usage = await session.fetchrow(
"SELECT * FROM coupon_usages WHERE coupon_id = $1 AND user_id = $2",
coupon_id,
callback_query.from_user.id
)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
key = await session.fetchrow(
"SELECT * FROM keys WHERE tg_id = $1 AND client_id = $2",
callback_query.from_user.id,
client_id
)
if not key or key["is_frozen"]:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key["expiry_time"]
new_expiry = max(now_ms, current_expiry) + (coupon["days"] * 86400 * 1000)
try:
await renew_key_in_cluster(
cluster_id=key["server_id"],
email=key["email"],
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=0
)
await update_key_expiry(client_id, new_expiry, session)
await session.execute(
"UPDATE coupons SET usage_count = usage_count + 1, is_used = $1 WHERE id = $2",
coupon["usage_count"] + 1 >= coupon["usage_limit"],
coupon["id"]
)
await create_coupon_usage(coupon["id"], callback_query.from_user.id, session)
alias = key.get("alias") or key["email"]
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime("%d.%m.%y, %H:%M")
text = f"✅ Купон активирован, подписка <b>{alias}</b> продлена на {format_days(coupon['days'])}⏳ до {expiry_date}📆."
await callback_query.message.answer(text)
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Произошла ошибка при продлении подписки.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(callback_query: CallbackQuery, state: FSMContext, admin: bool = False):
await callback_query.message.answer("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin)
await state.clear()
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
+20 -4
View File
@@ -12,14 +12,26 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: Optional[bool] = None
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text=" Создать купон", callback_data=AdminPanelCallback(action="coupons_create").pack())
builder.button(text="Купоны", callback_data=AdminPanelCallback(action="coupons_list").pack())
builder.row(
InlineKeyboardButton(
text=" Создать купон",
callback_data=AdminPanelCallback(action="coupons_create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="Купоны",
callback_data=AdminPanelCallback(action="coupons_list").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
@@ -59,7 +71,11 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = f"💰 <b>Сумма:</b> {coupon['amount']} рублей" if coupon["amount"] > 0 else f"⏳ <b>{format_days(coupon['days'])}</b>"
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"
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .gifts_handler import router
+275
View File
@@ -0,0 +1,275 @@
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
@@ -0,0 +1,69 @@
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()
+151 -6
View File
@@ -1,16 +1,161 @@
from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from middlewares import maintenance
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
def build_management_kb() -> InlineKeyboardMarkup:
def build_management_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="💾 Создать резервную копию", callback_data=AdminPanelCallback(action="backups").pack())
builder.button(text="🚫 Заблокировавшие бота", callback_data=AdminPanelCallback(action="bans").pack())
builder.button(text="🔄 Перезагрузить бота", callback_data=AdminPanelCallback(action="restart").pack())
builder.button(text="🌐 Сменить домен", callback_data=AdminPanelCallback(action="change_domain").pack())
builder.button(text="🔑 Восстановить пробники", callback_data=AdminPanelCallback(action="restore_trials").pack())
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()
+631 -18
View File
@@ -1,14 +1,46 @@
from aiogram import F, Router
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 asyncpg import Connection
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_management_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()
@@ -18,11 +50,36 @@ 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):
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(),
reply_markup=build_management_kb(admin.role),
)
@@ -36,10 +93,9 @@ async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: Connection):
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
logger.info(f"[DomainChange] Новый домен, введённый администратором: '{new_domain}'")
if not new_domain or " " in new_domain or not new_domain.replace(".", "").isalnum():
logger.warning("[DomainChange] Некорректный домен")
@@ -50,26 +106,583 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
return
new_domain_url = f"https://{new_domain}"
logger.info(f"[DomainChange] Новый домен с протоколом: '{new_domain_url}'")
query = """
UPDATE keys
SET key = regexp_replace(key, '^https://[^/]+', $1::TEXT)
WHERE key NOT LIKE $1 || '%'
"""
try:
await session.execute(query, new_domain_url)
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"))
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.fetchrow("SELECT key FROM keys LIMIT 1")
logger.info(f"[DomainChange] Пример обновленной записи: {sample}")
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 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()
+43 -10
View File
@@ -17,27 +17,60 @@ class AdminPanelCallback(CallbackData, prefix="admin_panel"):
super().__init__(**data)
def build_panel_kb() -> InlineKeyboardMarkup:
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="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="clusters").pack()),
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
builder.button(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack())
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="🤖 Управление", callback_data=AdminPanelCallback(action="management").pack()),
InlineKeyboardButton(text="💸 Тарифы", callback_data=AdminPanelCallback(action="tariffs").pack()),
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
)
builder.button(text=MAIN_MENU, callback_data="profile")
builder.adjust(1, 1, 2, 1, 2, 1)
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.button(
text="✅ Да, перезагрузить",
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
+36 -13
View File
@@ -1,29 +1,40 @@
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 aiogram.exceptions import TelegramBadRequest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from bot import version
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
from logger import logger
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
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=build_panel_kb())
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("🔄 Попытка редактировать сообщение без изменений — пропущено.")
@@ -35,17 +46,29 @@ async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMC
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(text=text, reply_markup=build_panel_kb())
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(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
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):
text = f"🤖 Панель администратора\n📌 Версия бота: {version}"
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
await message.answer(text=text, reply_markup=build_panel_kb())
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,
)
+30 -26
View File
@@ -1,4 +1,9 @@
import asyncio
import os
import subprocess
import sys
import psutil
from aiogram import F, Router
from aiogram.types import CallbackQuery
@@ -11,32 +16,31 @@ from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb, build_rest
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart"),
IsAdminFilter(),
)
async def handle_restart(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="🤔 Вы уверены, что хотите перезагрузить бота?",
reply_markup=build_restart_kb(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "restart_confirm"),
IsAdminFilter(),
)
async def handle_restart_confirm(callback_query: CallbackQuery):
@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:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
except subprocess.CalledProcessError:
await callback_query.message.edit_text(text="🔄 Бот успешно перезагружен!", reply_markup=kb)
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:
await callback_query.message.edit_text(text=f"⚠️ Ошибка при перезагрузке бота: {e.stderr}", reply_markup=kb)
print(f"[Restart] Ошибка при перезапуске: {e}")
os._exit(1)
+32 -8
View File
@@ -13,18 +13,39 @@ class AdminSenderCallback(CallbackData, prefix="admin_sender"):
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()
text="👥 Все пользователи",
callback_data=AdminSenderCallback(type="all").pack(),
)
)
builder.row(
InlineKeyboardButton(text="📢 Кластер", callback_data=AdminSenderCallback(type="cluster-select").pack())
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())
@@ -36,7 +57,10 @@ def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
for cluster in clusters:
name = cluster["cluster_name"]
builder.button(text=f"🌐 {name}", callback_data=AdminSenderCallback(type="cluster", data=name).pack())
builder.button(
text=f"🌐 {name}",
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
)
builder.adjust(2)
builder.row(build_admin_back_btn())
+235 -72
View File
@@ -1,11 +1,18 @@
import asyncio
import json
import re
from datetime import datetime
from typing import Any
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, Message
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
@@ -16,8 +23,100 @@ 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(
@@ -25,10 +124,16 @@ class AdminSender(StatesGroup):
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery):
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
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(
@@ -39,7 +144,22 @@ async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
await callback_query.message.edit_text(
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)
@@ -50,8 +170,10 @@ async def handle_sender_callback_text(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
clusters = await session.fetch("SELECT DISTINCT cluster_name FROM servers")
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
@@ -59,89 +181,130 @@ async def handle_sender_callback(callback_query: CallbackQuery, session: Any):
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: Any):
"""
Обрабатывает ввод сообщения для рассылки (поддержка текста + фото).
"""
text_message = message.html_text if message.text else None
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
photo_url = message.caption if message.photo and message.caption and message.caption.startswith("http") else None
if not text_message and message.caption:
text_message = message.caption
clean_text, keyboard = parse_message_buttons(original_text)
if not text_message and not photo and not photo_url:
await message.answer("⚠ Ошибка! Отправьте текст или изображение для рассылки.")
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
state_data = await state.get_data()
send_to = state_data.get("type", "all")
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":
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
WHERE k.expiry_time > $1
""",
int(datetime.utcnow().timestamp() * 1000),
)
query = select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms)
elif send_to == "unsubscribed":
tg_ids = await session.fetch(
"""
SELECT c.tg_id
FROM connections c
LEFT JOIN keys k ON c.tg_id = k.tg_id
GROUP BY c.tg_id
HAVING COUNT(k.tg_id) = 0 OR MAX(k.expiry_time) <= $1
""",
int(datetime.utcnow().timestamp() * 1000),
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id))
elif send_to == "untrial":
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections WHERE trial = 0")
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":
cluster_name = state_data.get("cluster_name")
tg_ids = await session.fetch(
"""
SELECT DISTINCT c.tg_id
FROM connections c
JOIN keys k ON c.tg_id = k.tg_id
JOIN servers s ON k.server_id = s.cluster_name
WHERE s.cluster_name = $1
""",
cluster_name,
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
)
elif send_to == "hotleads":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(~User.tg_id.in_(subquery))
)
else:
tg_ids = await session.fetch("SELECT DISTINCT tg_id FROM connections")
query = select(distinct(User.tg_id))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
total_users = len(tg_ids)
success_count = 0
text = f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}"
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
await message.answer(text=text)
messages = []
for tg_id in tg_ids:
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
for record in tg_ids:
tg_id = record["tg_id"]
try:
if photo or photo_url:
await message.bot.send_photo(
chat_id=tg_id, photo=photo if photo else photo_url, caption=text_message, parse_mode="HTML"
)
else:
await message.bot.send_message(chat_id=tg_id, text=text_message, parse_mode="HTML")
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15)
success_count += 1
except Exception as e:
logger.error(f"❌ Ошибка отправки пользователю {tg_id}: {e}")
success_count = sum(1 for result in results if result)
text = (
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f" <b>Доставлено:</b> {success_count}\n"
f" <b>Не доставлено:</b> {total_users - success_count}"
await 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 message.answer(text=text, reply_markup=build_admin_back_kb("sender"))
await state.clear()
+89 -7
View File
@@ -1,5 +1,5 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
@@ -10,12 +10,94 @@ class AdminServerCallback(CallbackData, prefix="admin_server"):
data: str
def build_manage_server_kb(server_name: str, cluster_name: str) -> InlineKeyboardMarkup:
from ..clusters.keyboard import AdminClusterCallback
def build_manage_server_kb(server_name: str, cluster_name: str, enabled: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🗑️ Удалить", callback_data=AdminServerCallback(action="delete", data=server_name).pack())
builder.button(text="✏️ Сменить название", callback_data=AdminServerCallback(action="rename", data=server_name).pack())
builder.button(text=BACK, callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack())
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}")]]
)
+338 -70
View File
@@ -1,48 +1,95 @@
from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_server, get_servers
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):
async def handle_server_manage(
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
session: AsyncSession,
):
server_name = callback_data.data
servers = await get_servers()
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)
((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\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}"
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),
reply_markup=build_manage_server_kb(server_name, cluster_name, enabled=server.get("enabled", True)),
)
else:
await callback_query.message.edit_text(text="❌ Сервер не найден.")
@@ -50,20 +97,20 @@ async def handle_server_manage(callback_query: CallbackQuery, callback_data: Adm
@router.callback_query(AdminServerCallback.filter(F.action == "delete"), IsAdminFilter())
async def process_callback_delete_server(
callback_query: CallbackQuery, callback_data: AdminServerCallback, state: FSMContext, session: Any
callback_query: CallbackQuery,
callback_data: AdminServerCallback,
state: FSMContext,
session: AsyncSession,
):
from ..clusters.clusters_handler import AdminClusterStates
server_name = callback_data.data
servers = await get_servers(session)
servers_dict = await get_servers(session, include_enabled=True)
cluster_name = None
for c_name, server_list in servers.items():
for server in server_list:
if server["server_name"] == server_name:
cluster_name = c_name
break
if cluster_name:
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:
@@ -73,36 +120,32 @@ async def process_callback_delete_server(
)
return
keys_count = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
server_name
)
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)
all_servers = await session.fetch(
"""
SELECT server_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.server_name) as key_count
FROM servers
WHERE server_name != $1
""",
server_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 server in all_servers:
for s_name, key_count in all_servers:
builder.row(
InlineKeyboardButton(
text=f"{server['server_name']} ({server['key_count']})",
callback_data=f"transfer_to_server|{server['server_name']}|{server_name}"
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()
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
)
@@ -113,49 +156,49 @@ async def process_callback_delete_server(
await state.set_state(AdminClusterStates.waiting_for_server_transfer)
return
remaining_servers = await session.fetchval(
"SELECT COUNT(*) FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
server_name
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:
other_clusters = await session.fetch(
"SELECT DISTINCT cluster_name FROM servers WHERE cluster_name != $1",
cluster_name
)
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:
cluster_keys_count = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE server_id = $1",
cluster_name
)
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)
all_clusters = await session.fetch(
"""
SELECT cluster_name, (SELECT COUNT(*) FROM keys WHERE server_id = servers.cluster_name) as key_count
FROM servers
WHERE cluster_name != $1
GROUP BY 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 cluster in all_clusters:
for cl_name, key_count in all_clusters:
builder.row(
InlineKeyboardButton(
text=f"{cluster['cluster_name']} ({cluster['key_count']})",
callback_data=f"transfer_to_cluster|{cluster['cluster_name']}|{cluster_name}|{server_name}"
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()
callback_data=AdminServerCallback(action="manage", data=server_name).pack(),
)
)
@@ -166,22 +209,247 @@ async def process_callback_delete_server(
await state.set_state(AdminClusterStates.waiting_for_cluster_transfer)
return
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
server_name
)
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:
await session.execute(
"DELETE FROM servers WHERE cluster_name = $1 AND server_name = $2",
cluster_name,
server_name
)
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()
+4 -2
View File
@@ -12,14 +12,16 @@ def build_stats_kb() -> InlineKeyboardMarkup:
callback_data=AdminPanelCallback(action="stats_export_users_csv").pack(),
)
builder.button(
text="📥 Выгрузить оплаты в CSV", callback_data=AdminPanelCallback(action="stats_export_payments_csv").pack()
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()
text="📥 Выгрузить горящих лидов",
callback_data=AdminPanelCallback(action="stats_export_hot_leads_csv").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
+225 -135
View File
@@ -1,15 +1,41 @@
from datetime import datetime
from typing import Any
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
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 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
@@ -18,125 +44,163 @@ 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: Any):
@router.callback_query(AdminPanelCallback.filter(F.action == "stats"), IsAdminFilter())
async def handle_stats(callback_query: CallbackQuery, session: AsyncSession):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM users")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = int(
await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE")
)
total_payments_week = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
)
total_payments_month = int(
await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
)
total_payments_last_month = int(
await session.fetchval(
"""
SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE created_at >= date_trunc('month', CURRENT_DATE - interval '1 month')
AND created_at < date_trunc('month', CURRENT_DATE)
"""
)
)
total_payments_all_time = int(await session.fetchval("SELECT COALESCE(SUM(amount), 0) FROM payments"))
all_keys = await session.fetch("SELECT created_at, expiry_time FROM keys")
def count_subscriptions_by_duration(keys):
periods = {"trial": 0, "1": 0, "3": 0, "6": 0, "12": 0}
for key in keys:
try:
duration_days = (key["expiry_time"] - key["created_at"]) / (1000 * 60 * 60 * 24)
if duration_days <= 29:
periods["trial"] += 1
elif duration_days <= 89:
periods["1"] += 1
elif duration_days <= 179:
periods["3"] += 1
elif duration_days <= 359:
periods["6"] += 1
else:
periods["12"] += 1
except Exception as e:
logger.error(f"Error processing key duration: {e}")
continue
return periods
subs_all_time = count_subscriptions_by_duration(all_keys)
registrations_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE")
registrations_week = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
registrations_month = await session.fetchval(
"SELECT COUNT(*) FROM users WHERE created_at >= date_trunc('month', CURRENT_DATE)"
)
users_updated_today = await session.fetchval("SELECT COUNT(*) FROM users WHERE updated_at >= CURRENT_DATE")
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
moscow_tz = pytz.timezone("Europe/Moscow")
update_time = datetime.now(moscow_tz).strftime("%d.%m.%y %H:%M:%S")
now = datetime.now(moscow_tz)
today = now.date()
hot_leads_count = await session.fetchval("""
SELECT COUNT(DISTINCT u.tg_id)
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
LEFT JOIN keys k ON u.tg_id = k.tg_id
WHERE p.status = 'success'
AND k.tg_id IS NULL
""")
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 = (
"📊 <b>Статистика проекта</b>\n\n"
"👤 <b>Пользователи:</b>\n"
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>{total_users}</b>\n\n"
"💡 <b>Активность:</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"
"🤝 <b>Реферальная система:</b>\n"
f"🤝 <b>Реферальная система:</b>\n"
f"└ 👥 Всего привлечено: <b>{total_referrals}</b>\n\n"
"🔐 <b>Подписки:</b>\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"└ 📋 По срокам:\n"
f" • 🎁 Триал: <b>{subs_all_time['trial']}</b>\n"
f" • 🗓️ 1 мес: <b>{subs_all_time['1']}</b>\n"
f" • 🗓️ 3 мес: <b>{subs_all_time['3']}</b>\n"
f" • 🗓️ 6 мес: <b>{subs_all_time['6']}</b>\n"
f" • 🗓️ 12 мес: <b>{subs_all_time['12']}</b>\n\n"
"💰 <b>Финансы:</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\n"
f"🔥 <b>Горящие лиды</b>: <b>{hot_leads_count}</b> (платили, но не продлили)\n\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}")
@@ -145,57 +209,83 @@ async def handle_stats(callback_query: CallbackQuery, session: Any):
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: Any):
@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")
await callback_query.message.answer_document(document=export, caption="📅 Экспорт пользователей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
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: Any):
@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")
await callback_query.message.answer_document(document=export, caption="📅 Экспорт платежей в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
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: Any):
@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="📥 Экспорт горящих лидов")
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)
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: Any):
@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")
await callback_query.message.answer_document(document=export, caption="📅 Экспорт подписок в CSV")
except Exception as e:
logger.error(f"Ошибка при экспорте подписок в CSV: {e}")
await callback_query.message.edit_text(text=f"Произошла ошибка при экспорте: {e}", reply_markup=kb)
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
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .tariffs_handler import router
+166
View File
@@ -0,0 +1,166 @@
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
+210 -74
View File
@@ -3,10 +3,14 @@ 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 RENEWAL_PRICES, TOTAL_GB
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
@@ -26,119 +30,190 @@ class AdminUserKeyEditorCallback(CallbackData, prefix="admin_users_key"):
edit: bool = False
def build_user_edit_kb(tg_id: int, key_records: list) -> InlineKeyboardMarkup:
def build_user_edit_kb(tg_id: int, key_records: list, is_banned: bool = False) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
current_time = datetime.now(tz=timezone.utc)
builder.button(
text=" Создать ключ",
callback_data=AdminUserEditorCallback(action="users_create_key", tg_id=tg_id).pack(),
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)
email = record.email
expiry = datetime.fromtimestamp(record.expiry_time / 1000, tz=timezone.utc)
days = (expiry - current_time).days
builder.button(
text=f"🔑 {email} ({'<1' if days < 1 else days} дн.)",
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=str(email)).pack(),
builder.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.button(
text="✉️ Сообщение", callback_data=AdminUserEditorCallback(action="users_send_message", tg_id=tg_id).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.button(
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.button(
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.button(
text="♻️ Восстановить триал",
callback_data=AdminUserEditorCallback(action="users_trial_restore", tg_id=tg_id).pack(),
)
builder.button(
text="❌ Удалить клиента", callback_data=AdminUserEditorCallback(action="users_delete_user", tg_id=tg_id).pack()
builder.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())
builder.adjust(1)
return builder.as_markup()
def build_users_balance_change_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
text=BACK,
callback_data=AdminUserEditorCallback(action="users_balance_edit", tg_id=tg_id).pack(),
)
return builder.as_markup()
def build_users_balance_kb(tg_id: int) -> InlineKeyboardMarkup:
async def build_users_balance_kb(session: AsyncSession, tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month, amount in RENEWAL_PRICES.items():
builder.button(
text=f"+ {amount}Р ({month} мес.)",
callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id, data=amount).pack(),
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.button(
text=f"- {amount}Р ({month} мес.)",
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.button(
text="💵 Добавить", callback_data=AdminUserEditorCallback(action="users_balance_add", tg_id=tg_id).pack()
)
builder.button(
text="💵 Вычесть", callback_data=AdminUserEditorCallback(action="users_balance_take", tg_id=tg_id).pack()
)
builder.button(
text="💵 Установить баланс",
callback_data=AdminUserEditorCallback(action="users_balance_set", tg_id=tg_id).pack(),
)
builder.row(build_editor_back_btn(tg_id, True))
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
def build_users_key_show_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text=BACK, # todo: fix magic text was set
text=BACK,
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email, edit=True).pack(),
)
return builder.as_markup()
def build_users_key_expiry_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
async def build_users_key_expiry_kb(session: AsyncSession, tg_id: int, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for month in RENEWAL_PRICES.keys():
month = int(month)
builder.button(
text=f"+ {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=month).pack(),
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.button(
text=f"- {month} мес.",
callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email, month=-month).pack(),
)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.button(
text="⏳ Добавить дни", callback_data=AdminUserKeyEditorCallback(action="add", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Вычесть дни", callback_data=AdminUserKeyEditorCallback(action="take", tg_id=tg_id, data=email).pack()
)
builder.button(
text="⏳ Установить дату истечения",
callback_data=AdminUserKeyEditorCallback(action="set", tg_id=tg_id, data=email).pack(),
)
builder.button(
text=BACK, # todo: fix magic text was set
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(2, 2, 2, 2, 2, 1)
return builder.as_markup()
@@ -156,7 +231,8 @@ def build_user_delete_kb(tg_id: int):
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()
text=BACK,
callback_data=AdminUserEditorCallback(action="users_key_edit", tg_id=tg_id, data=email).pack(),
)
builder.adjust(1)
return builder.as_markup()
@@ -164,6 +240,7 @@ def build_user_key_kb(tg_id: int, email: str) -> InlineKeyboardMarkup:
def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="⏳ Время истечения",
callback_data=AdminUserEditorCallback(
@@ -174,6 +251,10 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
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(),
@@ -182,18 +263,39 @@ def build_key_edit_kb(key_details: dict, email: str) -> InlineKeyboardMarkup:
text="📊 Трафик",
callback_data=AdminUserEditorCallback(action="users_traffic", data=email, tg_id=key_details["tg_id"]).pack(),
)
if TOTAL_GB > 0:
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="♻️ Сбросить трафик",
text="💻 HWID",
callback_data=AdminUserEditorCallback(
action="users_reset_traffic", data=email, tg_id=key_details["tg_id"]
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(
@@ -223,7 +325,8 @@ def build_editor_back_btn(tg_id: int, edit: bool = False) -> InlineKeyboardButto
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()
text=text,
callback_data=AdminUserEditorCallback(action="users_editor", tg_id=tg_id, edit=edit).pack(),
)
@@ -234,6 +337,39 @@ async def build_cluster_selection_kb(session, tg_id: int, email: str, action: st
for cluster_id in clusters:
builder.button(text=cluster_id, callback_data=f"{action}|{tg_id}|{email}|{cluster_id}")
builder.button(text=BACK, callback_data=f"edit_user_key|{tg_id}|{email}")
builder.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
+22 -9
View File
@@ -10,16 +10,16 @@ CANCEL = "❌ Отмена"
# Профиль
ADD_SUB = " Подписка"
ADD_SUB = " Добавить новую подписку"
MY_SUBS = "📱 Мои подписки"
BALANCE = "💰 Баланс"
BALANCE = "💵 Баланс"
INVITE = "👥 Пригласить"
GIFTS = "🎁 Подарить"
TARRIFS = "💡 Тарифы"
INSTRUCTIONS = "📘 Инструкции"
INVITE = "👥 Пригласить друга"
TOP_FIVE = "🏆 Топ-5"
TRIAL_SUB = "🎁 Пробная подписка"
MY_SUB = "🔐 Моя подписка"
RENEW_SUB = "🔄 Обновить подписку"
# Меню Оплат и баланса
@@ -29,10 +29,16 @@ COUPON = "🎟️ Активировать купон"
COUPON_RESTART = "🎟️ Попробовать другой купон"
YOOKASSA = "💳 ЮКасса: быстрая оплата"
YOOMONEY = "💳 ЮМани: перевод по карте"
FREEKASSA = "💰 FreeKassa: межд. платежи"
CRYPTOBOT = "💰 CryptoBot: криптовалюта"
STARS = "⭐ Оплата Звездами"
ROBOKASSA = "⭐ RoboKassa"
DISCOUNT_TARIFF = "🔥 Получить скидку"
MAX_DISCOUNT_TARIFF = "⚡ Получить максимальную скидку"
# Кнопки подписки на канал
SUB_CHANELL = "📢 Подписаться"
SUB_CHANELL_DONE = "✅ Я подписался"
# Подарки
@@ -40,8 +46,7 @@ GIFT = "🎁 Подарить подписку"
MY_GIFTS = "🎁 Мои подарки"
GIFTS_MENU = "В меню подарков"
SHARE_GIFT = "🎁 Поделиться подарком"
APPLY = " Подтвердить"
CANCEL = "❌ Отмена"
GET_GIFT = "🎁 Получить подарок"
# Создание и подключение ключей
@@ -73,9 +78,11 @@ PC_MACOS = "🍏 macOS"
TV = "📺 Телевизор"
ROUTER = "📶 Роутер"
MANUAL_INSTRUCTIONS = "📖 Ручная установка"
RENEW_KEY = "🔄 Продлить подписку"
RENEW_KEY = "Купить VPN 🔄 Продлить VPN"
RENEW_KEY_NOTIFICATION = "🔄 Продлить подписку"
TV_CONTINUE = "▶ Продолжить"
TV_INSTRUCTIONS = "📖 Полная инструкция"
HWID_BUTTON = "♻️ Сбросить привязку"
# Кнопки касс
@@ -83,6 +90,12 @@ TV_INSTRUCTIONS = "📖 Полная инструкция"
PAY = "Пополнить"
PAY_2 = "Оплатить"
CUSTOM_AMOUNT = "💰 Ввести свою сумму"
PAY = "Пополнить"
PAY_2 = "Оплатить"
STARS_BOT = "🤖 Бот для покупки звезд"
WATA_RU = "🇷🇺 WATA: Карты РФ"
WATA_SBP = "🏦 WATA: СБП"
WATA_INT = "🌍 WATA: Международные карты"
KASSAI_CARDS = "💳 KassaAI: Карты РФ"
KASSAI_SBP = "🏦 KassaAI: СБП"
# Кнопки Heleket
HELEKET_CRYPTO = "₿ Heleket: Crypto"
+53 -31
View File
@@ -8,8 +8,7 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import CAPTCHA_EMOJIS
from handlers.texts import CAPTCHA_PROMPT_MSG
from handlers.texts import CAPTCHA_EMOJIS, CAPTCHA_PROMPT_MSG
from logger import logger
from .utils import edit_or_send_message
@@ -25,12 +24,36 @@ async def generate_captcha(message: Message, state: FSMContext):
all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis)
await state.update_data(
correct_emoji=correct_emoji,
message_id=message.message_id,
chat_id=message.chat.id,
original_text=message.text,
)
state_data = await state.get_data()
if "user_data" not in state_data:
from_user = message.from_user
if not from_user:
logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
return None
await state.update_data(
user_data={
"tg_id": from_user.id,
"username": getattr(from_user, "username", None),
"first_name": getattr(from_user, "first_name", None),
"last_name": getattr(from_user, "last_name", None),
"language_code": getattr(from_user, "language_code", None),
"is_bot": getattr(from_user, "is_bot", False),
}
)
update_data = {
"correct_emoji": correct_emoji,
"message_id": message.message_id,
"chat_id": message.chat.id,
}
state_data = await state.get_data()
if "original_text" not in state_data:
update_data["original_text"] = message.text
await state.update_data(**update_data)
builder = InlineKeyboardBuilder()
for emoji in all_emojis:
@@ -45,34 +68,33 @@ async def generate_captcha(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import start_command
from handlers.start import process_start_logic
selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji")
message_id = state_data.get("message_id")
chat_id = state_data.get("chat_id")
original_text = state_data.get("original_text")
user_data = state_data.get("user_data")
if not message_id or not chat_id:
target_message = callback.message
else:
try:
target_message = await callback.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=callback.message.text,
)
except Exception:
target_message = callback.message
target_message = callback.message
if selected_emoji == correct_emoji:
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
await start_command(target_message, state, session, admin, captcha=False)
else:
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
await process_start_logic(
message=target_message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data,
)
else:
logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
if captcha:
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
)
+210 -21
View File
@@ -1,31 +1,49 @@
import html
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import ADMIN_ID
from database import (
add_payment,
add_user,
check_coupon_usage,
check_user_exists,
create_coupon_usage,
get_coupon_by_code,
get_keys,
get_tariff_by_id,
update_balance,
update_coupon_usage_count,
update_key_expiry,
)
from handlers.buttons import MAIN_MENU, COUPON_RESTART
from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.profile import process_callback_view_profile
from handlers.texts import (
COUPON_ACTIVATED_SUCCESS_MSG,
COUPONS_DAYS_MESSAGE,
COUPON_ALREADY_USED_MSG,
COUPON_DAYS_ACTIVATED_MSG,
COUPON_INPUT_PROMPT,
COUPON_NOT_FOUND_MSG,
)
from .utils import edit_or_send_message
from handlers.utils import edit_or_send_message, format_days
from logger import logger
class CouponActivationState(StatesGroup):
waiting_for_coupon_code = State()
waiting_for_key_selection = State()
router = Router()
@@ -54,32 +72,203 @@ async def handle_activate_coupon(callback_query_or_message: Message | CallbackQu
@router.message(CouponActivationState.waiting_for_coupon_code)
async def process_coupon_code(message: Message, state: FSMContext, session: Any):
coupon_code = message.text.strip()
activation_result = await activate_coupon(message.chat.id, coupon_code, session)
await activate_coupon(message, state, session, coupon_code=coupon_code)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
builder.row(InlineKeyboardButton(text=COUPON_RESTART, callback_data="activate_coupon"))
await message.answer(activation_result, reply_markup=builder.as_markup())
async def activate_coupon(
message: Message,
state: FSMContext,
session: AsyncSession,
coupon_code: str,
admin: bool = False,
user_data: dict | None = None,
):
logger.info(f"Активация купона: {coupon_code}")
coupon = await get_coupon_by_code(session, coupon_code)
if not coupon:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="exit_coupon_input"))
await message.answer(COUPON_NOT_FOUND_MSG, reply_markup=builder.as_markup())
return
if coupon.usage_count >= coupon.usage_limit or coupon.is_used:
await message.answer("❌ Лимит активаций купона исчерпан.")
await state.clear()
return
user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
usage = await check_coupon_usage(session, coupon.id, user_id)
if usage:
await message.answer(COUPON_ALREADY_USED_MSG)
await state.clear()
return
user_exists = await check_user_exists(session, user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
session=session,
tg_id=user.id,
username=getattr(user, "username", None),
first_name=getattr(user, "first_name", None),
last_name=getattr(user, "last_name", None),
language_code=getattr(user, "language_code", None),
is_bot=getattr(user, "is_bot", False),
)
if coupon.amount > 0:
try:
await update_balance(session, user_id, coupon.amount)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, user_id)
await add_payment(session, tg_id=user_id, amount=coupon.amount, payment_system="coupon")
await message.answer(f"✅ Купон активирован, на баланс начислено {coupon.amount} рублей.")
await state.clear()
except Exception as e:
logger.error(f"Ошибка при активации купона на баланс: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
if coupon.days:
try:
keys = await get_keys(session, user_id)
active_keys = [k for k in keys if not k.is_frozen]
if not active_keys:
await message.answer("❌ У вас нет активных подписок для продления.")
await state.clear()
return
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
response_message = COUPONS_DAYS_MESSAGE
for key in active_keys:
key_display = html.escape((key.alias or key.email).strip())
expiry_date = datetime.fromtimestamp(key.expiry_time / 1000, tz=moscow_tz).strftime(
"до %d.%m.%y, %H:%M"
)
response_message += f"• <b>{key_display}</b> ({expiry_date})\n"
builder.button(
text=key_display,
callback_data=f"extend_key|{key.client_id}|{coupon.id}",
)
response_message += "</blockquote>"
builder.button(text="Отмена", callback_data="cancel_coupon_activation")
builder.adjust(1)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.set_state(CouponActivationState.waiting_for_key_selection)
await state.update_data(coupon_id=coupon.id, user_id=user_id)
except Exception as e:
logger.error(f"Ошибка при обработке купона на дни: {e}")
await message.answer("❌ Ошибка при активации купона.")
await state.clear()
return
await message.answer("❌ Купон недействителен (нет суммы или дней).")
await state.clear()
async def activate_coupon(user_id: int, coupon_code: str, session: Any):
coupon_record = await get_coupon_by_code(coupon_code, session)
@router.callback_query(F.data.startswith("extend_key|"))
async def handle_key_extension(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession,
admin: bool = False,
):
from database.models import Coupon, Key
if not coupon_record:
return COUPON_NOT_FOUND_MSG
parts = callback_query.data.split("|")
client_id = parts[1]
coupon_id = int(parts[2])
tg_id = callback_query.from_user.id
usage_exists = await check_coupon_usage(coupon_record["id"], user_id, session)
try:
result = await session.execute(select(Coupon).where(Coupon.id == coupon_id))
coupon = result.scalar_one_or_none()
if not coupon or coupon.usage_count >= coupon.usage_limit:
await callback_query.message.edit_text("❌ Купон недействителен или лимит исчерпан.")
await state.clear()
return
if usage_exists:
return COUPON_ALREADY_USED_MSG
usage = await check_coupon_usage(session, coupon.id, tg_id)
if usage:
await callback_query.message.edit_text("❌ Вы уже активировали этот купон.")
await state.clear()
return
coupon_amount = coupon_record["amount"]
result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.client_id == client_id))
key = result.scalar_one_or_none()
if not key or key.is_frozen:
await callback_query.message.edit_text("❌ Выбранная подписка не найдена или заморожена.")
await state.clear()
return
await update_coupon_usage_count(coupon_record["id"], session)
await create_coupon_usage(coupon_record["id"], user_id, session)
now_ms = int(datetime.now().timestamp() * 1000)
current_expiry = key.expiry_time
new_expiry = max(now_ms, current_expiry) + (coupon.days * 86400 * 1000)
await update_balance(user_id, coupon_amount, session)
return COUPON_ACTIVATED_SUCCESS_MSG.format(coupon_amount=coupon_amount)
tariff = None
if key.tariff_id:
tariff = await get_tariff_by_id(session, key.tariff_id)
total_gb = int(tariff["traffic_limit"]) if tariff and tariff.get("traffic_limit") else 0
device_limit = int(tariff["device_limit"]) if tariff and tariff.get("device_limit") else 0
await renew_key_in_cluster(
cluster_id=key.server_id,
email=key.email,
client_id=client_id,
new_expiry_time=new_expiry,
total_gb=total_gb,
session=session,
hwid_device_limit=device_limit,
reset_traffic=False,
)
await update_key_expiry(session, client_id, new_expiry)
await update_coupon_usage_count(session, coupon.id)
await create_coupon_usage(session, coupon.id, tg_id)
alias = key.alias or key.email
expiry_date = datetime.fromtimestamp(new_expiry / 1000, tz=pytz.timezone("Europe/Moscow")).strftime(
"%d.%m.%y, %H:%M"
)
await callback_query.message.answer(
COUPON_DAYS_ACTIVATED_MSG.format(alias=alias, days=format_days(coupon.days), expiry=expiry_date)
)
await process_callback_view_profile(callback_query.message, state, admin, session)
await state.clear()
except Exception as e:
logger.error(f"Ошибка при продлении ключа: {e}")
await callback_query.message.edit_text("❌ Ошибка при активации купона.")
await state.clear()
@router.callback_query(F.data == "cancel_coupon_activation")
async def cancel_coupon_activation(
callback_query: CallbackQuery,
state: FSMContext,
admin: bool = False,
session: AsyncSession = None,
):
await callback_query.message.edit_text("⚠️ Активация купона отменена.")
await process_callback_view_profile(callback_query.message, state, admin, session)
await state.clear()
@router.callback_query(F.data == "exit_coupon_input")
async def handle_exit_coupon_input(
callback_query: CallbackQuery,
state: FSMContext,
session: AsyncSession = None,
):
await state.clear()
is_admin = callback_query.from_user.id in ADMIN_ID
await process_callback_view_profile(callback_query.message, state, admin=is_admin, session=session)
+7 -1
View File
@@ -1,7 +1,13 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, LabeledPrice, Message, PreCheckoutQuery
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
LabeledPrice,
Message,
PreCheckoutQuery,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import RUB_TO_XTR
+22
View File
@@ -0,0 +1,22 @@
from aiogram import F, Router
from aiogram.types import InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import SUPPORT_CHAT_URL
from handlers.buttons import MAIN_MENU, SUPPORT
from handlers.texts import FALLBACK_MESSAGE
fallback_router = Router()
@fallback_router.message(F.text)
async def handle_unhandled_messages(message: Message):
keyboard = InlineKeyboardBuilder()
keyboard.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
keyboard.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await message.answer(
FALLBACK_MESSAGE,
reply_markup=keyboard.as_markup(),
)
+8 -14
View File
@@ -1,12 +1,9 @@
import os
from typing import Any
from aiogram import F, Router
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
Message,
)
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import (
@@ -28,7 +25,6 @@ from handlers.buttons import (
PC_PC,
SUPPORT,
TV_CONTINUE,
TV_INSTRUCTIONS,
)
from handlers.texts import (
CHOOSE_DEVICE_TEXT,
@@ -71,7 +67,7 @@ async def send_instructions(callback_query_or_message: CallbackQuery | Message):
@router.callback_query(F.data.startswith("connect_pc|"))
async def process_connect_pc(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
if not record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
@@ -99,7 +95,7 @@ async def process_connect_pc(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("windows_menu|"))
async def process_windows_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_PC}"
@@ -121,7 +117,7 @@ async def process_windows_menu(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("macos_menu|"))
async def process_macos_menu(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
record = await get_key_details(session, key_name)
key = record["key"]
key_message_text = KEY_MESSAGE.format(key)
instruction_message = f"{key_message_text}{INSTRUCTION_MACOS}"
@@ -141,7 +137,7 @@ async def process_macos_menu(callback_query: CallbackQuery, session: Any):
@router.callback_query(F.data.startswith("connect_tv|"))
async def process_connect_tv(callback_query: CallbackQuery):
async def process_connect_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
@@ -161,13 +157,11 @@ async def process_connect_tv(callback_query: CallbackQuery):
@router.callback_query(F.data.startswith("continue_tv|"))
async def process_continue_tv(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
record = await get_key_details(key_name, session)
subscription_link = record["key"]
record = await get_key_details(session, key_name)
subscription_link = record.get("key") or record.get("remnawave_link")
message_text = SUBSCRIPTION_DETAILS_TEXT.format(subscription_link=subscription_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=TV_INSTRUCTIONS, url="https://vpn4tv.com/quick-guide.html"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_tv|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
+10 -2
View File
@@ -2,7 +2,11 @@ __all__ = ("router",)
from aiogram import Router
from .key_management import router as management_router
from .key_connect import router as connect_router
from .key_freeze import router as freeze_router
from .key_mode import router as key_mode_router
from .key_renew import router as renew_router
from .key_view import router as view_router
from .keys import router as keys_router
@@ -10,5 +14,9 @@ router = Router(name="keys_main_router")
router.include_routers(
keys_router,
management_router,
view_router,
renew_router,
freeze_router,
connect_router,
key_mode_router,
)
+229
View File
@@ -0,0 +1,229 @@
import os
from io import BytesIO
import qrcode
from aiogram import F, Router, types
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_ANDROID,
CONNECT_IOS,
DOWNLOAD_ANDROID,
DOWNLOAD_IOS,
INSTRUCTIONS_BUTTON,
)
from database.models import Key
from handlers.buttons import (
ANDROID,
BACK,
DOWNLOAD_ANDROID_BUTTON,
DOWNLOAD_IOS_BUTTON,
IMPORT_ANDROID,
IMPORT_IOS,
IPHONE,
MAIN_MENU,
MANUAL_INSTRUCTIONS,
PC,
TV,
)
from handlers.texts import (
ANDROID_DESCRIPTION_TEMPLATE,
CHOOSE_DEVICE_TEXT,
IOS_DESCRIPTION_TEMPLATE,
SUBSCRIPTION_DESCRIPTION,
)
from handlers.utils import edit_or_send_message
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("connect_device|"))
async def handle_connect_device(callback_query: CallbackQuery):
try:
key_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=IPHONE, callback_data=f"connect_ios|{key_name}"))
builder.row(InlineKeyboardButton(text=ANDROID, callback_data=f"connect_android|{key_name}"))
builder.row(InlineKeyboardButton(text=PC, callback_data=f"connect_pc|{key_name}"))
builder.row(InlineKeyboardButton(text=TV, callback_data=f"connect_tv|{key_name}"))
# builder.row(InlineKeyboardButton(text=ROUTER, callback_data=f"connect_router|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=CHOOSE_DEVICE_TEXT,
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
await callback_query.message.answer("❌ Ошибка при показе меню подключения.")
logger.error(f"Ошибка в handle_connect_device: {e}")
@router.callback_query(F.data.startswith("connect_phone|"))
async def process_callback_connect_phone(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
row = result.scalar_one_or_none()
if not row:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
key_link = row
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email}: {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = SUBSCRIPTION_DESCRIPTION.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS),
InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID),
)
builder.row(
InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"),
InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"),
)
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{email}"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_ios|"))
async def process_callback_connect_ios(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (iOS): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = IOS_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_IOS_BUTTON, url=DOWNLOAD_IOS))
builder.row(InlineKeyboardButton(text=IMPORT_IOS, url=f"{CONNECT_IOS}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("connect_android|"))
async def process_callback_connect_android(callback_query: CallbackQuery, session: AsyncSession):
email = callback_query.data.split("|")[1]
try:
result = await session.execute(select(Key.key).where(Key.email == email))
key_link = result.scalar_one_or_none()
if not key_link:
await callback_query.message.answer("❌ Ошибка: ключ не найден.")
return
except Exception as e:
logger.error(f"Ошибка при получении ключа для {email} (Android): {e}")
await callback_query.message.answer("❌ Произошла ошибка. Попробуйте позже.")
return
description = ANDROID_DESCRIPTION_TEMPLATE.format(key_link=key_link)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=DOWNLOAD_ANDROID_BUTTON, url=DOWNLOAD_ANDROID))
builder.row(InlineKeyboardButton(text=IMPORT_ANDROID, url=f"{CONNECT_ANDROID}{key_link}"))
if INSTRUCTIONS_BUTTON:
builder.row(InlineKeyboardButton(text=MANUAL_INSTRUCTIONS, callback_data="instructions"))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"connect_device|{email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=description,
reply_markup=builder.as_markup(),
media_path=None,
)
@router.callback_query(F.data.startswith("show_qr|"))
async def show_qr_code(callback_query: types.CallbackQuery, session: AsyncSession):
try:
key_name = callback_query.data.split("|")[1]
stmt = select(Key).where(Key.email == key_name)
result = await session.execute(stmt)
record = result.scalars().first()
if not record:
await callback_query.message.answer("❌ Подписка не найдена.")
return
qr_data = record.key or record.remnawave_link
if not qr_data:
await callback_query.message.answer("❌ У этой подписки отсутствует ссылка для подключения.")
return
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
qr_path = f"/tmp/qrcode_{record.email}.png"
with open(qr_path, "wb") as f:
f.write(buffer.read())
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{record.email}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text="🔲 <b>Ваш QR-код для подключения</b>",
reply_markup=builder.as_markup(),
media_path=qr_path,
)
os.remove(qr_path)
except Exception as e:
logger.error(f"Ошибка при генерации QR: {e}", exc_info=True)
await callback_query.message.answer("❌ Произошла ошибка при создании QR-кода.")
+212
View File
@@ -0,0 +1,212 @@
import time
from typing import Any
from aiogram import F, Router
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import text
from config import TRIAL_CONFIG
from database import (
get_key_details,
get_servers,
get_tariff_by_id,
mark_key_as_frozen,
mark_key_as_unfrozen,
)
from handlers.buttons import APPLY, BACK, CANCEL
from handlers.keys.key_utils import renew_key_in_cluster, toggle_client_on_cluster
from handlers.texts import (
FREEZE_SUBSCRIPTION_CONFIRM_MSG,
SUBSCRIPTION_FROZEN_MSG,
SUBSCRIPTION_UNFROZEN_MSG,
UNFREEZE_SUBSCRIPTION_CONFIRM_MSG,
)
from handlers.utils import edit_or_send_message, handle_error
from logger import logger
router = Router()
@router.callback_query(F.data.startswith("unfreeze_subscription|"))
async def process_callback_unfreeze_subscription(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
confirm_text = UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"unfreeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("unfreeze_subscription_confirm|"))
async def process_callback_unfreeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Размораживает (включает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=True, session=session)
if result["status"] != "success":
logger.warning(f"Не удалось включить подписку: {result.get('error') or result.get('results')}")
servers = await get_servers(session)
cluster_servers = servers.get(cluster_id, [])
if not cluster_servers:
text_error = "Сервер не найден."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(callback_query.message, text_error, builder.as_markup())
return
tariff = await get_tariff_by_id(session, record["tariff_id"]) if record.get("tariff_id") else None
if not tariff:
logger.info("[Unfreeze] Тариф не найден — возможно ключ триальный. Применяем дефолтные значения.")
total_gb = TRIAL_CONFIG["traffic_limit_gb"]
hwid_limit = TRIAL_CONFIG["hwid_limit"]
else:
total_gb = int(tariff.get("traffic_limit") or 0)
hwid_limit = int(tariff.get("device_limit") or 0)
now_ms = int(time.time() * 1000)
leftover = record["expiry_time"]
if leftover < 0:
leftover = 0
new_expiry_time = now_ms + leftover
await mark_key_as_unfrozen(session, record["tg_id"], client_id, new_expiry_time)
await session.commit()
max(leftover / (1000 * 86400), 0.01)
logger.info(
f"[Unfreeze Debug] Запуск renew_key_in_cluster с expiry={new_expiry_time}, gb={total_gb}, hwid={hwid_limit}"
)
await renew_key_in_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
new_expiry_time=new_expiry_time,
total_gb=total_gb,
session=session,
hwid_device_limit=hwid_limit,
reset_traffic=False,
)
text_ok = SUBSCRIPTION_UNFROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(callback_query.message, text_ok, builder.as_markup())
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при включении подписки: {e}")
@router.callback_query(F.data.startswith("freeze_subscription|"))
async def process_callback_freeze_subscription(callback_query: CallbackQuery, session: Any):
"""
Показывает пользователю диалог подтверждения заморозки (отключения) подписки.
"""
key_name = callback_query.data.split("|")[1]
confirm_text = FREEZE_SUBSCRIPTION_CONFIRM_MSG
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=APPLY,
callback_data=f"freeze_subscription_confirm|{key_name}",
),
InlineKeyboardButton(
text=CANCEL,
callback_data=f"view_key|{key_name}",
),
)
await edit_or_send_message(
target_message=callback_query.message,
text=confirm_text,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("freeze_subscription_confirm|"))
async def process_callback_freeze_subscription_confirm(callback_query: CallbackQuery, session: Any):
"""
Замораживает (отключает) подписку.
"""
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("Ключ не найден.")
return
email = record["email"]
client_id = record["client_id"]
cluster_id = record["server_id"]
result = await toggle_client_on_cluster(cluster_id, email, client_id, enable=False, session=session)
if result["status"] == "success":
now_ms = int(time.time() * 1000)
time_left = record["expiry_time"] - now_ms
if time_left < 0:
time_left = 0
await mark_key_as_frozen(session, record["tg_id"], client_id, time_left)
await session.commit()
text_ok = SUBSCRIPTION_FROZEN_MSG
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_ok,
reply_markup=builder.as_markup(),
)
else:
text_error = (
f"Произошла ошибка при заморозке подписки.\nДетали: {result.get('error') or result.get('results')}"
)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text=text_error,
reply_markup=builder.as_markup(),
)
except Exception as e:
await handle_error(tg_id, callback_query, f"Ошибка при заморозке подписки: {e}")
-669
View File
@@ -1,669 +0,0 @@
import asyncio
import uuid
from datetime import datetime, timedelta
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, FSInputFile
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from bot import bot
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
NOTIFY_EXTRA_DAYS,
PUBLIC_LINK,
RENEWAL_PRICES,
SUPPORT_CHAT_URL,
TRIAL_TIME,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_connection,
check_connection_exists,
create_temporary_data,
get_balance,
get_key_details,
get_trial,
store_key,
update_balance,
update_trial,
check_server_name_by_cluster,
)
from handlers.buttons import (
BACK,
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
PAYMENT,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_client_on_server, create_key_on_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.texts import (
CREATING_CONNECTION_MSG,
DISCOUNTS,
INSUFFICIENT_FUNDS_MSG,
SELECT_COUNTRY_MSG,
SELECT_TARIFF_PLAN_MSG,
key_message_success,
)
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster
from logger import logger
from panels.three_xui import delete_client
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
waiting_for_key_name = "waiting_for_key_name"
viewing_profile = "viewing_profile"
waiting_for_message = "waiting_for_message"
@router.callback_query(F.data == "create_key")
async def confirm_create_new_key(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.message.chat.id
await handle_key_creation(tg_id, state, session, callback_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
"""Создание ключа с учётом выбора тарифного плана."""
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
expiry_time = current_time + timedelta(days=TRIAL_TIME + extra_days)
logger.info(f"Доступен {TRIAL_TIME + extra_days}-дневный пробный период пользователю {tg_id}.")
await edit_or_send_message(
target_message=message_or_query if isinstance(message_or_query, Message) else message_or_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
builder = InlineKeyboardBuilder()
for index, (plan_id, price) in enumerate(RENEWAL_PRICES.items()):
discount_text = ""
if DISCOUNTS and plan_id in DISCOUNTS:
discount_percentage = DISCOUNTS[plan_id]
discount_text = f" ({discount_percentage}% скидка)"
if index == len(RENEWAL_PRICES) - 1:
discount_text = f" ({discount_percentage}% 🔥)"
builder.row(
InlineKeyboardButton(
text=f"📅 {plan_id} мес. - {price}{discount_text}",
callback_data=f"select_plan_{plan_id}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if isinstance(message_or_query, CallbackQuery):
target_message = message_or_query.message
else:
target_message = message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
await state.update_data(tg_id=tg_id)
await state.set_state(Form.waiting_for_server_selection)
@router.callback_query(F.data.startswith("select_plan_"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.message.chat.id
plan_id = callback_query.data.split("_")[-1]
plan_price = RENEWAL_PRICES.get(plan_id)
if plan_price is None:
await callback_query.message.answer("🚫 Неверный тарифный план.")
return
duration_days = int(plan_id) * 30
balance = await get_balance(tg_id)
if balance < plan_price:
required_amount = plan_price - balance
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"plan_id": plan_id,
"plan_price": plan_price,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
media_path=None,
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(plan_id=plan_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=int(plan_id))
async def create_key(
tg_id: int,
expiry_time: datetime,
state: FSMContext | None,
session: Any,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
"""Создаёт ключ с заданным сроком действия."""
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
if USE_COUNTRY_SELECTION:
logger.info("[Country Selection] USE_COUNTRY_SELECTION включен. Получение наименее загруженного кластера")
least_loaded_cluster = await get_least_loaded_cluster()
logger.info(f"[Country Selection] Наименее загруженный кластер: {least_loaded_cluster}. Получаем список серверов")
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1",
least_loaded_cluster,
)
if not servers:
logger.error(f"Нет серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.error(f"Нет доступных серверов в кластере {least_loaded_cluster}")
error_message = "❌ Нет доступных серверов для создания ключа."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
logger.info(f"[Country Selection] Список доступных серверов: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for country in available_servers:
if old_key_name:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{country}|{ts}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if target_message:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
media_path=None,
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
return
while True:
key_name = generate_random_email()
logger.info(f"[Key Generation] Сгенерировано имя ключа: {key_name} для пользователя {tg_id}")
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"[Key Generation] Имя ключа {key_name} уже существует. Генерация нового.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
least_loaded_cluster = await get_least_loaded_cluster()
tasks = [
asyncio.create_task(
create_key_on_cluster(least_loaded_cluster, tg_id, client_id, email, expiry_timestamp, plan)
)
]
await asyncio.gather(*tasks, return_exceptions=True)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
await store_key(
tg_id,
client_id,
email,
expiry_timestamp,
public_link,
least_loaded_cluster,
session,
)
data = {}
if state:
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
logger.info(f"[Database] Ключ сохранён в базе данных для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if target_message:
await edit_or_send_message(
target_message=target_message, text=error_message, reply_markup=None, media_path=None
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.replace(tzinfo=None).astimezone(moscow_tz)
remaining_time = expiry_time_local - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
if target_message:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(old_key_name, session)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(current_server, session)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = await session.fetch(
"SELECT server_name, api_url FROM servers WHERE cluster_name = $1 AND server_name != $2",
cluster_name,
current_server,
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
}
task = asyncio.create_task(check_server_availability(server_info))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(tg_id, expiry_time, selected_country, state, session, callback_query, old_key_name)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: Any,
callback_query: CallbackQuery,
old_key_name: str = None,
):
"""Финализирует создание ключа с выбором стран."""
if not await check_connection_exists(tg_id):
await add_connection(tg_id, balance=0.0, trial=0, session=session)
logger.info(f"[Connection] Подключение создано для пользователя {tg_id}")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(old_key_name, session)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
public_link = old_key_details["key"]
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(key_name, session)
if not existing_key:
break
logger.warning(f"Key name '{key_name}' already exists for user {tg_id}. Generating a new one.")
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
try:
server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
selected_country,
)
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден.")
if old_key_name:
old_server_id = old_key_details.get("server_id")
if old_server_id:
old_server_info = await session.fetchrow(
"SELECT api_url, inbound_id, server_name FROM servers WHERE server_name = $1",
old_server_id,
)
if old_server_info:
xui = AsyncApi(
old_server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
deletion_success = await delete_client(
xui,
old_server_info["inbound_id"],
email,
client_id,
)
if not deletion_success:
logger.warning(f"Не удалось удалить клиента с сервера {old_server_id}, но продолжаем процесс.")
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info=server_info,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
)
logger.info(f"Key created on server {selected_country} for user {tg_id}.")
if old_key_name:
await session.execute(
"""
UPDATE keys
SET server_id = $1
WHERE tg_id = $2 AND email = $3
""",
selected_country,
tg_id,
old_key_name,
)
else:
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
await session.execute(
"""
INSERT INTO keys (tg_id, client_id, email, created_at, expiry_time, key, server_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
tg_id,
client_id,
email,
created_at,
expiry_timestamp,
public_link,
selected_country,
)
data = await state.get_data()
if data.get("is_trial"):
trial_status = await get_trial(tg_id, session)
if trial_status in [0, -1]:
await update_trial(tg_id, 1, session)
if data.get("plan_id"):
plan_price = RENEWAL_PRICES.get(data["plan_id"])
await update_balance(tg_id, -plan_price, session)
except Exception as e:
logger.error(f"Error while creating the key for user {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
callback_data=f"connect_device|{key_name}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
remaining_time = expiry_time - datetime.now(moscow_tz)
days = remaining_time.days
key_message_text = key_message_success(public_link, f"⏳ Осталось дней: {days} 📅")
default_media_path = "img/pic.jpg"
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
if state:
await state.clear()
async def check_server_availability(server_info: dict) -> bool:
"""
Проверяет доступность сервера через API.
Args:
server_info (dict): Информация о сервере (api_url, username, password).
Returns:
bool: True, если сервер доступен, False в противном случае.
"""
try:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"Сервер {server_info.get('server_name', 'unknown')} доступен.")
return True
except asyncio.TimeoutError:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"Сервер {server_info.get('server_name', 'unknown')} недоступен: {e}")
return False
+13
View File
@@ -0,0 +1,13 @@
__all__ = ("router",)
from aiogram import Router
from .key_cluster_mode import router as cluster_router
from .key_country_mode import router as country_router
from .key_create import router as create_router
from .key_discount_mode import router as discount_router
router = Router(name="key_mode_router")
router.include_routers(create_router, cluster_router, country_router, discount_router)
+228
View File
@@ -0,0 +1,228 @@
import uuid
from datetime import datetime
import pytz
from aiogram import Router
from aiogram.types import (
CallbackQuery,
FSInputFile,
InlineKeyboardButton,
Message,
WebAppInfo,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from bot import bot
from config import CONNECT_PHONE_BUTTON, SUPPORT_CHAT_URL, TRIAL_CONFIG
from database import (
get_key_details,
get_tariff_by_id,
get_trial,
update_balance,
update_trial,
)
from handlers.buttons import (
CONNECT_DEVICE,
CONNECT_PHONE,
MAIN_MENU,
MY_SUB,
PC_BUTTON,
SUPPORT,
TV_BUTTON,
)
from handlers.keys.key_utils import create_key_on_cluster
from handlers.texts import key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_cluster_mode(
tg_id: int,
expiry_time: datetime,
state,
session,
message_or_query: Message | CallbackQuery | None = None,
plan: int = None,
):
target_message = None
safe_to_edit = False
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
while True:
key_name = generate_random_email()
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
try:
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
device_limit = 0
traffic_limit_gb = 0
if is_trial:
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
traffic_limit_gb = TRIAL_CONFIG.get("traffic_limit_gb", 100)
elif plan:
tariff = await get_tariff_by_id(session, plan)
if tariff:
if tariff.get("device_limit") is not None:
device_limit = int(tariff["device_limit"])
if tariff.get("traffic_limit") is not None:
traffic_limit_gb = int(tariff["traffic_limit"])
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
error_message = str(e)
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
await create_key_on_cluster(
cluster_id=least_loaded_cluster,
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
plan=plan,
session=session,
hwid_limit=device_limit,
traffic_limit_bytes=traffic_limit_gb,
is_trial=is_trial,
)
logger.info(f"[Key Creation] Ключ создан на кластере {least_loaded_cluster} для пользователя {tg_id}")
key_record = await get_key_details(session, email)
if not key_record:
raise ValueError(f"Ключ не найден после создания: {email}")
public_link = key_record.get("key")
remnawave_link = key_record.get("remnawave_link")
final_link = public_link or remnawave_link or ""
if is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(session, tg_id, 1)
if data.get("tariff_id"):
tariff = await get_tariff_by_id(session, data["tariff_id"])
if tariff:
await update_balance(session, tg_id, -tariff["price_rub"])
logger.info(f"[Database] Баланс обновлён для пользователя {tg_id}")
except Exception as e:
logger.error(f"[Error] Ошибка при создании ключа для пользователя {tg_id}: {e}")
error_message = "❌ Произошла ошибка при создании подписки. Пожалуйста, попробуйте снова."
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=error_message,
reply_markup=None,
)
else:
await bot.send_message(chat_id=tg_id, text=error_message)
return
builder = InlineKeyboardBuilder()
if await is_full_remnawave_cluster(least_loaded_cluster, session):
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
expiry_time_local = expiry_time.astimezone(moscow_tz)
expiry_time_local - datetime.now(moscow_tz)
tariff_info = None
if plan:
tariff_info = await get_tariff_by_id(session, plan)
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
)
else:
tariff_duration = tariff_info["name"]
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
key_message_text = key_message_success(
final_link,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title,
)
default_media_path = "img/pic.jpg"
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path=default_media_path,
)
else:
photo = FSInputFile(default_media_path)
await bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=key_message_text,
reply_markup=builder.as_markup(),
)
if state:
await state.clear()
+596
View File
@@ -0,0 +1,596 @@
import asyncio
import uuid
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import (
ADMIN_PASSWORD,
ADMIN_USERNAME,
CONNECT_PHONE_BUTTON,
PUBLIC_LINK,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
SUPPORT_CHAT_URL,
TRIAL_CONFIG,
)
from database import (
add_user,
check_server_name_by_cluster,
check_user_exists,
get_key_details,
get_trial,
update_balance,
update_trial,
)
from database.models import Key, Server, Tariff
from handlers.buttons import BACK, CONNECT_DEVICE, CONNECT_PHONE, MAIN_MENU, MY_SUB, PC_BUTTON, SUPPORT, TV_BUTTON
from handlers.keys.key_utils import create_client_on_server
from handlers.texts import SELECT_COUNTRY_MSG, key_message_success
from handlers.utils import (
edit_or_send_message,
format_days,
format_months,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger
from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
async def key_country_mode(
tg_id: int,
expiry_time: datetime,
state: FSMContext,
session: AsyncSession,
message_or_query: Message | CallbackQuery | None = None,
old_key_name: str = None,
plan: int = None,
):
target_message = None
safe_to_edit = False
if state and plan:
await state.update_data(tariff_id=plan)
if isinstance(message_or_query, CallbackQuery) and message_or_query.message:
target_message = message_or_query.message
safe_to_edit = True
elif isinstance(message_or_query, Message):
target_message = message_or_query
safe_to_edit = True
try:
least_loaded_cluster = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
text = str(e)
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
result = await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
).where(Server.cluster_name == least_loaded_cluster)
)
servers = result.mappings().all()
if not servers:
logger.error(f"❌ Нет серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
available_servers = []
tasks = [asyncio.create_task(check_server_availability(server, session)) for server in servers]
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
logger.warning(f"[Country Selection] Нет доступных серверов в кластере {least_loaded_cluster}")
text = "❌ Нет доступных серверов в выбранном кластере."
if safe_to_edit:
await edit_or_send_message(target_message=target_message, text=text, reply_markup=None)
else:
await bot.send_message(chat_id=tg_id, text=text)
return
logger.info(f"[Country Selection] Доступные сервера в кластере {least_loaded_cluster}: {available_servers}")
builder = InlineKeyboardBuilder()
ts = int(expiry_time.timestamp())
for server_name in available_servers:
if old_key_name:
callback_data = f"select_country|{server_name}|{ts}|{old_key_name}"
else:
callback_data = f"select_country|{server_name}|{ts}"
builder.row(InlineKeyboardButton(text=server_name, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if safe_to_edit:
await edit_or_send_message(
target_message=target_message,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
else:
await bot.send_message(
chat_id=tg_id,
text=SELECT_COUNTRY_MSG,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("change_location|"))
async def change_location_callback(callback_query: CallbackQuery, session: Any):
try:
data = callback_query.data.split("|")
if len(data) < 2:
await callback_query.answer("❌ Некорректные данные", show_alert=True)
return
old_key_name = data[1]
record = await get_key_details(session, old_key_name)
if not record:
await callback_query.answer("❌ Ключ не найден", show_alert=True)
return
expiry_timestamp = record["expiry_time"]
ts = int(expiry_timestamp / 1000)
current_server = record["server_id"]
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
await callback_query.answer("❌ Кластер для текущего сервера не найден", show_alert=True)
return
cluster_name = cluster_info["cluster_name"]
servers = (
(
await session.execute(
select(
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
)
.mappings()
.all()
)
if not servers:
await callback_query.answer("❌ Доступных серверов в кластере не найдено", show_alert=True)
return
available_servers = []
tasks = []
for server in servers:
server_info = {
"server_name": server["server_name"],
"api_url": server["api_url"],
"panel_type": server["panel_type"],
"enabled": server.get("enabled", True),
"max_keys": server.get("max_keys"),
}
task = asyncio.create_task(check_server_availability(server_info, session))
tasks.append(task)
results = await asyncio.gather(*tasks, return_exceptions=True)
for server, result in zip(servers, results, strict=False):
if result is True:
available_servers.append(server["server_name"])
if not available_servers:
await callback_query.answer("❌ Нет доступных серверов для смены локации", show_alert=True)
return
logger.info(f"Доступные страны для смены локации: {available_servers}")
builder = InlineKeyboardBuilder()
for country in available_servers:
callback_data = f"select_country|{country}|{ts}|{old_key_name}"
builder.row(InlineKeyboardButton(text=country, callback_data=callback_data))
builder.row(InlineKeyboardButton(text=BACK, callback_data=f"view_key|{old_key_name}"))
await edit_or_send_message(
target_message=callback_query.message,
text="🌍 Пожалуйста, выберите новую локацию для вашей подписки:",
reply_markup=builder.as_markup(),
media_path=None,
)
except Exception as e:
logger.error(f"Ошибка при смене локации для пользователя {callback_query.from_user.id}: {e}")
await callback_query.answer("❌ Ошибка смены локации. Попробуйте снова.", show_alert=True)
@router.callback_query(F.data.startswith("select_country|"))
async def handle_country_selection(callback_query: CallbackQuery, session: Any, state: FSMContext):
"""
Обрабатывает выбор страны.
Формат callback data:
select_country|{selected_country}|{ts} [|{old_key_name} (опционально)]
Если передан old_key_name значит, происходит смена локации.
"""
data = callback_query.data.split("|")
if len(data) < 3:
await callback_query.message.answer("❌ Некорректные данные. Попробуйте снова.")
return
selected_country = data[1]
try:
ts = int(data[2])
except ValueError:
await callback_query.message.answer("❌ Некорректное время истечения. Попробуйте снова.")
return
expiry_time = datetime.fromtimestamp(ts, tz=moscow_tz)
old_key_name = data[3] if len(data) > 3 else None
tg_id = callback_query.from_user.id
logger.info(f"Пользователь {tg_id} выбрал страну: {selected_country}")
logger.info(f"Получено время истечения (timestamp): {ts}")
await finalize_key_creation(
tg_id,
expiry_time,
selected_country,
state,
session,
callback_query,
old_key_name,
)
async def finalize_key_creation(
tg_id: int,
expiry_time: datetime,
selected_country: str,
state: FSMContext | None,
session: AsyncSession,
callback_query: CallbackQuery,
old_key_name: str = None,
tariff_id: int | None = None,
):
from_user = callback_query.from_user
if not await check_user_exists(session, tg_id):
await add_user(
session=session,
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
expiry_time = expiry_time.astimezone(moscow_tz)
if old_key_name:
old_key_details = await get_key_details(session, old_key_name)
if not old_key_details:
await callback_query.message.answer("❌ Ключ не найден. Попробуйте снова.")
return
key_name = old_key_name
client_id = old_key_details["client_id"]
email = old_key_details["email"]
expiry_timestamp = old_key_details["expiry_time"]
tariff_id = old_key_details.get("tariff_id") or tariff_id
else:
while True:
key_name = generate_random_email()
existing_key = await get_key_details(session, key_name)
if not existing_key:
break
client_id = str(uuid.uuid4())
email = key_name.lower()
expiry_timestamp = int(expiry_time.timestamp() * 1000)
traffic_limit_bytes = None
device_limit = 0
data = await state.get_data() if state else {}
is_trial = data.get("is_trial", False)
if is_trial:
traffic_limit_bytes = int(TRIAL_CONFIG.get("traffic_limit_gb", 100)) * 1024**3
device_limit = TRIAL_CONFIG.get("hwid_limit", 0)
elif data.get("tariff_id") or tariff_id:
tariff_id = data.get("tariff_id") or tariff_id
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = result.scalar_one_or_none()
if tariff:
if tariff.traffic_limit is not None:
traffic_limit_bytes = int(tariff.traffic_limit) * 1024**3
if tariff.device_limit is not None:
device_limit = int(tariff.device_limit)
public_link = None
remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
try:
result = await session.execute(select(Server).where(Server.server_name == selected_country))
server_info = result.scalar_one_or_none()
if not server_info:
raise ValueError(f"Сервер {selected_country} не найден")
panel_type = server_info.panel_type.lower()
cluster_info = await check_server_name_by_cluster(session, server_info.server_name)
if not cluster_info:
raise ValueError(f"Кластер для сервера {server_info.server_name} не найден")
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if old_key_name:
old_server_id = old_key_details["server_id"]
if old_server_id:
result = await session.execute(select(Server).where(Server.server_name == old_server_id))
old_server_info = result.scalar_one_or_none()
if old_server_info:
try:
if old_server_info.panel_type.lower() == "3x-ui":
xui = await get_xui_instance(old_server_info.api_url)
await delete_client(xui, old_server_info.inbound_id, email, client_id)
await session.execute(
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(key=None)
)
elif old_server_info.panel_type.lower() == "remnawave":
remna = RemnawaveAPI(old_server_info.api_url)
if await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna.delete_user(client_id)
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.email == email)
.values(remnawave_link=None)
)
except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента: {e}")
if panel_type == "remnawave" or is_full_remnawave:
remna = RemnawaveAPI(server_info.api_url)
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({server_info.server_name})")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": tg_id,
"activeUserInbounds": [server_info.inbound_id],
}
if traffic_limit_bytes:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit:
user_data["hwidDeviceLimit"] = device_limit
result = await remna.create_user(user_data)
if not result:
raise ValueError("❌ Ошибка при создании пользователя в Remnawave")
client_id = result.get("uuid")
remnawave_link = result.get("subscriptionUrl")
if old_key_name:
await session.execute(
update(Key).where(Key.tg_id == tg_id, Key.email == email).values(client_id=client_id)
)
if panel_type == "3x-ui":
semaphore = asyncio.Semaphore(2)
await create_client_on_server(
server_info={
"api_url": server_info.api_url,
"inbound_id": server_info.inbound_id,
"server_name": server_info.server_name,
"panel_type": server_info.panel_type,
},
tg_id=tg_id,
client_id=client_id,
email=email,
expiry_timestamp=expiry_timestamp,
semaphore=semaphore,
session=session,
plan=tariff_id,
is_trial=is_trial,
)
public_link = f"{PUBLIC_LINK}{email}/{tg_id}"
logger.info(f"[Key Creation] Подписка создана для пользователя {tg_id} на сервере {selected_country}")
if old_key_name:
update_data = {"server_id": selected_country}
if panel_type == "3x-ui":
update_data["key"] = public_link
elif panel_type == "remnawave":
update_data["remnawave_link"] = remnawave_link
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.email == email).values(**update_data))
else:
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_at,
expiry_time=expiry_timestamp,
key=public_link,
remnawave_link=remnawave_link,
server_id=selected_country,
tariff_id=tariff_id,
)
session.add(new_key)
if is_trial:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
await update_trial(session, tg_id, 1)
if tariff_id:
result = await session.execute(select(Tariff.price_rub).where(Tariff.id == tariff_id))
row = result.scalar_one_or_none()
if row:
await update_balance(session, tg_id, -row)
await session.commit()
except Exception as e:
logger.error(f"[Key Finalize] Ошибка при создании ключа для пользователя {tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при создании подписки. Попробуйте снова.")
return
builder = InlineKeyboardBuilder()
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if (panel_type == "remnawave" or is_full_remnawave) and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"))
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{email}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
link_to_show = public_link or remnawave_link or "Ссылка не найдена"
tariff_info = None
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff_info = result.scalar_one_or_none()
if is_trial:
trial_days = TRIAL_CONFIG.get("duration_days", 1)
if trial_days >= 30:
months = trial_days // 30
tariff_duration = format_months(months)
else:
tariff_duration = format_days(trial_days)
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=TRIAL_CONFIG.get("traffic_limit_gb", 100),
device_limit=TRIAL_CONFIG.get("hwid_limit", 0),
)
else:
tariff_duration = tariff_info["name"] if tariff_info else None
subgroup_title = tariff_info.get("subgroup_title", "") if tariff_info else ""
key_message_text = key_message_success(
link_to_show,
tariff_name=tariff_duration,
traffic_limit=tariff_info.get("traffic_limit", 0) if tariff_info else 0,
device_limit=tariff_info.get("device_limit", 0) if tariff_info else 0,
subgroup_title=subgroup_title,
)
await edit_or_send_message(
target_message=callback_query.message,
text=key_message_text,
reply_markup=builder.as_markup(),
media_path="img/pic.jpg",
)
if state:
await state.clear()
async def check_server_availability(server_info: dict, session: AsyncSession) -> bool:
server_name = server_info.get("server_name", "unknown")
panel_type = server_info.get("panel_type", "3x-ui").lower()
enabled = server_info.get("enabled", True)
max_keys = server_info.get("max_keys")
if not enabled:
logger.info(f"[Ping] Сервер {server_name} выключен (enabled = FALSE).")
return False
try:
if max_keys is not None:
result = await session.execute(select(func.count()).select_from(Key).where(Key.server_id == server_name))
key_count = result.scalar()
if key_count >= max_keys:
logger.info(f"[Ping] Сервер {server_name} достиг лимита ключей: {key_count}/{max_keys}.")
return False
except SQLAlchemyError as e:
logger.warning(f"[Ping] Ошибка при проверке лимита ключей на сервере {server_name}: {e}")
return False
try:
if panel_type == "remnawave":
remna = RemnawaveAPI(server_info["api_url"])
await asyncio.wait_for(remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD), timeout=5.0)
logger.info(f"[Ping] Remnawave сервер {server_name} доступен.")
return True
else:
xui = AsyncApi(
server_info["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
logger=logger,
)
await asyncio.wait_for(xui.login(), timeout=5.0)
logger.info(f"[Ping] 3x-ui сервер {server_name} доступен.")
return True
except TimeoutError:
logger.warning(f"[Ping] Сервер {server_name} не ответил вовремя.")
return False
except Exception as e:
logger.warning(f"[Ping] Ошибка при проверке сервера {server_name}: {e}")
return False
+374
View File
@@ -0,0 +1,374 @@
from collections import defaultdict
from datetime import datetime, timedelta
from math import ceil
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
NOTIFY_EXTRA_DAYS,
TRIAL_CONFIG,
TRIAL_TIME_DISABLE,
USE_COUNTRY_SELECTION,
USE_NEW_PAYMENT_FLOW,
)
from database import (
add_user,
check_user_exists,
create_temporary_data,
get_balance,
get_tariff_by_id,
get_tariffs_for_cluster,
get_trial,
)
from database.models import Admin
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import MAIN_MENU, PAYMENT
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
CREATING_CONNECTION_MSG,
INSUFFICIENT_FUNDS_MSG,
SELECT_TARIFF_PLAN_MSG,
)
from handlers.utils import edit_or_send_message, get_least_loaded_cluster
from logger import logger
from .key_cluster_mode import key_cluster_mode
from .key_country_mode import key_country_mode
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
class Form(FSMContext):
waiting_for_server_selection = "waiting_for_server_selection"
@router.callback_query(F.data == "create_key")
@router.callback_query(F.data == "buy")
@router.message(F.text == "/buy")
async def confirm_create_new_key(callback_query_or_message: CallbackQuery | Message, state: FSMContext, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
tg_id = callback_query_or_message.message.chat.id
message_or_query = callback_query_or_message
else:
tg_id = callback_query_or_message.chat.id
message_or_query = callback_query_or_message
await handle_key_creation(tg_id, state, session, message_or_query)
async def handle_key_creation(
tg_id: int,
state: FSMContext,
session: Any,
message_or_query: Message | CallbackQuery,
):
state_data = await state.get_data()
if state_data.get("key_creation_in_progress"):
logger.warning(f"[AntiSpam] Пользователь {tg_id} повторно нажал на покупку — игнор.")
return
await state.update_data(key_creation_in_progress=True)
try:
current_time = datetime.now(moscow_tz)
if not TRIAL_TIME_DISABLE:
trial_status = await get_trial(session, tg_id)
if trial_status in [0, -1]:
base_days = TRIAL_CONFIG["duration_days"]
extra_days = NOTIFY_EXTRA_DAYS if trial_status == -1 else 0
total_days = base_days + extra_days
expiry_time = current_time + timedelta(days=total_days)
logger.info(f"[Trial] Доступен {total_days}-дневный триал для пользователя {tg_id}")
await edit_or_send_message(
target_message=(
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=CREATING_CONNECTION_MSG,
reply_markup=None,
)
await state.update_data(is_trial=True)
await create_key(tg_id, expiry_time, state, session, message_or_query)
return
try:
cluster_name = await get_least_loaded_cluster(session)
except ValueError as e:
logger.error(f"Нет доступных кластеров: {e}")
await edit_or_send_message(
target_message=(
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=str(e),
reply_markup=InlineKeyboardBuilder()
.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
.as_markup(),
)
return
tariffs = await get_tariffs_for_cluster(session, cluster_name)
if not tariffs:
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
is_admin = result.scalar_one_or_none() is not None
if is_admin:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔗 Привязать тариф", callback_data=AdminPanelCallback(action="clusters").pack()
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = (
f"🚫 <b>Невозможно создать подписку</b>\n\n"
f"📊 <b>Информация о кластере:</b>\n<blockquote>"
f"🌐 <b>Кластер:</b> <code>{cluster_name}</code>\n"
f"⚠️ <b>Статус:</b> Нет привязанного тарифа\n</blockquote>"
f"💡 <b>Привяжите тариф к кластеру</b>"
)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
text = "❌ Нет доступных тарифов для выбора."
await edit_or_send_message(
target_message=(
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text=text,
reply_markup=builder.as_markup(),
)
return
group_code = tariffs[0].get("group_code") if tariffs else None
if not group_code:
await edit_or_send_message(
target_message=(
message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
),
text="❌ Не удалось определить группу тарифов.",
reply_markup=None,
)
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
subgroup = t.get("subgroup_title")
grouped_tariffs[subgroup].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
for subgroup in sorted(k for k in grouped_tariffs if k):
subgroup_hash = create_subgroup_hash(subgroup, group_code)
builder.row(
InlineKeyboardButton(
text=f"{subgroup}",
callback_data=f"tariff_subgroup_user|{subgroup_hash}",
)
)
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
target_message = message_or_query.message if isinstance(message_or_query, CallbackQuery) else message_or_query
await edit_or_send_message(
target_message=target_message,
text=SELECT_TARIFF_PLAN_MSG,
reply_markup=builder.as_markup(),
)
await state.update_data(tg_id=tg_id, cluster_name=cluster_name, group_code=group_code)
await state.set_state(Form.waiting_for_server_selection)
finally:
await state.update_data(key_creation_in_progress=False)
@router.callback_query(F.data.startswith("tariff_subgroup_user|"))
async def show_tariffs_in_subgroup_user(callback: CallbackQuery, state: FSMContext, session: Any):
subgroup_hash = callback.data.split("|")[1]
data = await state.get_data()
cluster_name = data.get("cluster_name")
group_code = data.get("group_code")
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup:
await edit_or_send_message(
target_message=callback.message,
text="❌ Подгруппа не найдена.",
reply_markup=None,
)
return
tariffs = await get_tariffs_for_cluster(session, cluster_name)
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup]
if not filtered:
await edit_or_send_message(
target_message=callback.message,
text="❌ В этой подгруппе пока нет тарифов.",
reply_markup=None,
)
return
builder = InlineKeyboardBuilder()
for t in filtered:
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"select_tariff_plan|{t['id']}",
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_tariff_group_list"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback.message,
text=f"<b>{subgroup}</b>\n\nВыберите тариф:",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "back_to_tariff_group_list")
async def back_to_tariff_group_list(callback: CallbackQuery, state: FSMContext, session: Any):
await state.get_data()
tg_id = callback.from_user.id
await handle_key_creation(
tg_id=tg_id,
state=state,
session=session,
message_or_query=callback,
)
@router.callback_query(F.data.startswith("select_tariff_plan|"))
async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: FSMContext):
tg_id = callback_query.from_user.id
tariff_id = int(callback_query.data.split("|")[1])
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
await callback_query.message.edit_text("❌ Указанный тариф не найден.")
return
duration_days = tariff["duration_days"]
price_rub = tariff["price_rub"]
balance = await get_balance(session, tg_id)
price_rub = tariff["price_rub"]
if balance < price_rub:
required_amount = ceil(price_rub - balance)
await create_temporary_data(
session,
tg_id,
"waiting_for_payment",
{
"tariff_id": tariff_id,
"duration_days": duration_days,
"required_amount": required_amount,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(message=callback_query, session=session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
)
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⏳ Подождите...", callback_data="creating_key"))
await edit_or_send_message(
target_message=callback_query.message,
text=CREATING_CONNECTION_MSG,
reply_markup=builder.as_markup(),
)
expiry_time = datetime.now(moscow_tz) + timedelta(days=duration_days)
await state.update_data(tariff_id=tariff_id)
await create_key(tg_id, expiry_time, state, session, callback_query, plan=tariff_id)
async def create_key(
tg_id: int,
expiry_time,
state,
session,
message_or_query=None,
old_key_name: str = None,
plan: int = None,
):
if not await check_user_exists(session, tg_id):
from_user = message_or_query.from_user if isinstance(message_or_query, CallbackQuery | Message) else None
if from_user:
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
logger.info(f"[User] Новый пользователь {tg_id} добавлен")
if USE_COUNTRY_SELECTION:
await key_country_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
old_key_name=old_key_name,
plan=plan,
)
else:
await key_cluster_mode(
tg_id=tg_id,
expiry_time=expiry_time,
state=state,
session=session,
message_or_query=message_or_query,
plan=plan,
)
+100
View File
@@ -0,0 +1,100 @@
from datetime import datetime, timedelta
from aiogram import F, Router
from aiogram.types import CallbackQuery
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from database import get_tariffs
from database.models import Notification
from handlers.notifications.notify_kb import build_tariffs_keyboard
from handlers.texts import DISCOUNT_TARIFF, DISCOUNT_TARIFF_MAX
from logger import logger
from .key_create import select_tariff_plan
router = Router()
@router.callback_query(F.data == "hot_lead_discount")
async def handle_discount_entry(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_2",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия скидки истёк.")
return
tariffs = await get_tariffs(session=session, group_code="discounts")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
@router.callback_query(F.data.startswith("discount_tariff|"))
async def handle_discount_tariff_selection(callback: CallbackQuery, session, state):
try:
tariff_id = int(callback.data.split("|")[1])
fake_callback = CallbackQuery.model_construct(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
message=callback.message,
data=f"select_tariff_plan|{tariff_id}",
)
await select_tariff_plan(fake_callback, session=session, state=state)
except Exception as e:
logger.error(f"Ошибка при выборе скидочного тарифа: {e}")
await callback.message.answer("❌ Произошла ошибка при выборе тарифа.")
@router.callback_query(F.data == "hot_lead_final_discount")
async def handle_ultra_discount(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(
select(Notification.last_notification_time).where(
Notification.tg_id == tg_id,
Notification.notification_type == "hot_lead_step_3",
)
)
last_time = result.scalar_one_or_none()
if not last_time:
await callback.message.edit_text("❌ Скидка недоступна.")
return
now = datetime.utcnow()
if now - last_time > timedelta(hours=DISCOUNT_ACTIVE_HOURS):
await callback.message.edit_text("⏳ Срок действия финальной скидки истёк.")
return
tariffs = await get_tariffs(session, group_code="discounts_max")
if not tariffs:
await callback.message.edit_text("❌ Скидочные тарифы временно недоступны.")
return
await callback.message.edit_text(
DISCOUNT_TARIFF_MAX,
reply_markup=build_tariffs_keyboard(tariffs, prefix="discount_tariff"),
)
+413
View File
@@ -0,0 +1,413 @@
from collections import defaultdict
from datetime import datetime, timedelta
from math import ceil
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from bot import bot
from config import USE_NEW_PAYMENT_FLOW
from database import (
check_tariff_exists,
create_temporary_data,
get_balance,
get_key_by_server,
get_key_details,
get_tariff_by_id,
get_tariffs,
update_balance,
update_key_expiry,
)
from database.models import Key, Server
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash
from handlers.buttons import BACK, MAIN_MENU, MY_SUB, PAYMENT
from handlers.keys.key_utils import renew_key_in_cluster
from handlers.payments.robokassa_pay import handle_custom_amount_input
from handlers.payments.stars_pay import process_custom_amount_input_stars
from handlers.payments.yookassa_pay import process_custom_amount_input
from handlers.payments.yoomoney_pay import process_custom_amount_input_yoomoney
from handlers.texts import (
INSUFFICIENT_FUNDS_RENEWAL_MSG,
KEY_NOT_FOUND_MSG,
PLAN_SELECTION_MSG,
get_renewal_message,
)
from handlers.utils import edit_or_send_message, format_days, format_months, get_russian_month
from logger import logger
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
@router.callback_query(F.data.startswith("renew_key|"))
async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
tg_id = callback_query.message.chat.id
key_name = callback_query.data.split("|")[1]
try:
record = await get_key_details(session, key_name)
if not record:
await callback_query.message.answer("<b>Ключ не найден.</b>")
return
client_id = record["client_id"]
expiry_time = record["expiry_time"]
server_id = record["server_id"]
tariff_id = record.get("tariff_id")
await state.update_data(renew_key_name=key_name, renew_client_id=client_id)
logger.info(f"[RENEW] Получение тарифов для server_id={server_id}")
try:
server_id_int = int(server_id)
filter_condition = or_(
Server.id == server_id_int,
Server.server_name == server_id,
Server.cluster_name == server_id,
)
except ValueError:
filter_condition = or_(
Server.server_name == server_id,
Server.cluster_name == server_id,
)
row = await session.execute(select(Server.tariff_group).where(filter_condition).limit(1))
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW] Тарифная группа не найдена для server_id={server_id}")
await callback_query.message.answer("❌ Не удалось определить тарифную группу.")
return
group_code = row[0]
if tariff_id:
if await check_tariff_exists(session, tariff_id):
current_tariff = await get_tariff_by_id(session, tariff_id)
if current_tariff["group_code"] not in ["discounts", "discounts_max", "gifts"]:
group_code = current_tariff["group_code"]
tariffs = await get_tariffs(session, group_code=group_code)
tariffs = [t for t in tariffs if t["is_active"]]
if not tariffs:
await callback_query.message.answer("❌ Нет доступных тарифов для продления.")
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
subgroup = t.get("subgroup_title")
grouped_tariffs[subgroup].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"renew_plan|{t['id']}",
)
)
for subgroup in sorted(k for k in grouped_tariffs if k):
subgroup_hash = create_subgroup_hash(subgroup, group_code)
builder.row(
InlineKeyboardButton(
text=subgroup,
callback_data=f"renew_subgroup|{subgroup_hash}",
)
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="renew_menu"))
balance = await get_balance(session, tg_id)
response_message = PLAN_SELECTION_MSG.format(
balance=balance,
expiry_date=datetime.utcfromtimestamp(expiry_time / 1000).strftime("%Y-%m-%d %H:%M:%S"),
)
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[RENEW] Ошибка в process_callback_renew_key для tg_id={tg_id}: {e}")
await callback_query.message.answer("❌ Произошла ошибка при обработке. Попробуйте позже.")
@router.callback_query(F.data.startswith("renew_subgroup|"))
async def show_tariffs_in_renew_subgroup(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
try:
subgroup_hash = callback.data.split("|")[1]
data = await state.get_data()
client_id = data.get("renew_client_id")
key_name = data.get("renew_key_name")
if not client_id or not key_name:
await callback.message.answer("❌ Данные для подгруппы не найдены.")
return
record = await get_key_details(session, key_name)
if not record:
await callback.message.answer("❌ Ключ не найден.")
return
server_id = record["server_id"]
try:
server_id_int = int(server_id)
filter_condition = or_(
Server.id == server_id_int,
Server.server_name == server_id,
Server.cluster_name == server_id,
)
except ValueError:
filter_condition = or_(
Server.server_name == server_id,
Server.cluster_name == server_id,
)
row = await session.execute(select(Server.tariff_group).where(filter_condition).limit(1))
row = row.first()
if not row or not row[0]:
logger.warning(f"[RENEW_SUBGROUP] Тарифная группа не найдена для server_id={server_id}")
await callback.message.answer("❌ Не удалось определить тарифную группу.")
return
group_code = row[0]
subgroup = await find_subgroup_by_hash(session, subgroup_hash, group_code)
if not subgroup:
await callback.message.answer("❌ Подгруппа не найдена.")
return
tariffs = await get_tariffs(session, group_code=group_code)
filtered = [t for t in tariffs if t["subgroup_title"] == subgroup and t["is_active"]]
if not filtered:
await edit_or_send_message(
target_message=callback.message,
text="❌ В этой подгруппе пока нет тарифов.",
reply_markup=None,
)
return
builder = InlineKeyboardBuilder()
for t in filtered:
builder.row(
InlineKeyboardButton(
text=f"{t['name']}{t['price_rub']}",
callback_data=f"renew_plan|{t['id']}",
)
)
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"renew_key|{key_name}"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
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"[RENEW_SUBGROUP] Ошибка при отображении подгруппы: {e}")
await callback.message.answer("❌ Произошла ошибка при отображении тарифов.")
@router.callback_query(F.data.startswith("renew_plan|"))
async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMContext, session: Any):
tg_id = callback_query.from_user.id
tariff_id = callback_query.data.split("|")[1]
tariff_id = int(tariff_id)
data = await state.get_data()
client_id = data.get("renew_client_id")
key_name = data.get("renew_key_name")
if not client_id or not key_name:
await callback_query.message.answer("❌ Данные для продления не найдены.")
return
try:
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff["is_active"]:
await callback_query.message.answer("❌ Тариф не найден или отключён.")
return
duration_days = tariff["duration_days"]
cost = tariff["price_rub"]
total_gb = tariff["traffic_limit"] or 0
record = await get_key_by_server(session, tg_id, client_id)
if not record:
await callback_query.message.answer(KEY_NOT_FOUND_MSG)
logger.error(f"[RENEW] Ключ с client_id={client_id} не найден.")
return
email = record["email"]
expiry_time = record["expiry_time"]
current_time = datetime.utcnow().timestamp() * 1000
if expiry_time <= current_time:
new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000)
else:
new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000)
balance = round(await get_balance(session, tg_id), 2)
cost = round(cost, 2)
if balance < cost:
required_amount = ceil(cost - balance)
logger.info(f"[RENEW] Недостаточно средств: {required_amount}")
await create_temporary_data(
session,
tg_id,
"waiting_for_renewal_payment",
{
"tariff_id": tariff_id,
"client_id": client_id,
"cost": cost,
"required_amount": required_amount,
"new_expiry_time": new_expiry_time,
"total_gb": total_gb,
"email": email,
},
)
if USE_NEW_PAYMENT_FLOW == "YOOKASSA":
await process_custom_amount_input(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "ROBOKASSA":
await handle_custom_amount_input(message=callback_query, session=session)
elif USE_NEW_PAYMENT_FLOW == "STARS":
await process_custom_amount_input_stars(callback_query, session)
elif USE_NEW_PAYMENT_FLOW == "YOOMONEY":
await process_custom_amount_input_yoomoney(callback_query, session)
else:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=PAYMENT, callback_data="pay"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=callback_query.message,
text=INSUFFICIENT_FUNDS_RENEWAL_MSG.format(required_amount=required_amount),
reply_markup=builder.as_markup(),
)
return
logger.info(f"[RENEW] Продление ключа для пользователя {tg_id} на {duration_days} дней")
await complete_key_renewal(
session,
tg_id,
client_id,
email,
new_expiry_time,
total_gb,
cost,
callback_query,
tariff_id,
)
except Exception as e:
logger.error(f"[RENEW] Ошибка при продлении ключа для пользователя {tg_id}: {e}")
async def resolve_cluster_name(session: AsyncSession, server_or_cluster: str) -> str | None:
result = await session.execute(select(Server).where(Server.cluster_name == server_or_cluster).limit(1))
server = result.scalars().first()
if server:
return server_or_cluster
result = await session.execute(select(Server.cluster_name).where(Server.server_name == server_or_cluster).limit(1))
row = result.scalar()
return row
async def complete_key_renewal(
session: AsyncSession,
tg_id: int,
client_id: str,
email: str,
new_expiry_time: int,
total_gb: int,
cost: float,
callback_query: CallbackQuery | None,
tariff_id: int,
):
try:
logger.info(f"[Info] Продление ключа {client_id} по тарифу ID={tariff_id} (Start)")
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff:
logger.error(f"[Error] Тариф с id={tariff_id} не найден.")
return
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=MY_SUB, callback_data=f"view_key|{email}"))
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
)
response_message = get_renewal_message(
tariff_name=tariff["name"],
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date,
subgroup_title=tariff.get("subgroup_title", ""),
)
if callback_query:
try:
await edit_or_send_message(
target_message=callback_query.message,
text=response_message,
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(f"[Error] Ошибка при редактировании сообщения: {e}")
await callback_query.message.answer(response_message, reply_markup=builder.as_markup())
else:
await bot.send_message(tg_id, response_message, reply_markup=builder.as_markup())
key_info = await get_key_details(session, email)
if not key_info:
logger.error(f"[Error] Ключ с client_id={client_id} не найден в БД.")
return
server_or_cluster = key_info["server_id"]
cluster_id = await resolve_cluster_name(session, server_or_cluster)
if not cluster_id:
logger.error(f"[Error] Кластер для {server_or_cluster} не найден.")
return
await renew_key_in_cluster(
cluster_id,
email,
client_id,
new_expiry_time,
total_gb,
session,
hwid_device_limit=tariff["device_limit"],
)
await update_key_expiry(session, client_id, new_expiry_time)
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await update_balance(session, tg_id, -cost)
logger.info(f"[Info] Продление ключа {client_id} завершено успешно (User: {tg_id})")
except Exception as e:
logger.error(f"[Error] Ошибка в complete_key_renewal: {e}")
+729 -272
View File
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
import html
import os
import re
from datetime import datetime
from typing import Any
import pytz
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
CONNECT_PHONE_BUTTON,
ENABLE_DELETE_KEY_BUTTON,
ENABLE_UPDATE_SUBSCRIPTION_BUTTON,
HWID_RESET_BUTTON,
QRCODE,
TOGGLE_CLIENT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_keys, get_servers, get_tariff_by_id
from database.models import Key
from handlers.buttons import (
ADD_SUB,
ALIAS,
BACK,
CHANGE_LOCATION,
CONNECT_DEVICE,
CONNECT_PHONE,
DELETE,
FREEZE,
HWID_BUTTON,
MAIN_MENU,
PC_BUTTON,
QR,
RENEW_SUB,
TV_BUTTON,
UNFREEZE,
)
from handlers.texts import (
DAYS_LEFT_MESSAGE,
FROZEN_SUBSCRIPTION_MSG,
KEYS_FOOTER,
KEYS_HEADER,
NO_SUBSCRIPTIONS_MSG,
RENAME_KEY_PROMPT,
SELECT_SUBS,
key_message,
)
from handlers.utils import (
edit_or_send_message,
format_days,
format_hours,
format_minutes,
get_russian_month,
is_full_remnawave_cluster,
)
from logger import logger
router = Router()
class RenameKeyState(StatesGroup):
waiting_for_new_alias = State()
@router.callback_query(F.data == "view_keys")
@router.message(F.text == "/subs")
async def process_callback_or_message_view_keys(callback_query_or_message: Message | CallbackQuery, session: Any):
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
else:
target_message = callback_query_or_message
tg_id = callback_query_or_message.from_user.id
try:
records = await get_keys(session, tg_id)
inline_keyboard, response_message = build_keys_response(records)
image_path = os.path.join("img", "pic_keys.jpg")
await edit_or_send_message(
target_message=target_message,
text=response_message,
reply_markup=inline_keyboard,
media_path=image_path,
)
except Exception as e:
error_message = f"Ошибка при получении ключей: {e}"
await target_message.answer(text=error_message)
def build_keys_response(records):
"""
Формирует сообщение и клавиатуру для устройств с указанием срока действия подписки.
"""
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
response_message = KEYS_HEADER
for record in records:
alias = record.alias
email = record.email
client_id = record.client_id
expiry_time = record.expiry_time
key_display = html.escape(alias.strip() if alias else email)
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
formatted_date_full = expiry_date_full.strftime("до %d.%m.%y, %H:%M")
else:
formatted_date_full = "без срока действия"
key_button = InlineKeyboardButton(text=f"🔑 {key_display}", callback_data=f"view_key|{email}")
rename_button = InlineKeyboardButton(text=ALIAS, callback_data=f"rename_key|{client_id}")
builder.row(key_button, rename_button)
response_message += f"• <b>{key_display}</b> ({formatted_date_full})\n"
response_message += KEYS_FOOTER
else:
response_message = NO_SUBSCRIPTIONS_MSG
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
inline_keyboard = builder.as_markup()
return inline_keyboard, response_message
@router.callback_query(F.data.startswith("rename_key|"))
async def handle_rename_key(callback: CallbackQuery, state: FSMContext):
client_id = callback.data.split("|")[1]
await state.set_state(RenameKeyState.waiting_for_new_alias)
await state.update_data(client_id=client_id, target_message=callback.message)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=BACK, callback_data="cancel_and_back_to_view_keys"))
await edit_or_send_message(
target_message=callback.message,
text=RENAME_KEY_PROMPT,
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "cancel_and_back_to_view_keys")
async def cancel_and_back(callback: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
await callback.answer()
await process_callback_or_message_view_keys(callback, session)
@router.message(F.text, RenameKeyState.waiting_for_new_alias)
async def handle_new_alias_input(message: Message, state: FSMContext, session: AsyncSession):
alias = message.text.strip()
if len(alias) > 10:
await message.answer("❌ Имя слишком длинное. Введите до 10 символов.\nПовторите ввод.")
return
if not alias or not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
await message.answer(
"❌ Введены недопустимые символы или имя пустое. Используйте только буквы, цифры и @._-\nПовторите ввод."
)
return
data = await state.get_data()
client_id = data.get("client_id")
try:
await session.execute(
update(Key).where(Key.tg_id == message.chat.id, Key.client_id == client_id).values(alias=alias)
)
await session.commit()
except Exception as e:
await message.answer("❌ Не удалось переименовать подписку.")
logger.error(f"Ошибка при обновлении alias: {e}")
finally:
await state.clear()
await process_callback_or_message_view_keys(message, session)
@router.callback_query(F.data.startswith("view_key|"))
async def process_callback_view_key(callback_query: CallbackQuery, session: Any):
key_name = callback_query.data.split("|")[1]
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
async def render_key_info(message: Message, session: Any, key_name: str, image_path: str):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
record = await get_key_details(session, key_name)
if not record:
await message.answer("<b>Информация о подписке не найдена.</b>")
return
is_frozen = record["is_frozen"]
record["email"]
client_id = record.get("client_id")
remnawave_link = record.get("remnawave_link")
key = record.get("key")
final_link = key or remnawave_link
builder = InlineKeyboardBuilder()
if is_frozen:
builder.row(InlineKeyboardButton(text=UNFREEZE, callback_data=f"unfreeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=message,
text=FROZEN_SUBSCRIPTION_MSG,
reply_markup=builder.as_markup(),
media_path=image_path,
)
return
expiry_time = record["expiry_time"]
server_name = record["server_id"]
expiry_date = datetime.utcfromtimestamp(expiry_time / 1000)
time_left = expiry_date - datetime.utcnow()
if time_left.total_seconds() <= 0:
days_left_message = DAYS_LEFT_MESSAGE
else:
total_seconds = int(time_left.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
days_left_message = (
f"⏳ Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
)
formatted_expiry_date = (
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
)
hwid_count = 0
is_full_remnawave = await is_full_remnawave_cluster(server_name, session)
if is_full_remnawave and client_id:
try:
servers = await get_servers(session)
remna_server = next(
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
)
if remna_server:
api = RemnawaveAPI(remna_server["api_url"])
if await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
devices = await api.get_user_hwid_devices(client_id)
hwid_count = len(devices or [])
except Exception as e:
logger.error(f"Ошибка при получении HWID для {client_id}: {e}")
tariff_name = ""
traffic_limit = 0
device_limit = 0
subgroup_title = ""
tariff = None
if record.get("tariff_id"):
tariff = await get_tariff_by_id(session, record["tariff_id"])
if tariff:
tariff_name = tariff["name"]
traffic_limit = tariff.get("traffic_limit", 0)
device_limit = tariff.get("device_limit", 0)
subgroup_title = tariff.get("subgroup_title", "")
tariff_duration = tariff_name
response_message = key_message(
final_link,
formatted_expiry_date,
days_left_message,
server_name,
server_name if USE_COUNTRY_SELECTION else None,
hwid_count=hwid_count if device_limit is not None else 0,
tariff_name=tariff_duration,
traffic_limit=traffic_limit,
device_limit=device_limit,
subgroup_title=subgroup_title,
)
if ENABLE_UPDATE_SUBSCRIPTION_BUTTON:
builder.row(
InlineKeyboardButton(
text=RENEW_SUB,
callback_data=f"update_subscription|{key_name}",
)
)
if is_full_remnawave and final_link:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, web_app=WebAppInfo(url=final_link)))
builder.row(InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"))
else:
if CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{key_name}"),
InlineKeyboardButton(text=TV_BUTTON, callback_data=f"connect_tv|{key_name}"),
)
else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
if HWID_RESET_BUTTON and hwid_count > 0:
builder.row(
InlineKeyboardButton(
text=HWID_BUTTON,
callback_data=f"reset_hwid|{key_name}",
)
)
if QRCODE:
builder.row(InlineKeyboardButton(text=QR, callback_data=f"show_qr|{key_name}"))
if ENABLE_DELETE_KEY_BUTTON:
builder.row(
InlineKeyboardButton(text=DELETE, callback_data=f"delete_key|{key_name}"),
)
if USE_COUNTRY_SELECTION:
builder.row(InlineKeyboardButton(text=CHANGE_LOCATION, callback_data=f"change_location|{key_name}"))
if TOGGLE_CLIENT:
builder.row(InlineKeyboardButton(text=FREEZE, callback_data=f"freeze_subscription|{key_name}"))
builder.row(InlineKeyboardButton(text=BACK, callback_data="view_keys"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
await edit_or_send_message(
target_message=message,
text=response_message,
reply_markup=builder.as_markup(),
media_path=image_path,
)
@router.callback_query(F.data.startswith("reset_hwid|"))
async def handle_reset_hwid(callback_query: CallbackQuery, session: Any):
from config import REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from panels.remnawave import RemnawaveAPI
key_name = callback_query.data.split("|")[1]
record = await get_key_details(session, key_name)
if not record:
await callback_query.answer("❌ Ключ не найден.", show_alert=True)
return
client_id = record.get("client_id")
if not client_id:
await callback_query.answer("❌ У ключа отсутствует client_id.", show_alert=True)
return
servers = await get_servers(session=session)
remna_server = next(
(srv for cl in servers.values() for srv in cl if srv.get("panel_type") == "remnawave"),
None,
)
if not remna_server:
await callback_query.answer("❌ Remnawave-сервер не найден.", show_alert=True)
return
api = RemnawaveAPI(remna_server["api_url"])
if not await api.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await callback_query.answer("❌ Авторизация в Remnawave не удалась.", show_alert=True)
return
devices = await api.get_user_hwid_devices(client_id)
if not devices:
await callback_query.answer("✅ Устройства не были привязаны.", show_alert=True)
else:
deleted = 0
for device in devices:
if await api.delete_user_hwid_device(client_id, device["hwid"]):
deleted += 1
await callback_query.answer(f"✅ Устройства сброшены ({deleted})", show_alert=True)
image_path = os.path.join("img", "pic_view.jpg")
await render_key_info(callback_query.message, session, key_name, image_path)
@router.callback_query(F.data == "renew_menu")
@router.callback_query(F.data == "extend")
@router.message(F.text == "/extend")
async def process_renew_menu(callback_query_or_message: CallbackQuery | Message, session: Any):
try:
if isinstance(callback_query_or_message, CallbackQuery):
target_message = callback_query_or_message.message
tg_id = callback_query_or_message.from_user.id
else:
target_message = callback_query_or_message
tg_id = callback_query_or_message.from_user.id
records = await get_keys(session, tg_id)
servers_dict = await get_servers(session)
all_server_names = set()
for servers in servers_dict.values():
for s in servers:
all_server_names.add(s["server_name"])
builder = InlineKeyboardBuilder()
moscow_tz = pytz.timezone("Europe/Moscow")
if records:
for record in records:
if getattr(record, "is_frozen", False):
continue
alias = record.alias
email = record.email
expiry_time = record.expiry_time
server_id = record.server_id
key_display = alias.strip() if alias else email
if expiry_time:
expiry_date_full = datetime.fromtimestamp(expiry_time / 1000, tz=moscow_tz)
now = datetime.now(moscow_tz)
days_left = (expiry_date_full - now).days
if (expiry_date_full - now).total_seconds() <= 0:
days_text = "🔴 Истекла"
else:
days_text = format_days(days_left)
else:
days_text = "истекла"
server_info = f" ({server_id})" if server_id in all_server_names else ""
btn_text = f"🔑 {key_display} (⏳{days_text}) {server_info}"
builder.row(InlineKeyboardButton(text=btn_text, callback_data=f"renew_key|{email}"))
text = SELECT_SUBS
builder.row(InlineKeyboardButton(text=ADD_SUB, callback_data="create_key"))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
image_path = os.path.join("img", "pic_view.jpg")
await edit_or_send_message(
target_message=target_message,
text=text,
reply_markup=builder.as_markup(),
media_path=image_path,
)
except Exception as e:
error_message = f"Ошибка при получении подписок для продления: {e}"
await target_message.answer(text=error_message)
+24 -965
View File
File diff suppressed because it is too large Load Diff
+150 -248
View File
@@ -5,266 +5,194 @@ import re
import time
import urllib.parse
from datetime import datetime
import aiohttp
import asyncpg
import pytz
from aiohttp import web
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import (
DATABASE_URL,
PROJECT_NAME,
RANDOM_SUBSCRIPTIONS,
SUPERNODE,
SUPPORT_CHAT_URL,
TOTAL_GB,
TRANSITION_DATE_STR,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from database import get_key_details, get_servers
from database.models import Server
from handlers.texts import HAPP_ANNOUNCE, HIDDIFY_PROFILE_TITLE, SUBSCRIPTION_INFO_TEXT, V2RAYTUN_ANNOUNCE
from handlers.utils import convert_to_bytes
from logger import logger
async def fetch_url_content(url: str, identifier: str) -> list[str]:
"""
Получает содержимое подписки по URL и декодирует его.
Args:
url: URL для получения содержимого
identifier: Идентификатор пользователя (tg_id или email)
Returns:
Список строк из подписки
"""
async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[str, str]]:
try:
logger.info(f"Получение URL: {url} для идентификатора: {identifier}")
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, ssl=False) as response:
if response.status == 200:
content = await response.text()
logger.info(f"Успешно получен контент с {url} для идентификатора: {identifier}")
return base64.b64decode(content).decode("utf-8").split("\n")
else:
logger.error(
f"Не удалось получить {url} для идентификатора: {identifier}, статус: {response.status}"
)
return []
except TimeoutError:
logger.error(f"Таймаут при получении {url} для идентификатора: {identifier}")
return []
lines = base64.b64decode(content).decode("utf-8").split("\n")
headers = {k.lower(): v for k, v in response.headers.items()}
logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
return lines, headers
return [], {}
except Exception as e:
logger.error(f"Ошибка при получении {url} для идентификатора: {identifier}: {e}")
return []
logger.error(f"Error fetching URL {url}: {e}")
return [], {}
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]:
"""
Объединяет строки подписки из нескольких URL, удаляя дубликаты.
Args:
urls: Список URL для получения подписок
identifier: Идентификатор пользователя (tg_id или email)
query_string: Строка запроса для добавления к URL
Returns:
Список уникальных строк из всех подписок
"""
async def combine_unique_lines(
urls: list[str], identifier: str, query_string: str
) -> tuple[list[str], list[dict[str, str]]]:
if SUPERNODE:
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
if not urls:
return []
return [], []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
return await fetch_url_content(url_with_query, identifier)
lines, headers = await fetch_url_content(url_with_query, identifier)
return lines, [headers]
logger.info(f"Начинаем объединение подписок для идентификатора: {identifier}, запрос: {query_string}")
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
logger.info(f"Составлены URL-адреса: {urls_with_query}")
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set()
for lines in results:
all_lines.update(filter(None, lines))
logger.info(
f"Объединено {len(all_lines)} строк после фильтрации и удаления дубликатов для идентификатора: {identifier}"
)
return list(all_lines)
all_lines = []
all_headers = []
for result in results:
if isinstance(result, tuple):
lines, headers = result
for line in filter(None, lines):
if line not in all_lines:
all_lines.append(line)
all_headers.append(headers)
return all_lines, all_headers
async def get_subscription_urls(server_id: str, email: str, conn) -> list[str]:
"""
Получает список URL-адресов для подписки в зависимости от режима выбора страны.
Args:
server_id: Идентификатор сервера или кластера
email: Email пользователя
conn: Соединение с базой данных
Returns:
Список URL-адресов для подписки
"""
async def get_subscription_urls(
server_id: str, email: str, session: AsyncSession, include_remnawave_key: str = None
) -> list[str]:
urls = []
if USE_COUNTRY_SELECTION:
logger.info(f"Режим выбора страны активен. Ищем сервер {server_id} в БД.")
server_data = await conn.fetchrow("SELECT subscription_url FROM servers WHERE server_name = $1", server_id)
if not server_data:
logger.warning(f"Не найден сервер {server_id} в БД!")
return []
subscription_url = server_data["subscription_url"]
urls = [f"{subscription_url}/{email}"]
logger.info(f"Используем подписку {urls[0]}")
return urls
result = await session.execute(
select(Server.subscription_url).where(Server.server_name == server_id, Server.enabled.is_(True))
)
server_data = result.scalar()
if server_data:
urls.append(f"{server_data}/{email}")
else:
servers = await get_servers(session)
cluster_servers = servers.get(server_id, [])
for server in cluster_servers:
if url := server.get("subscription_url"):
urls.append(f"{url}/{email}")
if include_remnawave_key:
urls.append(include_remnawave_key)
if RANDOM_SUBSCRIPTIONS:
random.shuffle(urls)
servers = await get_servers(conn)
logger.info(f"Режим выбора страны отключен. Используем кластер {server_id}.")
cluster_servers = servers.get(server_id, [])
if not cluster_servers:
logger.warning(f"Не найдены сервера для {server_id}")
return []
urls = [f"{server['subscription_url']}/{email}" for server in cluster_servers]
logger.info(f"Найдено {len(urls)} URL-адресов в кластере {server_id}")
return urls
def get_transition_timestamp() -> int:
"""
Получает временную метку перехода с учетом часового пояса Москвы.
Returns:
Временная метка перехода в миллисекундах
"""
moscow_tz = pytz.timezone("Europe/Moscow")
transition_date_naive = datetime.strptime(TRANSITION_DATE_STR, "%Y-%m-%d %H:%M:%S")
transition_date = moscow_tz.localize(transition_date_naive)
transition_timestamp_ms = int(transition_date.timestamp() * 1000)
return transition_timestamp_ms
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str:
"""
Рассчитывает информацию о трафике на основе подписок.
Args:
cleaned_subscriptions: Список строк подписки
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Строка с информацией о трафике
"""
def calculate_traffic(
cleaned_subscriptions: list[str],
expiry_time_ms: int | None,
headers_list: list[dict[str, str]],
) -> str:
logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}")
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
if TOTAL_GB != 0:
country_remaining = {}
for line in cleaned_subscriptions:
if "#" not in line:
continue
upload = 0
download = 0
total = 0
for headers in headers_list:
userinfo = headers.get("subscription-userinfo", "")
if userinfo:
parts = userinfo.split(";")
for part in parts:
part = part.strip()
if part.startswith("upload="):
upload += int(part.split("=")[1])
elif part.startswith("download="):
download += int(part.split("=")[1])
elif part.startswith("total="):
total += int(part.split("=")[1])
logger.debug(f"Processed Subscription-Userinfo: {userinfo}")
try:
_, meta = line.split("#", 1)
except ValueError:
continue
country_remaining = {}
for line in cleaned_subscriptions:
if "#" not in line:
continue
try:
_, meta = line.split("#", 1)
except ValueError:
continue
parts = meta.split("-")
country = parts[0].strip()
remaining_str = parts[1].strip() if len(parts) == 2 else ""
if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
logger.debug(f"Found traffic: {value}{unit} for {country}")
parts = meta.split("-")
country = parts[0].strip()
remaining_str = parts[1].strip() if len(parts) == 2 else ""
consumed_traffic_bytes = total - sum(country_remaining.values()) if country_remaining else download
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
download = max(download, consumed_traffic_bytes)
if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
if download == 0 and total == 0 and not country_remaining:
download = 1
num_countries = len(country_remaining)
issued_per_country = TOTAL_GB
total_traffic_bytes = issued_per_country * num_countries
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
else:
consumed_traffic_bytes = 1
total_traffic_bytes = 0
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
result = f"upload={upload}; download={download}; total={total}; expire={expire_timestamp}"
logger.debug(f"Calculated subscription-userinfo: {result}")
return result
def clean_subscription_line(line: str) -> str:
"""
Очищает строку подписки, оставляя только нужную информацию.
Args:
line: Исходная строка подписки
Returns:
Очищенная строка подписки
"""
if "#" not in line:
return line
try:
base, meta = line.split("#", 1)
except ValueError:
return line
parts = meta.split("-")
country = parts[0].strip() if parts else ""
traffic = ""
for part in parts[1:]:
part_decoded = urllib.parse.unquote(part).strip()
if re.search(r"\d+(?:[.,]\d+)?\s*(?:GB|MB|KB|TB)", part_decoded, re.IGNORECASE):
traffic = part_decoded
break
meta_clean = f"{country} - {traffic}" if traffic else country
return base + "#" + meta_clean
def format_time_left(expiry_time_ms: int | None) -> str:
"""
Форматирует оставшееся время подписки.
Args:
expiry_time_ms: Время истечения подписки в миллисекундах
Returns:
Отформатированная строка с оставшимся временем
"""
if not expiry_time_ms:
return "N/A"
now_ms = int(time.time() * 1000)
remaining_sec = max((expiry_time_ms - now_ms) / 1000, 0)
days = int(remaining_sec // 86400)
hours = int((remaining_sec % 86400) // 3600)
return f"{days}D,{hours}H ⏳" if days else f"{hours}H ⏳"
def prepare_headers(
user_agent: str, project_name: str, subscription_info: str, subscription_userinfo: str
user_agent: str,
project_name: str,
subscription_info: str,
subscription_userinfo: str,
) -> dict[str, str]:
"""
Подготавливает заголовки ответа в зависимости от User-Agent клиента.
Args:
user_agent: User-Agent клиента
project_name: Название проекта
subscription_info: Информация о подписке
subscription_userinfo: Информация о трафике
Returns:
Словарь с заголовками ответа
"""
if "Happ" in user_agent:
encoded_project_name = f"{project_name}"
announce_str = f"↖️Бот | {subscription_info} | Поддержка↗️"
announce_str = HAPP_ANNOUNCE.format(subscription_info=subscription_info)
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
@@ -278,13 +206,24 @@ def prepare_headers(
elif "Hiddify" in user_agent:
parts = subscription_info.split(" - ")[0].split(": ")
key_info = parts[1] if len(parts) > 1 else parts[0]
encoded_project_name = f"{project_name}\n📄 Подписка: {key_info}"
encoded_project_name = HIDDIFY_PROFILE_TITLE.format(project_name=project_name, key_info=key_info)
return {
"profile-update-interval": "3",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
elif "v2raytun" in user_agent:
encoded_project_name = f"{project_name}\n{subscription_info}"
announce_str = V2RAYTUN_ANNOUNCE
return {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": "inline",
"update-always": "true",
"announce": "base64:" + base64.b64encode(announce_str.encode("utf-8")).decode("utf-8"),
"announce-url": f"{SUPPORT_CHAT_URL}",
"profile-title": "base64:" + base64.b64encode(encoded_project_name.encode("utf-8")).decode("utf-8"),
"subscription-userinfo": subscription_userinfo,
}
else:
encoded_project_name = f"{project_name}\n{subscription_info}"
return {
@@ -295,85 +234,48 @@ def prepare_headers(
}
async def handle_subscription(request: web.Request, old_subscription: bool = False) -> web.Response:
"""
Обрабатывает запрос на подписку (старую или новую).
Args:
request: Объект запроса
old_subscription: Флаг, указывающий на тип подписки (старая или новая)
Returns:
Ответ с подпиской или сообщением об ошибке
"""
async def handle_subscription(request: web.Request) -> web.Response:
email = request.match_info.get("email")
tg_id = request.match_info.get("tg_id") if not old_subscription else None
tg_id = request.match_info.get("tg_id")
if not email or (not old_subscription and not tg_id):
logger.warning("Получен запрос с отсутствующими параметрами")
if not email or not tg_id:
return web.Response(text="❌ Неверные параметры запроса.", status=400)
logger.info(
f"Обработка запроса для {'старого' if old_subscription else 'нового'} клиента: email={email}, tg_id={tg_id}"
)
sessionmaker = request.app["sessionmaker"]
conn = await asyncpg.connect(DATABASE_URL)
try:
client_data = await get_key_details(email, conn)
if not client_data:
logger.warning(f"Клиент с email {email} не найден в базе.")
return web.Response(text="❌ Клиент с таким email не найден.", status=404)
async with sessionmaker() as session:
try:
key = await get_key_details(session, email)
if not key:
return web.Response(text="Клиент с таким email не найден.", status=404)
stored_tg_id = client_data.get("tg_id")
server_id = client_data["server_id"]
if int(tg_id) != int(key["tg_id"]):
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
if not old_subscription and int(tg_id) != int(stored_tg_id):
logger.warning(f"Неверный tg_id для клиента с email {email}.")
return web.Response(text="❌ Неверные данные. Получите свой ключ в боте.", status=403)
expiry_time_ms = key["expiry_time"]
server_id = key["server_id"]
remnawave_link = key["remnawave_link"]
if old_subscription:
created_at_ms = client_data["created_at"]
created_at_datetime = datetime.utcfromtimestamp(created_at_ms / 1000)
logger.info(f"created_at для {email}: {created_at_datetime}, server_id: {server_id}")
time_left = format_time_left(expiry_time_ms)
transition_timestamp_ms = get_transition_timestamp()
logger.info(f"Время перехода (с учетом часового пояса Москвы): {transition_timestamp_ms}")
urls = await get_subscription_urls(server_id, email, session, include_remnawave_key=remnawave_link)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
if created_at_ms >= transition_timestamp_ms:
logger.info(f"Клиент с email {email} является новым.")
return web.Response(text="❌ Эта ссылка устарела. Пожалуйста, обновите ссылку.", status=400)
query_string = request.query_string
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
expiry_time_ms = client_data.get("expiry_time")
time_left = format_time_left(expiry_time_ms)
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
urls = await get_subscription_urls(server_id, email, conn)
if not urls:
return web.Response(text="❌ Сервер не найден.", status=404)
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
subscription_info = SUBSCRIPTION_INFO_TEXT.format(email=email, time_left=time_left)
query_string = request.query_string if not old_subscription else ""
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
random.shuffle(combined_subscriptions)
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms, headers_list)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
return web.Response(text=base64_encoded, headers=headers)
base64_encoded = base64.b64encode("\n".join(cleaned_subscriptions).encode("utf-8")).decode("utf-8")
subscription_info = f"📄 Подписка: {email} - {time_left}"
user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
logger.info(f"Возвращаем объединенные подписки для email: {email}")
return web.Response(text=base64_encoded, headers=headers)
finally:
await conn.close()
async def handle_old_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для старых клиентов."""
return await handle_subscription(request, old_subscription=True)
async def handle_new_subscription(request: web.Request) -> web.Response:
"""Обработка запроса для новых клиентов."""
return await handle_subscription(request, old_subscription=False)
except Exception as e:
logger.error(f"Ошибка в handle_subscription: {e}", exc_info=True)
return web.Response(text=f"❌ Ошибка сервера: {e}", status=500)
+366 -222
View File
@@ -2,143 +2,165 @@ import asyncio
from datetime import datetime, timedelta
import asyncpg
import pytz
from aiogram import Bot, Router
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from config import (
DATABASE_URL,
NOTIFICATION_TIME,
NOTIFY_DELETE_DELAY,
NOTIFY_DELETE_KEY,
NOTIFY_HOT_LEADS,
NOTIFY_INACTIVE_TRAFFIC,
NOTIFY_MAXPRICE,
NOTIFY_RENEW,
NOTIFY_RENEW_EXPIRED,
RENEWAL_PRICES,
TOTAL_GB,
TRIAL_TIME_DISABLE,
)
from database import (
add_notification,
check_notification_time,
check_notifications_bulk,
check_tariff_exists,
delete_key,
delete_notification,
get_all_keys,
get_balance,
get_last_notification_time,
get_tariff_by_id,
get_tariffs_for_cluster,
update_balance,
update_key_expiry,
delete_notification
update_key_tariff,
)
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb
from handlers.notifications.notify_kb import (
build_notification_expired_kb,
build_notification_kb,
)
from handlers.texts import (
KEY_DELETED_MSG,
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
KEY_EXPIRED_DELAY_HOURS_MSG,
KEY_EXPIRED_DELAY_MINUTES_MSG,
KEY_EXPIRED_DELAY_MSG,
KEY_EXPIRED_NO_DELAY_MSG,
KEY_EXPIRY_10H,
KEY_EXPIRY_24H,
KEY_RENEWED,
KEY_RENEWED_TEMP_MSG,
get_renewal_message,
)
from handlers.utils import format_days, format_hours, format_minutes, format_months, get_russian_month
from logger import logger
from .notify_utils import send_notification
from .hot_leads_notifications import notify_hot_leads
from .notify_utils import send_messages_with_limit, send_notification
from .special_notifications import notify_inactive_trial_users, notify_users_no_traffic
router = Router()
moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock()
async def periodic_notifications(bot: Bot):
"""
Периодическая проверка и отправка уведомлений.
Защищена от одновременного запуска с помощью asyncio.Lock.
"""
async def periodic_notifications(bot: Bot, *, sessionmaker: async_sessionmaker):
while True:
if notification_lock.locked():
logger.warning("⛔ Предыдущая задача уведомлений ещё выполняется. Пропуск итерации.")
logger.warning("Уведомления уже выполняются. Пропуск...")
await asyncio.sleep(NOTIFICATION_TIME)
continue
async with notification_lock:
conn = None
try:
conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
async with sessionmaker() as session:
logger.info("🔔 Запуск обработки уведомлений")
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
logger.info("🚀 Запуск обработки уведомлений")
try:
keys = await get_all_keys(session=session)
keys = [k for k in keys if not k.is_frozen]
except Exception as e:
logger.error(f"Ошибка при получении ключей: {e}")
keys = []
try:
keys = await get_all_keys(session=conn)
keys = [k for k in keys if not k["is_frozen"]]
except Exception as e:
logger.error(f"Ошибка при получении ключей: {e}")
keys = []
if not TRIAL_TIME_DISABLE:
try:
await notify_inactive_trial_users(bot, session)
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
if not TRIAL_TIME_DISABLE:
await notify_inactive_trial_users(bot, conn)
await asyncio.sleep(0.5)
try:
await notify_24h_keys(bot, session, current_time, threshold_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
if NOTIFY_INACTIVE_TRAFFIC:
await notify_users_no_traffic(bot, conn, current_time, keys)
await asyncio.sleep(0.5)
try:
await notify_10h_keys(bot, session, current_time, threshold_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
logger.info("✅ Завершена обработка уведомлений")
try:
await handle_expired_keys(bot, session, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в handle_expired_keys: {e}")
if NOTIFY_INACTIVE_TRAFFIC:
try:
await notify_users_no_traffic(bot, session, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
if NOTIFY_HOT_LEADS:
try:
await notify_hot_leads(bot, session)
except Exception as e:
logger.error(f"Ошибка в notify_hot_leads: {e}")
logger.info("✅ Уведомления завершены")
except Exception as e:
logger.error(f"Ошибка в periodic_notifications: {e}")
finally:
if conn:
await conn.close()
logger.info("🔌 Соединение с базой данных закрыто.")
logger.error(f"Ошибка в periodic_notifications: {e}")
await asyncio.sleep(NOTIFICATION_TIME)
async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_24h: int, keys: list):
async def notify_24h_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
threshold_time_24h: int,
keys: list,
):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 24 часа.
"""
logger.info("Начало проверки подписок, истекающих через 24 часа.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_24h
]
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_24h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 24 часа.")
tg_ids = [key["tg_id"] for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk(session, "key_24h", 24, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
email = key.email or ""
notification_id = f"{email}_key_24h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=24, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(session, tg_id, notification_id, hours=24)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
days_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -146,54 +168,89 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_24H.format(
email=email,
days_left_message=days_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
if NOTIFY_RENEW:
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text)
try:
await process_auto_renew_or_notify(
bot,
session,
key,
notification_id,
1,
"notify_24h.jpg",
notification_text,
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_24h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 24 часа для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_24h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
logger.info("✅ Обработка всех уведомлений за 24 часа завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1)
async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, threshold_time_10h: int, keys: list):
"""
Отправляет уведомления пользователям о том, что их подписка истекает через 10 часов.
"""
async def notify_10h_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
threshold_time_10h: int,
keys: list,
):
logger.info("Начало проверки подписок, истекающих через 10 часов.")
expiring_keys = [
key for key in keys if key.get("expiry_time") and current_time < key.get("expiry_time") <= threshold_time_10h
]
expiring_keys = [key for key in keys if key.expiry_time and current_time < key.expiry_time <= threshold_time_10h]
logger.info(f"Найдено {len(expiring_keys)} подписок, истекающих через 10 часов.")
tg_ids = [key.tg_id for key in expiring_keys]
emails = [key.email or "" for key in expiring_keys]
users = await check_notifications_bulk(session, "key_10h", 10, tg_ids=tg_ids, emails=emails)
messages = []
for key in expiring_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
expiry_timestamp = key.get("expiry_time")
tg_id = key.tg_id
email = key.email or ""
notification_id = f"{email}_key_10h"
try:
can_notify = await check_notification_time(tg_id, notification_id, hours=10, session=conn)
except Exception as e:
logger.error(f"Ошибка проверки уведомления для пользователя {tg_id}: {e}")
continue
can_notify = await check_notification_time(session, tg_id, notification_id, hours=10)
if not can_notify:
continue
user = next((u for u in users if u["tg_id"] == tg_id and u["email"] == email), None)
if not user:
continue
expiry_timestamp = key.expiry_time
hours_left = int((expiry_timestamp - current_time) / (1000 * 3600))
hours_left_message = (
f"⏳ Осталось времени: {hours_left} часов" if hours_left > 0 else "⏳ Последний день подписки!"
hours_left_formatted = (
f"⏳ Осталось времени: {format_hours(hours_left)}" if hours_left > 0 else "⏳ Последний день подписки!"
)
expiry_datetime = datetime.fromtimestamp(expiry_timestamp / 1000, tz=moscow_tz)
@@ -201,69 +258,104 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
notification_text = KEY_EXPIRY_10H.format(
email=email,
hours_left_message=hours_left_message,
hours_left_formatted=hours_left_formatted,
formatted_expiry_date=formatted_expiry_date,
)
if NOTIFY_RENEW:
try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_10h.jpg", notification_text
bot,
session,
key,
notification_id,
1,
"notify_10h.jpg",
notification_text,
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else:
keyboard = build_notification_kb(email)
try:
await send_notification(bot, tg_id, "notify_10h.jpg", notification_text, keyboard)
logger.info(f"Отправлено уведомление об истечении подписки через 10 часов для пользователя {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Не удалось отправить уведомление пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": notification_text,
"photo": "notify_10h.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
logger.info("✅ Обработка всех уведомлений за 10 часов завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(session, tg_id, msg["notification_id"])
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1)
async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time: int, keys: list):
"""
Обрабатывает истекшие ключи, проверяя продление или удаление.
"""
async def handle_expired_keys(
bot: Bot,
session: AsyncSession,
current_time: int,
keys: list,
):
logger.info("Начало обработки истекших ключей.")
expired_keys = [key for key in keys if key.get("expiry_time") and key.get("expiry_time") < current_time]
expired_keys = [key for key in keys if key.expiry_time and key.expiry_time < current_time]
logger.info(f"Найдено {len(expired_keys)} истекших ключей.")
tg_ids = [key.tg_id for key in expired_keys]
emails = [key.email or "" for key in expired_keys]
users = await check_notifications_bulk(session, "key_expired", 0, tg_ids=tg_ids, emails=emails)
messages = []
for key in expired_keys:
tg_id = key["tg_id"]
email = key.get("email", "")
client_id = key.get("client_id")
server_id = key.get("server_id")
tg_id = key.tg_id
email = key.email or ""
client_id = key.client_id
server_id = key.server_id
notification_id = f"{email}_key_expired"
try:
last_notification_time = await get_last_notification_time(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"Ошибка получения времени последнего уведомления для пользователя {tg_id}: {e}")
continue
last_notification_time = await get_last_notification_time(session, tg_id, notification_id)
if NOTIFY_RENEW_EXPIRED:
try:
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения баланса для пользователя {tg_id}: {e}")
continue
balance = await get_balance(session, tg_id)
tariffs = await get_tariffs_for_cluster(session, server_id)
tariff = tariffs[0] if tariffs else None
renewal_period_months = 1
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
if balance >= renewal_cost:
try:
if tariff and balance >= tariff["price_rub"]:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_expired.jpg", KEY_RENEWED_TEMP_MSG
bot,
session,
key,
notification_id,
1,
"notify_expired.jpg",
get_renewal_message(
tariff_name=tariff.get("name", ""),
traffic_limit=tariff.get("traffic_limit") if tariff.get("traffic_limit") is not None else 0,
device_limit=tariff.get("device_limit") if tariff.get("device_limit") is not None else 0,
subgroup_title=tariff.get("subgroup_title", ""),
),
)
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
except Exception as e:
logger.error(f"Ошибка авто-продления для пользователя {tg_id}: {e}")
continue
if NOTIFY_DELETE_KEY:
@@ -279,141 +371,193 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
if delete_immediately or delete_after_delay:
try:
await delete_key_from_cluster(server_id, email, client_id)
await delete_key(client_id, conn)
await delete_key_from_cluster(server_id, email, client_id, session)
await delete_key(session, client_id)
logger.info(f"🗑 Ключ {client_id} для пользователя {tg_id} успешно удалён.")
keyboard = build_notification_expired_kb()
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
KEY_DELETED_MSG.format(email=email),
keyboard,
)
logger.info(f"📢 Отправлено уведомление об удалении подписки {email} пользователю {tg_id}.")
except Exception as e:
logger.error(f"Не удалось отправить уведомление об удалении пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
except Exception as e:
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
continue
if last_notification_time is None:
if last_notification_time is None and any(u["tg_id"] == tg_id and u["email"] == email for u in users):
keyboard = build_notification_kb(email)
if NOTIFY_DELETE_DELAY > 0:
hours = NOTIFY_DELETE_DELAY // 60
minutes = NOTIFY_DELETE_DELAY % 60
if hours > 0:
if minutes > 0:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix} и {minutes} минут"
delay_message = KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG.format(email=email, time_str=time_str)
else:
hour_suffix = "час" if hours == 1 else "часа" if 2 <= hours <= 4 else "часов"
time_str = f"{hours} {hour_suffix}"
delay_message = KEY_EXPIRED_DELAY_HOURS_MSG.format(email=email, time_str=time_str)
if hours > 0 and minutes > 0:
time_formatted = f"{format_hours(hours)} и {format_minutes(minutes)}"
elif hours > 0:
time_formatted = format_hours(hours)
else:
delay_message = KEY_EXPIRED_DELAY_MINUTES_MSG.format(email=email, minutes=NOTIFY_DELETE_DELAY)
time_formatted = format_minutes(minutes)
delay_message = KEY_EXPIRED_DELAY_MSG.format(email=email, time_formatted=time_formatted)
else:
delay_message = KEY_EXPIRED_NO_DELAY_MSG.format(email=email)
try:
await send_notification(
bot,
tg_id,
"notify_expired.jpg",
delay_message,
keyboard,
)
await add_notification(tg_id, notification_id, session=conn)
logger.info(
f"📢 Отправлено уведомление о необходимости продления подписки {email} пользователю {tg_id}."
)
except Exception as e:
logger.error(f"Не удалось отправить уведомление о продлении подписки пользователю {tg_id}: {e}")
messages.append({
"tg_id": tg_id,
"text": delay_message,
"photo": "notify_expired.jpg",
"keyboard": keyboard,
"notification_id": notification_id,
"email": email,
})
logger.info("✅ Обработка истекших ключей завершена.")
if messages:
results = await send_messages_with_limit(bot, messages, session=session)
sent_count = 0
for msg, result in zip(messages, results, strict=False):
await add_notification(session, msg["tg_id"], msg["notification_id"])
if result:
sent_count += 1
logger.info(f"📢 Уведомление об истекшем ключе {msg['email']} отправлено пользователю {msg['tg_id']}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе {msg['email']} пользователю {msg['tg_id']}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истекших ключах.")
logger.info("Обработка истекших ключей завершена.")
await asyncio.sleep(1)
async def process_auto_renew_or_notify(
bot,
conn,
key: dict,
key,
notification_id: str,
renewal_period_months: int,
standard_photo: str,
standard_caption: str
standard_caption: str,
):
"""
Если баланс пользователя позволяет, продлевает ключ на максимальный возможный срок и списывает средства;
иначе отправляет стандартное уведомление.
"""
tg_id = key.get("tg_id")
email = key.get("email", "")
tg_id = key.tg_id
email = key.email or ""
renew_notification_id = f"{email}_renew"
try:
can_renew = await check_notification_time(tg_id, renew_notification_id, hours=24, session=conn)
can_renew = await check_notification_time(conn, tg_id, renew_notification_id, hours=24)
if not can_renew:
logger.info(
f"⏳ Подписка {email} уже продлевалась в течение последних 24 часов, повторное продление отменено."
)
return
balance = await get_balance(tg_id)
except Exception as e:
logger.error(f"Ошибка получения данных для пользователя {tg_id}: {e}")
return
balance = await get_balance(conn, tg_id)
server_id = key.server_id
tariff_id = key.tariff_id
if NOTIFY_MAXPRICE:
renewal_period_months = max(
(int(months) for months, price in RENEWAL_PRICES.items() if balance >= price), default=None
tariffs = await get_tariffs_for_cluster(conn, server_id)
if not tariffs:
logger.warning(f"⛔ Нет доступных тарифов для продления подписки {email} (сервер: {server_id})")
return
selected_tariff = None
if not tariff_id:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
else:
if await check_tariff_exists(conn, tariff_id):
current_tariff = await get_tariff_by_id(conn, tariff_id)
if current_tariff["group_code"] in ["discounts", "discounts_max", "gifts"]:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
elif balance >= current_tariff["price_rub"]:
selected_tariff = current_tariff
else:
cluster_tariffs = [t for t in tariffs if t["is_active"] and balance >= t["price_rub"]]
if cluster_tariffs:
cluster_tariffs_31 = [t for t in cluster_tariffs if t["duration_days"] <= 31]
if cluster_tariffs_31:
selected_tariff = max(cluster_tariffs_31, key=lambda x: x["duration_days"])
else:
selected_tariff = None
if not selected_tariff:
keyboard = build_notification_kb(email)
await add_notification(conn, tg_id, notification_id)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
return
client_id = key.client_id
current_expiry = key.expiry_time
duration_days = selected_tariff["duration_days"]
selected_tariff["name"]
renewal_cost = selected_tariff["price_rub"]
traffic_limit = selected_tariff["traffic_limit"]
device_limit = selected_tariff["device_limit"]
total_gb = traffic_limit if traffic_limit else 0
new_expiry_time = (
current_expiry
if current_expiry > datetime.utcnow().timestamp() * 1000
else datetime.utcnow().timestamp() * 1000
) + duration_days * 24 * 60 * 60 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%d %B %Y, %H:%M")
formatted_expiry_date = formatted_expiry_date.replace(
datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz).strftime("%B"),
get_russian_month(datetime.fromtimestamp(new_expiry_time / 1000, tz=moscow_tz)),
)
else:
renewal_period_months = 1 if balance >= RENEWAL_PRICES["1"] else None
if renewal_period_months:
renewal_period_months = int(renewal_period_months)
renewal_cost = RENEWAL_PRICES[str(renewal_period_months)]
client_id = key.get("client_id")
server_id = key.get("server_id")
current_expiry = key.get("expiry_time")
new_expiry_time = current_expiry + renewal_period_months * 30 * 24 * 3600 * 1000
formatted_expiry_date = datetime.fromtimestamp(new_expiry_time / 1000, moscow_tz).strftime("%d %B %Y, %H:%M")
logger.info(
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
f"Продление подписки {email} на {duration_days} дней для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
)
try:
await renew_key_in_cluster(server_id, email, client_id, new_expiry_time, TOTAL_GB)
await update_balance(tg_id, -renewal_cost, session=conn)
await update_key_expiry(client_id, new_expiry_time, conn)
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=client_id,
new_expiry_time=int(new_expiry_time),
total_gb=total_gb,
hwid_device_limit=device_limit,
session=conn,
)
await update_balance(conn, tg_id, -renewal_cost)
await update_key_expiry(conn, client_id, int(new_expiry_time))
await update_key_tariff(conn, client_id, selected_tariff["id"])
await add_notification(conn, tg_id, renew_notification_id)
await delete_notification(conn, tg_id, notification_id)
await add_notification(tg_id, renew_notification_id, session=conn)
await delete_notification(tg_id, notification_id, session=conn)
renewed_message = get_renewal_message(
tariff_name=selected_tariff["name"],
traffic_limit=selected_tariff.get("traffic_limit")
if selected_tariff.get("traffic_limit") is not None
else 0,
device_limit=selected_tariff.get("device_limit") if selected_tariff.get("device_limit") is not None else 0,
expiry_date=formatted_expiry_date,
subgroup_title=selected_tariff.get("subgroup_title", ""),
)
logger.info(
f"✅ Ключ {client_id} продлён на {renewal_period_months} мес. для пользователя {tg_id}. Списано {renewal_cost}."
)
keyboard = build_notification_expired_kb()
result = await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
if result:
logger.info(f"✅ Уведомление о продлении подписки {email} отправлено пользователю {tg_id}.")
else:
logger.warning(f"📢 Не удалось отправить уведомление о продлении подписки {email} пользователю {tg_id}.")
renewed_message = KEY_RENEWED.format(
email=email, months=renewal_period_months, expiry_date=formatted_expiry_date
)
keyboard = build_notification_expired_kb()
await send_notification(bot, tg_id, "notify_expired.jpg", renewed_message, keyboard)
except KeyError as e:
logger.error(f"❌ Ошибка форматирования сообщения KEY_RENEWED: отсутствует ключ {e}")
except Exception as e:
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
else:
keyboard = build_notification_kb(email)
await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
except Exception as e:
logger.error(f"❌ Ошибка в process_auto_renew_or_notify: {e}")

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