1464 Commits

Author SHA1 Message Date
Capybara-z 0c7da4b9fd Fix special tariff notifications 2025-11-01 14:53:15 +03:00
Capybara-z ea6538a487 fix sender banning issues 2025-10-25 18:33:39 +03:00
Vladless f7d792963c Resolve conflicts: prefer dev over main 2025-10-25 17:12:56 +03:00
Capybara-z f6eeeb8ea0 fix device limit 2025-10-25 15:29:29 +03:00
Capybara-z 738a72b628 fix text sub 2025-10-25 02:12:31 +03:00
Capybara-z 784e38c345 fix improve server sync filtering / tariff update logic 2025-10-25 02:10:28 +03:00
Capybara-z da44b4e041 Fix Remnawave sync 2025-10-25 00:57:32 +03:00
Vladislav Lisitsyn 12dd8f75fb Revamp README.md with improved formatting and visuals
Updated the README.md to enhance formatting, add images, and improve the overall presentation of SoloBot's features and capabilities.
2025-10-24 18:59:32 +03:00
Vladless 057a71cfce up bot version 2025-10-24 09:47:14 +03:00
Vladless 3c25fb3015 ruff format/ minor fixes and improvements 2025-10-24 09:39:54 +03:00
Capybara-z a573fdc4d4 preserve UUID in Remnawave sync 2025-10-24 02:39:32 +03:00
Capybara-z a6c34beff1 Fix server validation and add key renewal hooks 2025-10-23 21:54:40 +03:00
Capybara-z a256a2f338 fix: add subgroup/special group support to key renewals and cluster sync 2025-10-22 02:07:18 +03:00
Capybara-z e6313bf5a3 Refactor admin panel menu / fix QR code caching 2025-10-21 23:39:15 +03:00
Capybara-z 8fdd2dd3c4 Add router icon / multi-format media support 2025-10-18 00:21:14 +03:00
Vladless 7090daefc5 fix change locations/ delete update_subscription_button/ refactoring the deletion function 2025-10-17 22:23:16 +03:00
Vladislav Lisitsyn d1e739c029 Merge pull request #246 from JustYay/dev 2025-10-17 04:52:02 +03:00
Boris Kovalskii 92c2a1f2c7 FIX "Новые вебхуки" 2025-10-16 23:42:35 +00:00
Boris Kovalskii 0ca56d0967 Add files via upload 2025-10-16 23:41:47 +00:00
Boris Kovalskii 99bd6ce42f Delete web/kassai_payment.py 2025-10-17 09:41:27 +10:00
Boris Kovalskii d012d7a9c4 Delete web/heleket_payment.py 2025-10-17 09:41:17 +10:00
Vladislav Lisitsyn 82fc4a9b33 Merge pull request #245 from JustYay/dev
FIX "Сообщение о минимальном балансе"
2025-10-14 01:06:12 +03:00
Vladless 5a769f8972 optimization of database transactions/fixing middlewares/ cache of images 2025-10-14 01:05:52 +03:00
Boris Kovalskii a1e7db2ac5 FIX "Сообщение о минимальном балансе" 2025-10-13 21:52:01 +00:00
Capybara-z a1347dff04 fix notifications 2025-10-13 19:04:52 +03:00
Vladless 5a4df05b7b aggregated links for update key/ fix router button for country mode 2025-10-13 17:43:53 +03:00
Capybara-z eca7009a55 fix notification message handling 2025-10-13 10:11:26 +03:00
Vladless bdab2360c3 fix country_mode 2025-10-12 21:43:21 +03:00
Vladless 8d80360615 logging_level/ fix subgroup migrations and more 2025-10-12 20:59:18 +03:00
Vladless 50c73821a6 adding servers for special groups/ ruff formatting 2025-10-11 01:27:10 +03:00
Capybara-z f1a5465e36 refactor notifications with configurable timing / native crypto link handling / referral cache optimization 2025-10-10 23:56:34 +03:00
Oleg Kraev 7938a0dc4f Merge pull request #244 from JustYay/dev
FIX "message is not modified"
2025-10-10 08:24:04 +03:00
Boris Kovalskii 24c7adc73c FIX "message is not modified" 2025-10-10 05:19:35 +00:00
Vladless 42f1e81436 changed extension logic / fix: images when creating and back buttons 2025-10-10 03:36:19 +03:00
Vladless 66900c284c Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-10-09 18:37:09 +03:00
Vladislav Lisitsyn 4aa764a126 Merge pull request #242 from JustYay/dev
FIX "Добавление и ответы бота чатах"
2025-10-09 18:28:44 +03:00
Capybara-z 2e866103df add auto-blocking for failed broadcasts and improve send speed 2025-10-09 17:17:21 +03:00
Boris Kovalskii 3d7c76b113 FIX "Добавление и ответы бота чатах"
FIX "Добавление и ответы бота чатах" при обязательной подписке:

Теперь бот будет:
- Проверять подписку только в личных сообщениях
- Игнорировать все сообщения из групповых чатов
- Игнорировать анонимные сообщения от имени сообщества
- Игнорировать сообщения от других ботов
2025-10-09 03:16:11 +00:00
Vladislav Lisitsyn 15e9982530 Merge pull request #241 from JustYay/dev
FIX "Adding a chat for a mandatory subscription"
2025-10-08 18:56:38 +03:00
Vladless 199ea9f153 add pagination for modules menu 2025-10-07 23:19:16 +03:00
Vladless 32a2ea8d13 add logic for back button in payments 2025-10-07 14:45:18 +03:00
Vladless 5d73fd4960 fix Tribute router 2025-10-07 14:07:13 +03:00
Vladless 8f3882819a fix Tribute flow 2025-10-07 13:48:48 +03:00
Boris Kovalskii ebf1b22094 FIX "Добавление чата для обязательной подписки" 2025-10-07 00:12:21 +00:00
Vladless b09bde32e4 Module management/ TRIBUTE webapp/ small gift fix/ backup selection in CLI 2025-10-07 02:48:34 +03:00
Vladless 23e01da0fd fix connection button for router tariffs 2025-10-06 17:37:33 +03:00
Vladless d127624121 adding a fixed exchange rate for the dollar to the ruble/fixing server failures 2025-10-05 13:32:11 +03:00
Capybara-z d073e1a3bd Add hook system for tariff group management 2025-10-04 23:18:27 +03:00
Capybara-z 706d81f4bc fix restore trials logic 2025-10-02 16:02:00 +03:00
Capybara-z f43954cd77 fix encode happ crypt links all versions 2025-10-02 10:45:44 +03:00
Capybara-z dbb2395639 fix encode happ crypt3 links 2025-10-02 01:08:23 +03:00
Vladless e4641a0e86 fix issue template format 2025-10-01 14:12:14 +03:00
Vladless 12a615b5a8 add issue template 2025-10-01 14:05:27 +03:00
Vladless b829d32236 contributing guidelines / code of conduct / security policy / issue template / pr template 2025-10-01 13:31:25 +03:00
Vladislav Lisitsyn 52714536e3 Create CONTRIBUTING.md 2025-10-01 13:02:27 +03:00
Vladless ebdd7e124c fix happ cryptolink for remnawave webapp 2025-10-01 12:24:43 +03:00
Vladless be74a7cadc ruff formatting 2025-09-30 19:19:05 +03:00
Vladless 7a533dd0da link generator/ router and vless support/ selective server billing/ traffic usage counting/ cosmetic improvements and bug fixes 2025-09-30 19:17:05 +03:00
Vladless b5d13beb96 fix stars peyment status/ remove literal for schemas 2025-09-24 00:55:24 +03:00
Vladless e01e7b4d1e add pending for api payments 2025-09-24 00:22:13 +03:00
Vladless d1837bd414 minor fixes/api for payments 2025-09-23 22:41:11 +03:00
Vladislav Lisitsyn 04365dd335 Merge pull request #239 from JustYay/dev
FIX "Приведение касс к одному стилю"
2025-09-21 14:24:54 +03:00
Boris Kovalskii 1b6b2b2956 FIX "Отобжражение в handlers касс" 2025-09-21 04:17:06 +00:00
Boris Kovalskii 353545ce8d FIX "Приведение касс к одному стилю"
- Теперь создаем клавиатуры методами из keyboards
- Язык пользователя берется из БД
2025-09-21 04:10:10 +00:00
Capybara-z 20f83d1a82 fix extend hooks for key creation 2025-09-19 23:55:22 +03:00
Capybara-z c424c41048 extend hooks for key creation 2025-09-19 12:13:04 +03:00
Vladless 81751093d9 fix middlewares, gifts and more 2025-09-19 02:47:40 +03:00
Capybara-z b68b539c68 add extend keyboard hooks / refactor hot leads texts / fix hot leads notification logic 2025-09-18 21:23:58 +03:00
Vladless 17b9e92809 minor fixes for start menu 2025-09-16 23:34:48 +03:00
Capybara-z 4b8bea65be Add hook integration: cluster balancing, subscription routing, tariff menu 2025-09-16 22:09:32 +03:00
Vladislav Lisitsyn c1e26ca1ce Merge pull request #238 from JustYay/dev
FIX "Выбор касс и кнопка возврата"
2025-09-16 21:23:06 +03:00
Boris Kovalskii 425ba29e12 FIX "Кнопка back_to_pay"
Не работала кнопка возврата, для восстановления функцицональности сделано временное решение - возврат на balance
2025-09-16 12:43:30 +00:00
Boris Kovalskii 7cc6ddb4ef FIX "Активные платежки" 2025-09-16 12:38:02 +00:00
Vladislav Lisitsyn ea7310bcb8 Merge pull request #237 from JustYay/dev
Fix Currency rate and Heleket payment
2025-09-16 02:30:34 +03:00
Boris Kovalskii 284d79dd67 FIX "Комментарии 5" 2025-09-15 23:27:23 +00:00
Boris Kovalskii 412a31b938 FIX "Modules в Providers"
Теперь мы можем использовать modules для каждого провайдера, если несколько методов в одной платежке
2025-09-15 23:25:42 +00:00
Boris Kovalskii 1a6450284d FIX "Комментарии 3" 2025-09-15 23:04:27 +00:00
Boris Kovalskii 5e6333d08e Delete handlers/payments/kassai/kassai.py 2025-09-16 08:58:50 +10:00
Boris Kovalskii 3a23883e46 Delete handlers/payments/heleket/heleket.py 2025-09-16 08:58:33 +10:00
Boris Kovalskii b444367887 FIX "Комментарии 2" 2025-09-15 22:57:00 +00:00
Boris Kovalskii 752292cb4a FIX "Приведение Касс к Стандарту бота"
Heleket и KassaAI
2025-09-15 22:53:48 +00:00
Boris Kovalskii b0482cca1a FIX "Добавление быстрого флоу для кастомных касс" 2025-09-15 22:13:57 +00:00
Boris Kovalskii 4e1d7e51a9 FIX "Вернул роутер" 2025-09-15 21:53:14 +00:00
Boris Kovalskii e19e00aa04 FIX "Комментарии" 2025-09-15 21:46:42 +00:00
Boris Kovalskii 45041982d5 FIX "Двойное предложение выбрать Heleket"
Убрал один роутер, и выход сразу на страницу пополнения баланса
2025-09-15 21:45:06 +00:00
Boris Kovalskii c4bafc6c1d FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:43:10 +00:00
Boris Kovalskii 5baf44640d FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:39:07 +00:00
Vladless d6b920ff4d currency/fast flow for everyone/optimization and cache/new payment system/and more 2025-09-15 21:55:31 +03:00
Vladless 6d9e1aba4a Update cli for backup after update 2025-09-11 22:38:47 +03:00
Capybara-z 840e5e4c53 Fix traffic display on iPhones in subscription 2025-09-10 11:33:20 +03:00
Capybara-z e16580857f Fix traffic notifications timing and remove async hooks from gifts/coupons 2025-09-05 21:59:47 +03:00
Capybara-z fc3871fd8e add hooks integration for coupons and gifts keyboard 2025-09-05 18:06:00 +03:00
Vladless b1f0462aab add hooks for about_menu 2025-09-04 10:54:20 +03:00
Vladless 417089cef4 bot version 2025-09-03 23:15:39 +03:00
Capybara-z c08072a87b auto-initialize tariff weights on bot startup 2025-09-03 00:36:11 +03:00
Vladless a10316d215 bug fixes: silent_mode/ cash register indempotence/ antispam for key_country/ ban improvements 2025-09-02 23:38:52 +03:00
Capybara-z 4d3d23ac58 Fix device limit handling in subscription renewal 2025-09-01 21:38:19 +03:00
Capybara-z 9074876e3d Fixed hot leads export count mismatch \ Fixed ban message formatting for popup alerts \ Fixed user deletion back button navigation 2025-09-01 15:36:02 +03:00
Vladless 1487fb2858 add hook for user_info in admin-panel 2025-08-29 11:57:11 +03:00
Capybara-z 16d031d90e fix hooks 2025-08-29 10:35:12 +03:00
Capybara-z 1afa57bff4 fix text hot lead 2025-08-29 00:33:09 +03:00
Capybara-z 05c591336b Improved hot lead logic 2025-08-29 00:06:47 +03:00
Capybara-z cd9b92d036 Removed tblocker / Fixed trial subscriptions counting 2025-08-26 18:26:35 +03:00
Vladless 344a24a669 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-25 02:10:03 +03:00
Vladless a5883e9057 add support for franchise/ minor fixes 2025-08-25 02:08:18 +03:00
Vladislav Lisitsyn e292accccb Merge pull request #235 from TrackLine/dev
fix hook:view_key_menu
2025-08-24 21:50:27 +03:00
Vladislav Lisitsyn ed5426dd71 Merge pull request #236 from myr1001/dev
fix notifications for notifications module
2025-08-24 21:50:04 +03:00
Capybara-z 9b5ab83b1f Expanding the work of hooks 2025-08-24 00:13:40 +03:00
Capybara-z 4795107178 fix instructions 2025-08-23 14:18:35 +03:00
myr1001 379944200b Update kassai_payment.py
fix notofications for notifications module
2025-08-22 15:19:39 +03:00
myr1001 cb8453f415 Update wata_payment.py
fix notifications for notifications module
2025-08-22 15:18:42 +03:00
Capybara-z 69f86fe821 add module hooks to replace profile text / implement interception of gifts via hooks / add dynamic control of buttons in key interfaces / add insert_at button ordering via hooks 2025-08-21 17:00:03 +03:00
Max Shalenkov 099be68d29 fix hook:view_key_menu 2025-08-20 23:11:28 +04:00
Vladless 7ab58d4461 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-19 01:02:24 +03:00
Vladless 9bb95e8189 fixed auto-installation of dependencies 2025-08-18 23:45:55 +03:00
Capybara-z a257e61349 Fix hot leads logic / Add tariff sorting functionality 2025-08-18 21:50:02 +03:00
Vladless 44d6fe87dd minor fixes: utm_api/donations and module payments/users info in admin 2025-08-18 15:55:45 +03:00
Vladislav Lisitsyn b01eabdcda Merge pull request #234 from Vladless/feat/callback-answer
Callback answer middleware
2025-08-18 15:55:24 +03:00
Capybara-z 2a3f346434 add user message hook system / fix SSL certificate validation 2025-08-15 15:44:07 +03:00
Vladislav Lisitsyn 8597eed42d Merge pull request #233 from TrackLine/dev
Add hooks in start menu
2025-08-14 22:05:21 +03:00
Max Shalenkov 070773d28d remove comment 2025-08-14 23:03:30 +04:00
Max Shalenkov e2a6712382 add hooks in start menu 2025-08-14 23:01:24 +04:00
Capybara-z 4ab4eedd04 fix ssl check 2025-08-13 20:43:29 +03:00
Vladislav Lisitsyn e2e73e130e Merge pull request #232 from TrackLine/dev
Add new hooks for modules
2025-08-13 14:59:41 +03:00
Max Shalenkov fa1317a5fb fix imports 2025-08-13 15:58:45 +04:00
Max Shalenkov cb0a155597 add delete hook button; add hook for periodic notifications 2025-08-13 15:52:36 +04:00
Vladislav Lisitsyn 00f24acc4e Merge pull request #231 from TrackLine/dev
add hook in key_view
2025-08-12 21:52:59 +03:00
Max Shalenkov f5b4ab65ea add hook in key_view 2025-08-12 22:51:26 +04:00
Vladless b61c3daca8 Tribute/ Robokassa 2025-08-12 01:19:36 +03:00
hteppl 83d93a833d Fix topups was None in admin panel if payments doesn't exist 2025-08-11 08:56:21 +03:00
Vladless b999e4aced robokassa nomenclature/ minor fixes for API 2025-08-10 23:06:26 +03:00
Vladless 214c9f0c65 fix gifts country_mode tariffs 2025-08-07 17:41:36 +03:00
Vladless 31545a4225 fix gifts/custom amount for wata/change domain 2025-08-07 17:39:25 +03:00
Capybara-z 1441a16474 Add support for streaming modules / Add mailing for trial subscriptions 2025-08-06 23:07:27 +03:00
Capybara-z cec91b711f Add webhook support for modules / Implement hooks for payment keyboard 2025-08-04 20:31:28 +03:00
Vladless f87da6db7a silent mode/ wata flow/ api tracking/ refactoring 2025-08-02 21:13:50 +03:00
Vladless f02a6358e9 minor fixes for gifts_menu 2025-08-02 01:20:46 +03:00
Vladislav Lisitsyn 20c1bd6a75 Merge pull request #230 from TrackLine/dev
fix rates for wata payments
2025-08-01 23:02:31 +03:00
Vladless 254620774e fix delete modules 2025-08-01 22:41:23 +03:00
Capybara-z 3078089044 Location of module buttons 2025-08-01 22:37:37 +03:00
Vladless 9c67c401b9 the idempotence fix 2025-08-01 22:22:09 +03:00
Vladless af79f511e5 module architecture/ delete tg_id in gifts 2025-08-01 22:16:26 +03:00
Max Shalenkov b20ec194c5 fix wata 2025-08-01 19:57:35 +04:00
Max Shalenkov adfb556259 fix rates for wata payments 2025-08-01 19:56:13 +04:00
Capybara-z e9ff8d851e fix for days accrual in cluster 2025-08-01 15:19:26 +03:00
Vladless ac258cda37 module architecture/ moderator restrictions 2025-08-01 01:56:56 +03:00
Vladless 5fc4ce8930 module architecture/ formatting 2025-07-31 23:42:45 +03:00
Vladless 3aa7b1edc5 support for remnawave api 2.x 2025-07-30 20:02:40 +03:00
Vladless 10eca6c6ac minor fixes 2025-07-30 09:49:56 +03:00
Vladislav Lisitsyn b59b3f0e53 Merge pull request #228 from JustYay/dev
minor fix KassaAI
2025-07-30 09:45:46 +03:00
Boris Kovalskii 290ef28a7f minor fix KassaAI 2025-07-30 16:43:55 +10:00
Vladless 66bb7c8d33 minor fixes 2025-07-30 09:23:28 +03:00
Vladless b947a48ca2 zero-day fixes 2025-07-30 06:34:27 +03:00
Oleg Kraev 8dde5acbbc Merge pull request #226 from Vladless/feature/more-user-info
More user info
2025-07-30 00:27:16 +03:00
hteppl 760aab75cd Implement users topups amount in admin user profile 2025-07-30 00:25:00 +03:00
hteppl a6a77238f7 Remove uv.lock due to project currently is not supporting uv 2025-07-29 22:50:06 +03:00
Capybara-z f75b5e8312 refactor: migrate trial subscription to database-driven tariffs 2025-07-29 20:50:43 +03:00
Vladislav Lisitsyn 39701b1f55 Update README.md 2025-07-29 19:53:06 +03:00
Vladless 5f69a36077 formatting 2025-07-29 19:45:13 +03:00
Vladless 2556d3842b up bot version 2025-07-29 19:34:49 +03:00
Vladless ffec36fa54 antispam for create_key 2025-07-29 17:17:15 +03:00
Vladless da6815c5f1 fix gifts tariffs and format days 2025-07-28 20:33:50 +03:00
Capybara-z 76c0c468f5 fix: mass sender broadcasting / tariff group display in admin / correct device limit handling / configurable referral QR button / navigation fixes / fixed traffic notifications / fixed country shuffling 2025-07-27 21:34:04 +03:00
Capybara-z 357c8a01e7 Merge pull request #223 from JustYay/dev
Heleket, Wata, KassaAI fix
2025-07-26 13:50:46 +03:00
Boris Kovalskii 595c312e2b Heleket, Wata, KassaAI fix
Fix, when the payment is lower, than acceptable sum by merchant
2025-07-26 17:03:42 +10:00
Vladless 28435d2943 stars bot url for texts 2025-07-26 00:22:16 +03:00
Vladless 171634522e support invite links/ shadow bans/ bulk preemptive bans 2025-07-25 05:40:45 +03:00
Vladless 424cb50942 fix yoomoney router 2025-07-24 23:40:22 +03:00
Vladislav Lisitsyn 15ce453523 Merge pull request #222 from JustYay/dev
Crypto-payment via Heleket
2025-07-24 23:31:46 +03:00
Boris Kovalskii cfe8b2edcd Crypto payment via Heleket 2025-07-24 23:14:36 +10:00
Boris Kovalskii 3b64b28b86 we dont send new message 2025-07-24 20:25:01 +10:00
Boris Kovalskii 918872e4b4 Force text fix 2025-07-24 20:16:08 +10:00
Capybara-z f21b49d5fd Merge pull request #220 from JustYay/dev
Fix paymentid and success and fail urls
2025-07-24 13:03:43 +03:00
Boris Kovalskii 2bf2d98c88 Fix for old users 2025-07-24 20:01:55 +10:00
Boris Kovalskii 63cc967dfc Fix paymentid and success and fail urls 2025-07-24 19:10:14 +10:00
Vladislav Lisitsyn 9bea1d6a6c Merge pull request #219 from JustYay/dev
Back callback fixes
2025-07-24 07:27:56 +03:00
Boris Kovalskii 97234d8835 Security bug fix
Fix duplicate webhook accept
2025-07-24 13:53:33 +10:00
Boris Kovalskii c5e8917043 Custom amount and deletion fixes 2025-07-24 13:34:07 +10:00
Boris Kovalskii b39ae1a1ee Small fixes 2025-07-24 13:27:28 +10:00
Boris Kovalskii d5190ea07e Clear code 2025-07-24 13:24:21 +10:00
Boris Kovalskii 7f35ec0745 Delete message about payment from Kassa AI
Delete message about payment from Kassa AI after success payment, if the message is available
2025-07-24 13:22:45 +10:00
Boris Kovalskii 16b22101af Back callback fixes
Убрано лишнее меню из касс, при возвращении, сейчас возвращает сразу на вкладку balance
2025-07-24 12:53:49 +10:00
Vladislav Lisitsyn ccdb490b84 Merge pull request #218 from JustYay/dev
Kassa AI
2025-07-24 02:58:53 +03:00
Boris Kovalskii c12e61fb41 Clear code 2025-07-24 09:42:23 +10:00
Boris Kovalskii c877aef711 Kassa AI 2025-07-24 03:10:15 +10:00
Vladless a174b7bd81 save alias after update_sub/db pool 2025-07-21 23:03:03 +03:00
Vladless 43f4657089 minor fixes for install dependencies 2025-07-21 19:38:59 +03:00
Capybara-z 50d2d5f716 Fixed key display in admin panel / Fixed earnings calculation in statistics 2025-07-21 19:29:48 +03:00
Capybara-z 34618a3de1 fix: device limit transmission in admin key creation 2025-07-20 20:17:21 +03:00
Vladless 1b930460ca Animation of the CLI 2025-07-20 06:34:58 +03:00
Vladless fe439cd31d CLI 0.2.7/ fixed permissions 2025-07-20 03:18:51 +03:00
Vladless 81733b041a solobot command for different copies/fixes command installation errors 2025-07-20 03:05:04 +03:00
Vladless 7ac599aa18 importer 3x-ui/CLI update/upload file/clearing logs 2025-07-20 02:12:26 +03:00
Capybara-z 279409600b feat: add update time and refresh button to ads cards 2025-07-20 00:02:53 +03:00
Capybara-z d701ce31c8 fix: tariff selection in admin panel and key operation 2025-07-19 23:16:46 +03:00
Capybara-z 315459ed2e fix: key traffic display 2025-07-19 18:16:02 +03:00
Vladless 0dd3641151 bot version up 2025-07-19 01:29:09 +03:00
Vladless c610fb118e formatting/3x-ui db import/cosmetic fixes 2025-07-19 01:14:42 +03:00
Capybara-z 8f471a1e43 Fix statistics admin 2025-07-19 00:52:20 +03:00
Vladless c7b2f15131 fix state in middleware subs 2025-07-18 20:52:56 +03:00
Vladless 12079cac34 fix sub channel 2025-07-18 20:46:02 +03:00
Vladless 96b283c6cb api 0.2.0/ minor fixes 2025-07-18 20:13:39 +03:00
Capybara-z aeee2ec352 fix: broadcast callbacks and button parsing 2025-07-18 16:05:48 +03:00
Vladless 19c0db3690 stars bot link/ defolt hwid limit 0 2025-07-18 04:32:09 +03:00
Vladless ede74ef7af fix time and text/ cosmetic fixes/ description of the payment/ vat_code 2025-07-18 02:07:30 +03:00
Vladislav Lisitsyn 4607c81e7f Merge pull request #217 from TrackLine/dev
Add wata.pro payments
2025-07-17 20:45:20 +03:00
Max Shalenkov cc70ac8f14 remove comments 2025-07-16 22:49:58 +00:00
Max Shalenkov f7eb475292 final fix wata.pro 2025-07-16 22:22:40 +00:00
Max Shalenkov 38da3cca91 fix wata 2025-07-16 23:59:33 +03:00
Max Shalenkov bf1e454f0c Merge branch 'Vladless:dev' into dev 2025-07-16 23:17:46 +03:00
Vladless 36ea55a10d add change domain for remnawave link 2025-07-16 22:08:20 +03:00
Max Shalenkov 9ee2f0addf change payments 2025-07-16 19:49:49 +03:00
Capybara-z 52aa6f81e2 fix Improve server limits and disabled servers handling 2025-07-14 22:21:04 +03:00
Capybara-z b1c53d5fad Improve server limits and disabled servers handling 2025-07-13 00:52:49 +03:00
Capybara-z 52d5f784a2 Show all inbound nodes with country flags in Remnawave 2025-07-12 02:24:29 +03:00
Capybara-z cafe576478 Fix admin management 2025-07-11 19:27:36 +03:00
Capybara-z 9427cb20d9 Improve cluster tariff error handling / Fix hot leads filtering logic 2025-07-11 14:15:04 +03:00
Capybara-z 68b9092749 fix permanent ban functionality / fix statistics timezone consistency 2025-07-10 14:13:20 +03:00
Capybara-z e3887fa66a Add keyboard support for sending messages 2025-07-09 22:11:40 +03:00
Capybara-z ffd08e726d Fix Gifts API from JustYay
Fix Gifts API
2025-07-04 17:44:36 +03:00
Capybara-z 03444b65ba Improve bot version update message 2025-07-04 17:41:11 +03:00
Capybara-z b817b5f087 Add subscription purchase command/Add subscription renewal command/Improve bot version update message 2025-07-04 17:34:21 +03:00
Boris Kovalskii f93dc40f5f Fix Gifts API 2025-07-04 17:19:06 +10:00
Capybara-z 71c8014be1 update version 2025-07-03 19:57:29 +03:00
Capybara-z 33e44abef0 fix: server deletion when disabled / fix: multiple gifts receiving / add: tariff group info in subscription 2025-06-30 21:48:14 +03:00
Capybara-z e7f91a971b add temporary Remnawave API functions for user import / add UTM code validation in ads creation 2025-06-29 17:27:07 +03:00
Capybara-z 8ee133edc6 Fix SSL certificate validation / Fix tariff sorting for subscription renewals 2025-06-26 00:21:31 +03:00
Vladless 8954d3a756 expiry time for update key in api 2025-06-24 00:35:00 +03:00
Vladless 0640c4f1fd add update key for api 2025-06-23 23:18:09 +03:00
Vladless 2c9680cc8d add verify_admin_token 2025-06-23 22:10:05 +03:00
Capybara-z 9d3c0cb4b1 tariff subgroups use hash callbacks / only ungrouped tariffs when creating / add subgroup management 2025-06-23 20:57:07 +03:00
Capybara-z 6bfa94d9bb Add image for balance/payment menu 2025-06-23 00:52:31 +03:00
Vladless 58cda2e5ff add get_all_by_field method 2025-06-22 21:10:56 +03:00
Vladless 2d4cdb0e35 fix router for freekassa 2025-06-22 15:12:43 +03:00
Capybara-z 1331632bdc Improve trial period display in referral offers 2025-06-22 15:09:52 +03:00
Vladless eadcacdedc Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-22 08:36:02 +03:00
Vladless 855398d186 add freekassa_pay 2025-06-22 08:35:17 +03:00
Capybara-z 8bd09018ec fix: renew key flow with subgroups 2025-06-21 21:52:25 +03:00
Vladless 36b074b209 fix sub/captcha for gifts 2025-06-21 19:54:39 +03:00
Capybara-z 318598026c fix: butons trial profil 2025-06-20 22:38:20 +03:00
Capybara-z 755c85db2d fix: renew proper key selection 2025-06-20 16:54:13 +03:00
Capybara-z 9074eb277d fix: add error handling for HWID 2025-06-20 00:52:08 +03:00
Capybara-z 55cbe579f7 fix: trial menu logic / feat: admin cluster pagination 2025-06-20 00:48:06 +03:00
Vladless d9292840c5 subgroups for gifts in admin-panel/bug fix 2025-06-19 18:12:09 +03:00
Vladless f817110476 add requirements for setup bots 2025-06-19 06:58:21 +03:00
Vladless 4699c6a394 Sync Remnawave panel / import users & keys / restore DB / rename buttons / UI tweaks 2025-06-19 05:09:29 +03:00
Capybara-z 0abb83aae1 feat: add broadcast preview with confirmation / improve referral bonuses (percent and fixed RUB) / unify personal message formatting 2025-06-19 00:11:35 +03:00
Capybara-z d532f1211c Fix balance buttons 2025-06-18 19:03:25 +03:00
Capybara-z e4538ec0f2 Fix renewal buttons in notifications 2025-06-18 18:48:44 +03:00
Capybara-z 157f7454bd Fix 3x-ui key renewal / Add option to disable balance button / New subscription purchase/renewal menu / Add pagination for UTM tags 2025-06-18 18:27:34 +03:00
Vladless d9f6013aa1 add api routes 2025-06-16 17:20:49 +03:00
Vladless a3d8a6233a add subgroups for gifts 2025-06-16 00:09:59 +03:00
Capybara-z 5ecb5b66f0 fix sync, skip frozen subs / improve keys update / fix country switch no tariff / improve unfreeze sub / add back button in admin reset hwid 2025-06-15 23:48:33 +03:00
Vladless c9797f9b26 minor fixes 2025-06-15 22:58:44 +03:00
Vladless 19063b3592 add uvicorn for FastApi 2025-06-14 22:28:21 +03:00
Vladless f0c1f62d62 API/ remnawave 1.6.12/ admins in database and more 2025-06-14 21:16:53 +03:00
Vladless e7410ae776 minor improvements to UTM tracking accuracy 2025-06-14 07:01:42 +03:00
Vladless 606af6f52a make get_git_commit_number work under systemd 2025-06-14 06:30:09 +03:00
Vladless 3f22e20c24 CLI 0.2.0/git number for admins 2025-06-14 05:13:20 +03:00
Vladislav Lisitsyn c2110e7304 Update bot.py 2025-06-14 04:22:16 +03:00
Capybara-z c07e55a86e fix: traffic reset / coupon menu display 2025-06-14 03:45:47 +03:00
Vladless 48e15fbaad subgroups in tariffs 2025-06-14 01:58:29 +03:00
Capybara-z e280a4dc12 fix: tariff name display 2025-06-14 01:01:45 +03:00
Capybara-z 493f01d6aa fix: sync delays / tariff name display 2025-06-14 00:56:22 +03:00
Capybara-z a93855e1a6 fix: correct version check to compare against current branch 2025-06-13 22:54:25 +03:00
Capybara-z a9821aced7 add subscription count display for clusters and servers 2025-06-13 21:03:47 +03:00
Vladless 98055bc1ab add payment class for yookassa 2025-06-13 14:32:33 +03:00
Vladless 801dac4884 add payment class for yookassa 2025-06-13 14:31:10 +03:00
Capybara-z a315222df7 fix the back button in key reissue 2025-06-13 14:06:19 +03:00
Vladless 34feb87b0a fix custom amount for robokassa 2025-06-13 12:09:49 +03:00
Vladless ab04498488 id sorted for renew plans and rm cashback in statistic 2025-06-13 08:58:18 +03:00
Vladless 6f8bc8ad24 fix referrals and tariff name in key view 2025-06-13 08:05:18 +03:00
Vladless 1e6c901e43 add idempotency check to prevent duplicate payments 2025-06-13 07:55:12 +03:00
Vladless 2aade7acf9 add idempotency check to prevent duplicate payments 2025-06-13 04:00:05 +03:00
Capybara-z 9ade45347c add full server management with cluster transfer support 2025-06-12 23:47:18 +03:00
Vladless 1fb7f8d412 gifts for more users in admin-panel 2025-06-11 23:09:23 +03:00
Vladless aabfaf10ee CLI 0.1.9/SSL check for servers 2025-06-11 01:32:41 +03:00
Vladless bbc907ef93 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn e7e7de8115 Merge branch 'main' into dev 2025-06-09 22:45:13 +03:00
Vladislav Lisitsyn a65c38394f Update README.md 2025-06-09 22:43:02 +03:00
Vladless 82ad2ae8e9 daily reports and minor fixes 2025-06-09 21:41:30 +03:00
Vladless 3e251a46ea fix permissions for delete tables 2025-06-09 18:43:45 +03:00
Capybara-z c76c8d6544 fix: android tv connection for remnawave 2025-06-05 19:16:05 +03:00
Capybara-z d61c0dcb30 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-05 01:15:21 +03:00
Capybara-z e2d31525ea add: android tv connection for remnawave 2025-06-05 01:14:20 +03:00
Vladless 4dda834805 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-04 14:58:23 +03:00
Vladless 6a46f7e7ff fix custom amount input for stars pay 2025-06-04 14:58:00 +03:00
Capybara-z 05ee8ff1f4 fix remnawave sync logic
- Исправлена логика синхронизации Remnawave, если обновлять нечего пересоздаем.
2025-06-04 00:52:22 +03:00
Vladless 9f696fa9cd sort tariffs by ID 2025-06-03 03:37:25 +03:00
Vladless 9666947125 use update instead of recreate for Remnawave-only clusters 2025-06-03 02:40:56 +03:00
Vladless 0f6d09c06c fix UTC daily report and back button for bans 2025-06-03 00:35:20 +03:00
Vladless f1e3cb0c05 add cryptobot description 2025-06-02 20:41:58 +03:00
Vladless 20a4c0ab38 fix cashback in stats/back tariff/disable cluster 2025-06-02 20:11:02 +03:00
Capybara-z eef645fbcf hide device count for unlimited tariffs 2025-06-01 02:30:46 +03:00
Capybara-z e00588d468 format tariff duration in messages / format delete delay message for expired subscriptions 2025-06-01 01:30:55 +03:00
Vladless 5ab21c9e8d Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-31 20:44:16 +03:00
Vladless cb45a4d760 up bot version 2025-05-31 20:44:04 +03:00
Capybara-z 7d8d874390 group tariffs and sort by duration 2025-05-31 18:08:52 +03:00
Capybara-z 36107a32c7 group tariffs and sort by duration 2025-05-31 16:48:40 +03:00
Vladless c0dc3dcc18 delete cuopons stats for utm 2025-05-31 15:54:29 +03:00
Vladless df43ea2716 fix remna traffic for create_key 2025-05-30 22:12:16 +03:00
Capybara-z 43af9df078 fix: trial key display / show correct trial days / fix trial limits 2025-05-30 19:07:27 +03:00
Capybara-z 31b9d0abdf fix: admin balance buttons / show tariff in key / show tariff on buy/renew / backup send modes 2025-05-30 17:02:37 +03:00
Vladless 572aa88ee1 rebase subs for country_mode 2025-05-30 16:54:44 +03:00
Capybara-z 3dbec2196a fix: coupon renew uses tariff limits / fix autorenew / fix renew keyboard / show tariff info in key / key renew uses correct tariff / fix start menu display 2025-05-29 22:31:28 +03:00
Vladless 9a5cc083ca Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-29 14:38:53 +03:00
Vladless 2172aa02a8 Preserve images inside the img/ folder during updates 2025-05-29 14:37:22 +03:00
Capybara-z 4316bf2209 Fix auto-renewal logic to properly select tariff during renewal 2025-05-29 00:03:04 +03:00
Vladless 331d140b42 restore hot leads export and stats section 2025-05-28 22:49:59 +03:00
Vladless e67dce82d6 actual commit for admin-menu 2025-05-28 22:14:07 +03:00
Vladless b442e70657 CLI 0.1.8/update tariff menu 2025-05-28 20:48:11 +03:00
Vladless 4fb02063fd fix add time in admins 2025-05-28 19:10:46 +03:00
Capybara-z 96fb83f7f4 fix: key_utils.py 2025-05-28 01:18:58 +03:00
Capybara-z cc3b8a0571 Merge branch 'dev' of fix: key management in admin menu / fixed synchronization 2025-05-28 01:08:16 +03:00
Capybara-z 32fda4e8b5 fix: key management in admin menu / fixed synchronization 2025-05-28 00:44:26 +03:00
Capybara-z cd903944fa tariff lookup for country model
- Определение тарифа для режима стран при продлении ключей.
2025-05-27 00:15:36 +03:00
Vladless 3ab1007178 add commit number to bot version display 2025-05-26 23:11:04 +03:00
Vladless 0e820f62c9 add new payment flow for yoomoney/ add payment for refferals 2025-05-26 22:36:26 +03:00
Vladless 3e33513fe9 add support traffic for trial in cluster mode/country mode 2025-05-26 21:37:00 +03:00
Capybara-z daa9710579 exclude referral and coupon payments from statistics 2025-05-26 19:07:19 +03:00
Capybara-z 6b2f4f6ac5 delete accrual statistics 2025-05-26 12:24:43 +03:00
Vladless 8e44c9755b Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-25 23:56:02 +03:00
Vladless d55fbf0fed add trial limits for create clusters_mode 2025-05-25 23:54:43 +03:00
Capybara-z e05f8f8183 add to accrual statistics
- Добавлено логирование начислений купонов.
2025-05-25 23:38:31 +03:00
Capybara-z 1ae19cd351 add to accrual statistics
- Добавлено логирование начислений реферавлов.
2025-05-25 23:35:29 +03:00
Vladislav Lisitsyn d79deb307e Merge pull request #206 from Capybara-z/patch-8
Fix key expiration notification and auto-renewal handling
2025-05-25 20:38:28 +03:00
Vladless 8f494c0b98 add round for balance and less callback for tariffs 2025-05-25 20:34:16 +03:00
Capybara-z 71803174b0 Fix key expiration notification and auto-renewal handling
- Исправлена обработка уведомлений об истечении ключей.
- Добавлена корректная работа с задержкой удаления ключей и авто-продлением подписок.
2025-05-25 19:47:35 +03:00
Vladless 1c660320da cleanup_orphans_before_migrations 2025-05-25 13:13:18 +03:00
Vladless 014dbd7d73 cleanup_orphans_before_migrations 2025-05-25 12:59:42 +03:00
Vladless 22e0d610a7 cleanup_orphans_before_migrations 2025-05-25 12:49:38 +03:00
Vladless d4f77df197 cleanup_orphans_before_migrations 2025-05-25 12:36:40 +03:00
Vladless 0f7a9e58d8 fix back button in view tariff groups 2025-05-24 23:41:25 +03:00
Vladless 9ae2de2eb5 fix key create for user in admin 2025-05-24 23:25:49 +03:00
Vladless 1309c3ef43 minor fixes 2025-05-24 22:32:15 +03:00
Vladless 0edadd9592 fix gift tariff name 2025-05-24 22:01:32 +03:00
Vladless cc403fac67 session for renew_key 2025-05-24 21:53:15 +03:00
Vladless faecb7fb13 trial user in check_notification 2025-05-24 21:31:53 +03:00
Vladless 32cc047943 session in process_callback_view_profile 2025-05-24 20:49:19 +03:00
Vladless d4d2ff1867 ORM update and lots of improvements 2025-05-24 19:30:23 +03:00
Vladless d2d6ba1be9 key column null for table keys 2025-05-20 23:53:49 +03:00
Vladless 96905c0f51 sorted tariffs by id for renew key 2025-05-20 18:23:46 +03:00
Vladless fe2c70ab75 delete tarif_group for servers 2025-05-20 16:12:41 +03:00
Vladless 1174a3ff34 convert balance column 2025-05-20 13:21:31 +03:00
Vladless e66a465eee group by id 2025-05-20 13:04:37 +03:00
Vladless d6ec510055 fix add_notification for blocked users 2025-05-20 12:35:54 +03:00
Vladless 29bd962d49 balance numeric in database 2025-05-20 09:06:47 +03:00
Vladless 8fb8e203ae sudo python3 main.py 2025-05-20 08:55:18 +03:00
Vladless 78cfd35ad8 add tariff_group in get_servers 2025-05-20 08:45:17 +03:00
Vladless e6429fda9d fix delete tariff 2025-05-19 17:15:38 +03:00
Vladless d491178410 add group_code 2025-05-19 14:17:35 +03:00
Vladless b3b21d1f5f minor improvements 2025-05-19 11:08:51 +03:00
Vladless e8030c1db9 discounts tariffs/notify hot leads/change success payment 2025-05-19 09:07:32 +03:00
Vladless a1f0449e5c bot version 2025-05-19 06:03:36 +03:00
Vladless fd3e26dd47 tariff id in callback 2025-05-19 06:03:03 +03:00
Vladless ae39b6df9a statistic tariffs/database logs 2025-05-19 05:41:19 +03:00
Vladless cbd2f58499 ban middleware/ ban users 2025-05-19 03:59:26 +03:00
Vladless 8ea78943fe add daily project report 2025-05-18 00:44:17 +03:00
Vladless 8bd904af6c tariffs view in cluster menu 2025-05-18 00:27:39 +03:00
Vladless b3369d8708 tblocker router 2025-05-17 20:54:23 +03:00
Vladless 4af368549e tariff_id for payments 2025-05-17 20:44:43 +03:00
Vladless 07bbd9b177 data formatted in renew 2025-05-16 20:27:48 +03:00
Vladless 11898a5d13 fix gifts user_data 2025-05-16 18:37:41 +03:00
Vladless e662e0d06e tariffs order by id 2025-05-16 15:46:12 +03:00
Vladless dc2e1158fa ip limit for tariffs 2025-05-16 10:06:26 +03:00
Vladless 109df2354c add tariffs stats 2025-05-16 08:51:43 +03:00
Vladless 4d337e3df5 add device_limit in tariffs 2025-05-16 07:09:18 +03:00
Vladless 9a042c638a tariff customization/traffic for each tariff/ruff 2025-05-16 06:02:18 +03:00
Vladless d520f02c03 add alias for key menu in admin-panel 2025-05-13 06:45:56 +03:00
Vladless 0581327358 add more hwid stats 2025-05-13 04:36:06 +03:00
Vladless 0883cf2bf6 add more stats 2025-05-13 04:32:36 +03:00
Vladless 6067eca053 hwid management in admin-panel and key menu 2025-05-12 23:16:56 +03:00
Vladislav Lisitsyn 48d7e197a1 Merge pull request #205 from Capybara-z/patch-7
Add tblocker blocking notifications
2025-05-12 23:04:28 +03:00
Capybara-z 961514d712 Add tblocker blocking notifications
- Логика обработчки оповещений на вебхук от tblocker
2025-05-12 22:12:01 +03:00
Capybara-z 97bfc1bc43 add tblocker webhook handler
- Добавлен обработчик вебхука tblocker
2025-05-12 22:05:36 +03:00
Oleg Kraev b3b15db8c0 Merge pull request #204 from Capybara-z/patch-6
add sub info
2025-05-12 02:46:05 +03:00
Vladless ce275babc3 4.3-beta 2025-05-11 19:23:27 +03:00
Vladless 3aac6b9872 add support HWID 2025-05-11 16:55:19 +03:00
Oleg Kraev 8813664f3f Update README.md 2025-05-11 16:53:22 +03:00
Capybara-z ebd50f8b3d add sub info
- добавил хедер v2raytun для отображения травика и даты завершения подписки.
2025-05-11 16:14:35 +03:00
Vladless 62fccf2b10 add support remnawave api 1.6.x 2025-05-11 16:12:51 +03:00
Vladless 943027f17f back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless ea0a3dc962 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 2d2a11e5b6 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 55fe1342cf Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn 105f798c26 Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z 944295165d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 18be0049cc fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 26a32b6f07 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn f477094614 Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 367331d113 add state.clear 2025-05-09 20:35:50 +03:00
Vladless 51d40323e5 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 5ddf7f8242 ruff formatting 2025-05-09 01:34:11 +03:00
Vladless e4a73f9aaa set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless b99c090a84 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 15ad1a0f79 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 344e4757ef oops 2025-05-08 17:19:00 +03:00
hteppl 55461aa50b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 24928fe561 Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn f16084b941 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn caeb531445 Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn fefbf4da39 Update README.md 2025-05-08 02:36:49 +03:00
Vladless cd3c19b79d 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 6cd1dd8bc2 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn 3837f47bb6 Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z bff7350224 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless cd4c526da0 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn 38e931446a Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z 55af3bde3d fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn 6bca31f92c Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z f8858e8561 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z d3c729cdc2 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z 4c954409cd Update notification 2025-05-05 21:39:20 +03:00
Capybara-z b2dbdb60b6 Update notification 2025-05-05 21:38:11 +03:00
Oleg Kraev a26d334653 Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless ef1cf1fe26 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless 1356170fb0 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 998cf9a6e1 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 85a6be831e Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 9df5d8469d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn 3133cc7799 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 7b894300b7 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 2621d29ef0 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 76ad4e5821 fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 0056454375 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 3c15589b03 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 4849c899c2 fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless 44b9986a45 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless a9359e9dc0 update version 2025-04-26 23:30:24 +03:00
Vladless 76e730475d get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless 18d3b4246a add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 22121b9171 ruff formatting 2025-04-24 23:20:18 +03:00
Vladless ff498feb7e scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 15fcc0ac70 Update README.md 2025-04-23 22:29:54 +03:00
Vladless b1c73dc1a6 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless 888812babd fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless 023cd1728d update user middleware 2025-04-22 19:21:15 +03:00
Vladless abbd3474b4 update table for utm 2025-04-22 00:41:34 +03:00
Vladless fa17faa424 update version 2025-04-21 22:04:07 +03:00
Vladless 799c657605 refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless fa875fb3bc CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless cc628a08fa CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless fa85fb70f1 hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless 1d5980d725 rm old logic 2025-04-21 01:46:19 +03:00
Vladless 9474ccfb06 refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn c6e91c0543 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn 593206f6a0 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z ea6f5297c1 Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 471714b80d v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn a92a19b84d Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 289e0ae5b5 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless d08b410888 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless 455455d696 beta version 2025-04-18 05:01:47 +03:00
Vladless 57ad26711d fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 24428a030e add column in table servers 2025-04-18 01:34:00 +03:00
Vladless c42fee4298 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless aba9620c0f limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless dd8c87f887 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless eebc2ee1d5 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless 7bd5ecc55f remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 5d25e211df Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z 0887e28b2b Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z e04be209cb Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z d19eee8897 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 3efdeed00c add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless 9e89a2db10 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 59a23ea9bd Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z 216c8f8d81 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn d0f6511c5c Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z e1ecb6a1dc Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless 99c02874ec CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 0cd896d35e gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless ab35ce9178 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless f71e6eb226 add import renew 2025-04-10 15:02:46 +03:00
Vladless cd3b55271a add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl e71de5f3cf Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless 0fc2c535de Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless aef1eb2861 total_gb fix 2025-04-09 23:13:43 +03:00
Vladislav Lisitsyn b57d79d65c Update version 2025-04-09 20:03:46 +03:00
Vladless d9533e673e refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
Vladless 3e452c4c1b exclude img 2025-04-09 16:08:59 +03:00
Vladless 31582080c6 bash -c in cli-interface 2025-04-08 14:43:46 +03:00
Vladless d85dad2a9e version in cli-interface 2025-04-08 14:34:16 +03:00
Vladislav Lisitsyn 7f15e208e1 Merge pull request #184 from Capybara-z/patch-11
Fix for coupon activation
2025-04-07 02:17:43 +03:00
Capybara-z ae291e2ae7 Fix for coupon activation
- Корректная обработка активации купонов.
2025-04-07 02:04:01 +03:00
Capybara-z 7fd15da4a6 Fix for coupon activation
- Корректная передача состояний и текста в купоны.
2025-04-07 02:03:11 +03:00
Vladless 4fdbaca7e8 yoomoney webapp 2025-04-07 00:22:15 +03:00
Vladless 54180c6b28 change callback in add_key 2025-04-06 21:44:09 +03:00
Vladless b7db9be638 cli-interface 2025-04-06 21:19:33 +03:00
Vladless 76d6476ea7 cli-interface 2025-04-06 21:15:59 +03:00
Vladislav Lisitsyn 70867dccec Merge pull request #183 from Capybara-z/patch-10
Add server availability check and cluster-based country selection
2025-04-06 01:59:25 +03:00
Capybara-z dd7144cd5b Add server availability check and cluster-based country selection
- Добавил проверку доступности серверов перед отображением списка стран для выбора.
- Сделал пропуск удаления ключа при недоступности сервера, чтобы смена страны не останавливалась.
- Обеспечил вывод только рабочих серверов при создании ключа.
- Улучшил выбор стран, сделав его корректным согласно кластерам пользователя.
- Исправил пересоздание клиента, обновляя только server_id при смене страны.
2025-04-06 00:30:20 +03:00
Vladless 29f54a408d add data_state 2025-04-05 20:48:13 +03:00
Vladless bff7d3ede0 on/off manual_instructions/fix send_photo 2025-04-05 02:16:24 +03:00
Vladislav Lisitsyn 2a5a254cae Merge pull request #180 from Capybara-z/patch-9
Фикс ошибок и создание ключей
2025-04-05 02:03:28 +03:00
Vladislav Lisitsyn f7db7f445b Merge pull request #179 from Capybara-z/patch-8
Исправление.
2025-04-05 02:02:43 +03:00
Capybara-z ec9016f958 Перенос обратобтки состояний после создания ключа
- Перенос оплат и списания триала после создания ключа
2025-04-05 00:46:24 +03:00
Capybara-z d73a765866 Фикс ошибок сервера
- Исправлен сбор ошибок сервера
2025-04-05 00:44:45 +03:00
Capybara-z eb70e38a02 Исправление.
- Убрал лишнию проверку.
2025-04-04 23:38:14 +03:00
Vladislav Lisitsyn b587cf527e Merge pull request #177 from Capybara-z/patch-6
Добавление активности юзера, мелкие правки
2025-04-03 22:31:54 +03:00
Capybara-z a8ce3f65ee Добавление формулы склонения.
- Добалвение формулы склонения
- Адаптация клонение дней к новой формуле.
2025-04-03 22:17:19 +03:00
Capybara-z 5200e25e88 Корректировка текста
- Скорректировал текст, при смене названия синхронизация не нужна.
2025-04-03 11:32:05 +03:00
Capybara-z e676213102 Функция склонения дней.
- Добавлена функция склонения дней.
2025-04-03 10:26:47 +03:00
Capybara-z 1f56820ce5 Склонение дней
- Склонение дней
2025-04-03 10:25:58 +03:00
Capybara-z 58937a405d Склонение дней
- Склонение дней
2025-04-03 10:25:39 +03:00
Capybara-z b7d13576b8 Добавлено подверждение удаления
- Добавил подверждение удаления
- Поправил удаление купонов.
2025-04-03 09:58:23 +03:00
Capybara-z 0828bca2ed Правка под дни 2025-04-03 09:57:39 +03:00
Capybara-z c1dc10e5ed Купоны по дням
- Купоны теперь можно выдать днями
- Добавил поделиться днями через инлайн мод.
2025-04-03 02:31:54 +03:00
Capybara-z 50eba2d093 Перенесана логика купонов
- Логика купонов перенесена в свое место.
2025-04-03 02:06:28 +03:00
Capybara-z ea6d9d300f Корректировка работы с купонами.
- Скорректировал работу с купонами так как ранее работа была напрямую из start
- Скорретировал работу с купонам под новый столбец days
2025-04-03 02:05:36 +03:00
Capybara-z a06ee6f395 Добавлено создание столбца
- Добавил создание столбца в купонах days
2025-04-03 02:04:07 +03:00
Capybara-z 86e678de08 Добавлена возможность делиться купоном
- Добалена возможность делиться купоном при инлайн режиме.
2025-04-02 23:07:05 +03:00
Capybara-z 772a677424 Отображение пробной подписки
- Добавлено отображение пробной подписки если доступна в профиле.
2025-04-02 21:24:49 +03:00
Capybara-z cccb963da2 Дата регистрации/активности
- Добавлена стата даты регистрации/активности
2025-04-02 21:12:23 +03:00
Vladislav Lisitsyn 2b3ce6ce55 Merge pull request #176 from Capybara-z/patch-5
Смена версии
2025-04-02 03:01:10 +03:00
Capybara-z 1c119b1d7a Смена версии
- Смена бета версии
2025-04-02 02:50:41 +03:00
Vladislav Lisitsyn 4043380411 Merge pull request #175 from Capybara-z/patch-4
Фикс активных юзеров и улучшения управления кластерами/серверами
2025-04-02 02:29:48 +03:00
Capybara-z c3d71bdc28 Доработка сообщений
- Добавлены напоминание синхронизации.
2025-04-02 02:08:09 +03:00
Capybara-z dc6f73c539 Логика переноса ключей
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:43:04 +03:00
Capybara-z 476a8cb842 Добавление логики переноса
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:42:12 +03:00
Capybara-z bc81c64cbe Корректировка сообщений
- Удалил ненужное
2025-04-02 01:41:11 +03:00
Capybara-z 812264722a Добавление QR-кода
- Добавлен QR-код для реф ссылки.
2025-04-02 00:13:13 +03:00
Capybara-z 0d06f61d40 Доп стата
- Стата за прошлый месяц
2025-04-01 23:55:27 +03:00
Capybara-z 2634facd51 Добавление логики смены названий
- Добавлена логика смены названия кластера/сервера
- Сделана проверка на наличие ключей с названием кластера/сервера и смены на новое название
2025-04-01 23:40:35 +03:00
Capybara-z a92b6d1e86 Добавление кнопки
- Добавлена кнопка смены названия кластера
2025-04-01 23:35:19 +03:00
Capybara-z 4cfbcd8590 добавление кнопки
- добавление кнопки смены названия сервера
2025-04-01 23:34:12 +03:00
Capybara-z 73ba081779 фикс активации купона
- Корректировка состояния активации купона
2025-04-01 22:10:40 +03:00
Vladislav Lisitsyn 3aa6c26940 Merge pull request #174 from Capybara-z/patch-3
Фикс имени подписки и экранирование в подписках
2025-04-01 15:46:35 +03:00
Capybara-z dc8ff4a007 Фикс html разметки
- Добавил экранирование
2025-04-01 15:45:37 +03:00
Capybara-z 6390a18327 Фикс имени подписки
- Поставлены ограничения на символа ввода
- Поравлен текст.
2025-04-01 15:25:07 +03:00
Vladless a5d9dad826 fixing bugs 2025-04-01 02:37:52 +03:00
Vladislav Lisitsyn 06e6b30d31 Merge pull request #173 from Capybara-z/patch-2
Добавление отдельного меню Windows/macOS и функции личного рейтинга.
2025-04-01 00:11:35 +03:00
Capybara-z f9e404a7c2 правка текста
-правка текста
2025-04-01 00:01:44 +03:00
Capybara-z 3920992cee Выбор win/macOS
- Сделан выбор win/macOS
- Сделаны текста для них отдельные.
2025-03-31 22:08:27 +03:00
Capybara-z fcb6dd9773 Часть текста перенесана
- Часть текста перенесена CHOOSE_DEVICE_TEXT
2025-03-31 22:04:33 +03:00
Capybara-z 6d42647bf6 Добавление новых кнопок
-  Добавлены кнопки скачать win/macos
- Добавлены кнопки выбора устройств win/macos
2025-03-31 22:01:59 +03:00
Capybara-z 6e3b33e335 Добавление личного рейтинга
-Добавление личного рейтинга вне зависимости от топа.
2025-03-31 00:57:13 +03:00
Vladless 063ade1a2a add user in profile 2025-03-30 00:29:44 +03:00
Vladless 56ffbd654e add new menu in country mode 2025-03-29 22:27:26 +03:00
Vladless 47777c7ee8 fix QRCode, False DISCOUNTS and name profile 2025-03-29 21:57:36 +03:00
Vladless 27d021171e add back button 2025-03-29 12:03:56 +03:00
Vladless d4b00fb494 format text 2025-03-29 02:15:52 +03:00
Vladless 7816fa535c add pillow in requirements 2025-03-29 01:18:59 +03:00
Vladless 0208c48212 add qrcode in requirements 2025-03-28 22:19:25 +03:00
Vladless 4295e4b5ec ruff, button file, castomize ping and backup 2025-03-28 20:39:06 +03:00
Vladless 4fb13a089a reorganizing the menu, reissuing the key, improving statistics, and more 2025-03-28 17:44:14 +03:00
Vladless f1841caea7 rebase api file 2025-03-27 12:52:23 +03:00
Vladless 4a67f9233d customizing the menu, resetting traffic, and creating a key from the admin panel 2025-03-27 04:39:05 +03:00
Vladless e55a89af8e fix admin and add time in cluster 2025-03-27 03:04:21 +03:00
Vladless 1f172f1a21 rename sub and reorganization menu 2025-03-27 02:01:39 +03:00
Vladless 3f7bcb090a notify keyboards in folder 2025-03-26 23:54:36 +03:00
Vladless a3b049dc1f fix after merge 2025-03-26 16:41:43 +03:00
hteppl bf2d225c55 Fix keyboards for users_handler and make data optional for AdminClusterCallback 2025-03-26 02:49:04 +03:00
Vladislav Lisitsyn 33b15b8486 Merge pull request #172 from Vladless/feature/admin-optimize
Admin optimize
2025-03-26 02:37:00 +03:00
Oleg Kraev 19a273baf4 Merge pull request #171 from Vladless/upstream-170
Upstream
2025-03-26 02:06:19 +03:00
hteppl 1c205f4a24 Upstream 2025-03-26 02:05:24 +03:00
hteppl f2a47de5da Fix packages imports 2025-03-26 02:00:28 +03:00
hteppl 372933e230 Implement untrial type for sender 2025-03-26 01:59:52 +03:00
hteppl 11236ac64e Implement separate sync logic 2025-03-26 01:58:59 +03:00
hteppl 46f71a7a6e Update init routing for handlers 2025-03-26 01:11:54 +03:00
hteppl 70293fe387 Fix back button in sender + add broadcast start text 2025-03-26 00:19:24 +03:00
hteppl c7915a45b8 Fix some back buttons 2025-03-26 00:16:14 +03:00
Vladislav Lisitsyn 8b016c7010 Merge pull request #169 from izzzzzi/main
Исправление сравнения tg_id в обработчике подписок.
2025-03-25 20:21:01 +03:00
izzzzzi 17032811a4 Исправление сравнения tg_id в обработчике подписок. Приведение типов к int для корректной проверки идентификаторов. 2025-03-25 22:00:54 +05:00
hteppl ff591fc387 Optimize and fix imports 2025-03-25 17:42:53 +03:00
Oleg Kraev ece35bb90a Merge pull request #168 from Vladless/upstream-168
Changes from upstream
2025-03-25 17:34:54 +03:00
Oleg Kraev dfec3c5748 Merge branch 'feature/admin-optimize' into upstream-168 2025-03-25 17:34:46 +03:00
hteppl 29ecb61ad2 Make clusters 2 in a row 2025-03-25 17:27:25 +03:00
hteppl 79fd3cde5d Do not use clusters_handler for handling management actions 2025-03-25 17:21:05 +03:00
hteppl 5f4a23e808 Trying to split cluster and server manage logic 2025-03-25 17:12:47 +03:00
hteppl 3d10e2c1fe Code optimize by ruff 2025-03-25 16:54:02 +03:00
hteppl 6b8507253e Improve clusters and servers manage texts + fix input len checks 2025-03-25 16:52:38 +03:00
Vladislav Lisitsyn 90d9c0bc54 Merge pull request #167 from Capybara-z/patch-1
Standardization of texts in a separate file.
2025-03-25 16:39:07 +03:00
hteppl 71301f90ae Update AdminPanelCallback __init__ as in super().__init__ 2025-03-25 16:32:31 +03:00
hteppl 472a3ad69d Use ruff formatting 2025-03-25 16:30:44 +03:00
hteppl 5cd63bc89d Move admin keyboards to their own packages 2025-03-25 16:28:08 +03:00
hteppl 77bd87a2b5 Rename classes + implement own packages for all handlers 2025-03-25 16:22:52 +03:00
Capybara-z 53483ce6aa Вынос текста
Вынесенно:
- NO_SUBSCRIPTIONS_MSG
- FROZEN_SUBSCRIPTION_MSG
- UNFREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_UNFROZEN_MSG
- FREEZE_SUBSCRIPTION_CONFIRM_MSG
- SUBSCRIPTION_FROZEN_MSG
- DELETE_KEY_CONFIRM_MSG
- KEY_DELETED_MSG_SIMPLE
- INSUFFICIENT_FUNDS_RENEWAL_MSG
2025-03-25 15:24:39 +03:00
Capybara-z a9e546091a Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:24:07 +03:00
Capybara-z 924b5e15c5 Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:16:33 +03:00
Capybara-z ae9b5b1bf7 Вынос текста
Вынесенно:
- TRIAL_INACTIVE_FIRST_MSG
- TRIAL_INACTIVE_BONUS_MSG
- ZERO_TRAFFIC_MSG
2025-03-25 15:15:25 +03:00
Capybara-z adb669fa9e Вынос текста
Вынесенно:
- KEY_DELETED_MSG
- KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG
- KEY_EXPIRED_DELAY_HOURS_MSG
- KEY_EXPIRED_DELAY_MINUTES_MSG
- KEY_EXPIRED_NO_DELAY_MSG
- KEY_RENEWED_TEMP_MSG
2025-03-25 15:11:42 +03:00
Capybara-z 2daf2fadc8 Вынос текста
Вынесенно:
- CAPTCHA_PROMPT_MSG
2025-03-25 15:11:10 +03:00
Capybara-z c9b0cd2db1 Вынос текст
Вынесенно:
- COUPON_INPUT_PROMPT
- COUPON_NOT_FOUND_MSG
- COUPON_ALREADY_USED_MSG
- COUPON_ACTIVATED_SUCCESS_MSG
2025-03-25 15:10:46 +03:00
Capybara-z 098459f3ce Вынос текста
Вынесенно:
- PAYMENT_METHODS_MSG
2025-03-25 15:10:23 +03:00
Capybara-z 64d25967c9 Вынос текста
Вынесенно:
- BALANCE_MANAGEMENT_TEXT
- BALANCE_HISTORY_HEADER
- INVITE_TEXT_NON_INLINE
2025-03-25 15:10:04 +03:00
Capybara-z 5804a8e87a Вынос текста
Вынесенно: 
- COUPON_SUCCESS_MSG
- SUBSCRIPTION_REQUIRED_MSG
- NOT_SUBSCRIBED_YET_MSG
- SUBSCRIPTION_CONFIRMED_MSG 
- SUBSCRIPTION_CHECK_ERROR_MSG
- GIFT_ALREADY_USED_OR_NOT_EXISTS_MSG
2025-03-25 15:08:56 +03:00
Vladless 388fbb5afc add_refferal_stats 2025-03-23 19:44:11 +03:00
Vladless d3e502ce43 minor bug fixes and improvements 2025-03-21 09:26:30 +03:00
Vladless af497f644a frozen_subs/restore_all_trial and more 2025-03-12 18:12:29 +03:00
Vladislav Lisitsyn e57680f3a7 Merge pull request #166 from Vladless/revert-165-main 2025-03-12 10:24:09 +03:00
Vladislav Lisitsyn c7eff5cc3e Revert "Добавление нового middleware и рефакторинг" 2025-03-12 10:23:51 +03:00
Vladislav Lisitsyn 3ccbf00259 Merge pull request #165 from izzzzzi/main
Добавление нового middleware и рефакторинг
2025-03-11 23:44:51 +03:00
izzzzzi 6066775cf4 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-03-08 04:59:32 +05:00
izzzzzi 92ec071e6b Добавление нового middleware и рефакторинг обработчиков с поддержкой target_message 2025-03-08 04:59:23 +05:00
Oleg Kraev 8e070349ba Merge pull request #163 from izzzzzi/main
Рефакторинг и оптимизация кода
2025-03-07 16:13:02 +03:00
Oleg Kraev 80360691f2 Merge pull request #164 from Vladless/fix/readme
Move preview gifs into .github folder
2025-03-07 15:46:08 +03:00
hteppl 2f0c22abe8 Move preview gifs into .github folder 2025-03-07 15:44:57 +03:00
izzzzzi 9c7cbe403b Рефакторинг и оптимизация кода: улучшение импортов, форматирования и обработки уведомлений 2025-03-07 06:16:24 +05:00
Vladless fcdfd3a484 fix refferal system 2025-03-07 00:51:02 +03:00
Vladislav Lisitsyn 8f1ceb4e66 Merge pull request #162 from izzzzzi/main
Добавлена обработка исключений в асинхронных задачах
2025-03-04 16:09:25 +03:00
izzzzzi 2db29fba9d Добавлена обработка таймаутов с использованием httpx в клиентских операциях 2025-03-04 01:23:54 +05:00
izzzzzi d0125c1f33 Добавлена обработка исключений в асинхронных задачах с помощью return_exceptions=True 2025-03-04 00:57:41 +05:00
Vladislav Lisitsyn 0dba6a6e42 Merge pull request #161 from izzzzzi/main
Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
2025-03-03 19:27:02 +03:00
Vladless 02fbf89d09 version 4.0 2025-03-02 21:53:20 +03:00
Oleg Kraev 305fbde885 Update README.md 2025-03-02 16:39:09 +03:00
Vladless 3d94037d4e version 4.0 2025-03-02 15:31:47 +03:00
Vladless 4a5aae06c3 add screening username 2025-03-02 12:33:52 +03:00
Vladislav Lisitsyn 19e476aa3f Update README.md 2025-03-02 03:23:07 +03:00
Vladless 5c62ed8d92 Added an extension check 2025-03-02 02:50:12 +03:00
Vladislav Lisitsyn f138572b38 Update README.md 2025-03-01 12:11:23 +03:00
Vladislav Lisitsyn f907906d04 Update README.md 2025-03-01 02:17:41 +03:00
Vladless 097ce51b2c add preview_2 2025-03-01 02:15:57 +03:00
Vladless 1fa553f246 add preview 2025-03-01 00:21:15 +03:00
Vladless 32c65b3194 up bot version 2025-02-28 22:44:39 +03:00
Vladless a4d11f67fa fix captha and stars 2025-02-28 20:30:04 +03:00
Vladless 1775d90bab fix Hiddify subscription 2025-02-26 12:46:33 +03:00
izzzzzi 1d6ae5fa07 Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
- Значительное упрощение middleware для ограничения частоты запросов
- Унифицирован механизм троттлинга с единым кэшем
- Добавлена поддержка уведомлений о превышении лимита запросов для CallbackQuery
- Удалены избыточные проверки и сложная логика
- Сокращено количество параметров инициализации
2025-02-26 02:33:26 +05:00
Vladless fa2d3eaa0a minor bug fixes 2025-02-25 22:06:44 +03:00
Vladless 148e99ea6f minor improvements 2025-02-25 20:19:41 +03:00
Vladless 64f99ac660 up bot version 2025-02-25 20:10:26 +03:00
Vladless bded99263a refactoring and minor improvements 2025-02-25 19:56:42 +03:00
Vladislav Lisitsyn aded5606a8 Merge pull request #160 from izzzzzi/main
Рефакторинг и оптимизация обработки подписок
2025-02-25 18:43:14 +03:00
izzzzzi 79982f5aa6 Рефакторинг и оптимизация импортов в модулях проекта
- Упорядочивание и группировка импортов в соответствии с PEP 8
- Удаление неиспользуемых импортов
- Перемещение локальных импортов в начало списка
- Добавление пустых строк для улучшения читаемости
- Систематизация порядка импортов в различных модулях проекта
2025-02-25 04:14:58 +05:00
izzzzzi dc27a6e69b Рефакторинг и улучшение модуля резервного копирования базы данных
- Добавлена расширенная типизация и аннотации типов
- Улучшена обработка ошибок и логирование в функциях бэкапа
- Оптимизирован процесс создания и очистки резервных копий с использованием pathlib
- Добавлены информативные docstring комментарии
- Усовершенствована отправка бэкапов администраторам с обработкой исключений
- Использованы современные подходы к работе с файловой системой и переменными окружения
2025-02-25 02:51:02 +05:00
izzzzzi 8af59ffbfe Улучшение обработки пользователей и middleware с возвратом данных
- Модифицирована функция upsert_user для возврата полных данных пользователя
- Обновлен UserMiddleware для сохранения данных пользователя в контексте
- Расширена функциональность register_middleware с поддержкой опционального исключения middleware
- Добавлена типизация и улучшена обработка middleware
2025-02-25 02:46:59 +05:00
izzzzzi 013b26ecc8 Оптимизация и улучшение middleware и обработки сессий базы данных
- Рефакторинг middlewares с добавлением типизации и улучшенной логики
- Оптимизация SessionMiddleware с использованием пула соединений
- Улучшение обработки пользователей и логирования в UserMiddleware
- Расширение функциональности ThrottlingMiddleware с более гибким управлением
- Добавление дополнительных проверок и логирования в AdminMiddleware
- Оптимизация функции upsert_user с поддержкой передачи существующей сессии
2025-02-25 02:39:44 +05:00
izzzzzi 7df831c084 Рефакторинг и оптимизация обработки подписок
- Значительное улучшение структуры кода в subscriptions.py
- Добавлены типизация и аннотации типов
- Разделение логики на отдельные функции с четкой ответственностью
- Улучшена обработка часовых поясов с использованием pytz
- Оптимизирована логика получения и обработки подписок
- Добавлены информативные логи и комментарии
2025-02-25 01:00:41 +05:00
Vladless 507e0b5e1d refactoring and fix refferal_functions 2025-02-23 23:04:08 +03:00
Vladless f2a12b8d98 fix country_mode 2025-02-23 17:03:31 +03:00
Vladless 1d089cce7d add_commands: coupon/invite 2025-02-22 19:50:53 +03:00
Vladislav Lisitsyn ba89c054fd Merge pull request #159 from izzzzzi/main
Refactor inline referral handler and admin stats time display
2025-02-22 14:12:53 +03:00
izzzzzi 33801f58c4 Refactor inline referral handler and admin stats time display
- Simplified inline referral handler by removing unnecessary try-except block
- Increased cache time for inline query results
- Added Moscow timezone for admin stats update time display
2025-02-22 14:27:56 +05:00
Vladislav Lisitsyn a963f4ad12 Merge pull request #158 from Vladless/feature/time-in-admin-stats
Время обновления в главном меню стастистики Администратора
2025-02-22 11:27:41 +03:00
hteppl b8472ee428 Add update_time in admin stats main page 2025-02-22 11:22:56 +03:00
Vladless 769f5eaf9a minor improvements 2025-02-21 22:36:03 +03:00
Vladislav Lisitsyn c9e791fa76 Merge pull request #157 from Capybara-z/patch-9
Оптимизация subscriptions.py
2025-02-21 17:33:16 +03:00
Capybara-z a32d9d422f Конвертатор байтов
- Добавлен конвертатор байтов.
2025-02-20 20:07:58 +03:00
Capybara-z 39baa1640c Оптимизация трафика
- Оптимизирован траифик, спасибо @hteppl
- Исправлен баг с логами в хедере хидифая.
2025-02-20 20:06:27 +03:00
Capybara-z 37ebf4cdee Апдейт
- Время подписок берется из бд
- Облегчен парсер данных
- Добавлена поддержка хидифай
- Улучшена работа отдачи и обновления подписки.
2025-02-19 22:26:01 +03:00
Vladislav Lisitsyn bb0085a438 Merge pull request #156 from Capybara-z/patch-8
changing the subscription issue
2025-02-19 02:33:33 +03:00
Capybara-z b05e57fb16 фикс парсера времени
- фикс парсера времени для старых панелей
2025-02-19 01:31:15 +03:00
Capybara-z 4c977d07ce чистка кода
- Почитсил коментарии.
2025-02-18 23:57:38 +03:00
Capybara-z bd39825cf9 фикс расчета остатка
- Не посмотрел что TOTAL_GB указывается в байтах
2025-02-18 19:40:47 +03:00
Capybara-z e01a7efe4a фикс подписок
- Поправлено время для SUPERNODA = False
- Добавлен подсчет использованного трафика для Happ
2025-02-18 17:35:40 +03:00
Capybara-z 8b159ab75d фикс парсинга времени
- исправлен парсинг времени если передается без запятой
2025-02-18 14:31:43 +03:00
Capybara-z 316bd191a2 Указал коменты 2025-02-18 13:45:59 +03:00
Capybara-z 29f6fe40e0 фикс отображения
- Исправлено отображение с трафиком и без для спуперноды вкл/выкл
- Исправлен парсинг в общем для разных режимов.
2025-02-18 13:30:46 +03:00
Capybara-z 40f1f1b14e Правки для Happ
- Добавлен хедер трафика и даты завершения
2025-02-18 12:12:34 +03:00
Capybara-z 31822ee634 поправлены отступы 2025-02-18 11:19:52 +03:00
Capybara-z 773db02746 Фикс отображения данных
- Фикс отображения данных для Happ
2025-02-18 11:17:33 +03:00
Capybara-z 2ba9615151 фикс отображения трафика
- Корректное отображения трафика
2025-02-18 10:51:51 +03:00
Capybara-z 19c4c0496a Единое время обновления подписки
- Единое время обновления подписки
2025-02-18 10:30:18 +03:00
Capybara-z 903cde9a78 фикс часов
- фискс отображения когда остаются только часы
2025-02-18 10:19:05 +03:00
Capybara-z 3592eccea9 Фикс подпсиок
- Убираем профиль
- Отказ от SUB_MESSAGE
- Перадача подпсики и времени работы вместо SUB_MESSAGE
- Отдельная итеграция для Happ по хукам.
2025-02-18 09:21:10 +03:00
Vladless 4afd1668c9 add_change_domain/bug_fixes_16 2025-02-18 02:05:14 +03:00
Vladislav Lisitsyn f799dd8766 Merge pull request #154 from Capybara-z/patch-7
subscriptions update
2025-02-18 00:09:00 +03:00
Capybara-z 254c489010 Апдейт подписок.
- Добавление профиля.
- Выводит имейл и время подписки
- Страны становятся чистыми
- Профиль всегда на 1 месте
- Перемашка стран.
2025-02-17 02:50:21 +03:00
Vladislav Lisitsyn 53bc6f6167 Merge pull request #153 from JustYay/main
small fix for showing error
2025-02-17 02:47:00 +03:00
Boris Kovalskii b48cb94b12 small fix for show error 2025-02-16 17:27:52 +10:00
Vladislav Lisitsyn 0813ea2897 Merge pull request #152 from JustYay/main
Optimize and fix refferals
2025-02-16 10:20:54 +03:00
Boris Kovalskii 770f40f4ff Opimize and fix coupons and refferals 2025-02-16 17:09:16 +10:00
Boris Kovalskii dba8752436 optimize database 2025-02-16 17:08:38 +10:00
Vladless 4b503ce31a edit_text/lots of improvements 2025-02-16 07:17:35 +03:00
Vladislav Lisitsyn 8b9c00f53e Merge pull request #151 from izzzzzi/main
Add client toggle functionality for 3x-ui servers
2025-02-15 00:10:53 +03:00
Zakhar Izmaylov 55811aff56 Add inline referral query handler with customizable offers 2025-02-14 23:20:40 +03:00
Zakhar Izmaylov 310aa9bce6 Add client toggle functionality for 3x-ui servers 2025-02-14 23:06:45 +03:00
Vladislav Lisitsyn 2dcd1e65db Update README.md 2025-02-14 05:17:20 +03:00
Vladislav Lisitsyn 4f840c171a Update README.md 2025-02-14 05:08:36 +03:00
Vladless bfefdef304 optimization notifications and get_traffic 2025-02-14 04:36:37 +03:00
Vladless 3de049e436 NOTIFICATIONS 2.0/bug fixes and improvments 2025-02-14 00:30:06 +03:00
Vladislav Lisitsyn 74bb6b3f6d Merge pull request #149 from izzzzzi/main
Add notification tracking to prevent duplicate notifications for key expiry and renewal
2025-02-10 21:57:46 +03:00
Zakhar Izmaylov 56e6dc3ae6 Migrate from requirements.txt to pyproject.toml for dependency management 2025-02-09 12:08:22 +03:00
Zakhar Izmaylov c34626aec0 Add logging support for inline queries in middleware 2025-02-09 10:52:02 +03:00
Zakhar Izmaylov d4187149b8 Add notification tracking to prevent duplicate notifications for key expiry and renewal 2025-02-08 11:10:11 +03:00
Zakhar Izmaylov e982a5b718 Ignore GitHub Actions workflow directory in .gitignore 2025-02-08 11:00:00 +03:00
Zakhar Izmaylov a9a17bd693 Remove Ruff code formatting GitHub Actions workflow 2025-02-08 10:59:50 +03:00
GitHub Actions aef1973720 Auto-format code with Ruff using pyproject.toml 2025-02-08 07:46:14 +00:00
Vladless 5a77f29bd5 add traffic statistic/share refferal link and more 2025-02-07 21:38:46 +03:00
Vladless 0789b3fe9d Improve stability and minor fixes 2025-02-06 19:01:20 +03:00
Vladless c82f64980b fix_amount 2025-02-06 14:56:47 +03:00
Vladless 7623f339bc ruff formatting 2025-02-06 13:53:02 +03:00
Vladless 3de1635efa Major bug fixes 2025-02-06 02:03:12 +03:00
Vladless eb824162d2 fixes: float_stats/delete_user/message_errors 2025-02-05 05:58:03 +03:00
Vladless 54d5df72af minor_fixes 2025-02-03 00:08:55 +03:00
Vladless 74c991cf85 limit_coupon_usage 2025-02-02 23:15:55 +03:00
Vladless 46e6102412 minor fixes 2025-02-02 19:40:17 +03:00
Vladless 698b8a9484 minor fixes 2025-02-02 19:00:22 +03:00
Vladless 94f3c00628 fix_negative cashback 2025-02-02 17:49:43 +03:00
Vladless 630cf61f84 change_location/CASHBACK/logging/bug_fixes 2025-02-02 05:50:14 +03:00
Vladless d3b384aa61 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:42:28 +03:00
Vladislav Lisitsyn d209cf11c7 Merge pull request #147 from Capybara-z/patch-5
reorganizing notifications
2025-02-02 02:14:08 +03:00
Vladless 49eb0e68a1 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:11:57 +03:00
Capybara-z 3e7bebf189 Корректировка уведомлений.
- Исправлена работа оповещений 10/24 часа.
- Восстановлена и переработана работа истекших подписок.
- Переработана работа уведомлений при их удалении.
2025-02-02 00:58:07 +03:00
Vladless 73fbc22324 fix coupons error 2025-02-01 19:15:47 +03:00
Vladless 9774db74e5 Merge remote-tracking branch 'origin/fix/admin-actions' 2025-02-01 18:32:38 +03:00
Vladislav Lisitsyn 82d8b50956 Merge pull request #144 from izzzzzi/main
Enhance error handling with start menu fallback
2025-02-01 18:11:23 +03:00
Zakhar Izmaylov 587bf76c61 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-02-01 11:34:33 +03:00
Zakhar Izmaylov c2e4719f18 Enhance error handling with start menu fallback
- Add fallback mechanism to display start menu after error occurs
- Attempt to show start command or start callback query in error handler
- Log any additional errors during start menu display
2025-02-01 11:28:02 +03:00
Vladislav Lisitsyn 7b4bddfa28 Merge pull request #143 from izzzzzi/main
Update get_key_details query and return value
2025-01-31 21:40:13 +03:00
Zakhar Izmaylov 8be57829fb Update get_key_details query and return value
- Reorder SELECT columns in database query for get_key_details
- Add server_id to the returned key details dictionary
- Ensure consistent and complete key information retrieval
2025-01-31 21:05:55 +03:00
Vladislav Lisitsyn ceb3e160da Merge pull request #141 from izzzzzi/main
Fix key
2025-01-31 14:30:13 +03:00
Zakhar Izmaylov 007f3a7bcf Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 11:01:40 +03:00
Zakhar Izmaylov cc42900142 Update server name input handler with session parameter
- Add session parameter to server name input handler
- Modify server name uniqueness check to use session and cluster name
- Improve data retrieval and validation in server creation flow
2025-01-31 10:54:42 +03:00
hteppl 1009bb4001 Temporary fix for key expiry change 2025-01-31 06:46:53 +03:00
hteppl e4727dd0a4 Missing doc for field in update_key_expiry 2025-01-31 06:45:56 +03:00
GitHub Actions 3d6acf51f6 Auto-format code with Ruff using pyproject.toml 2025-01-31 03:45:45 +00:00
hteppl 1634588be9 Delete admin_user_editor.py 2025-01-31 06:45:24 +03:00
Zakhar Izmaylov c379011fd2 Fix key renewal and expiry update function signatures
- Update `change_expiry_time` to pass session to `update_key_expiry`
- Adjust `complete_key_renewal` connection closing order
- Minor code structure improvements in key handling functions
2025-01-31 06:44:36 +03:00
hteppl 8483ef1df0 Fix cluster selection 2025-01-31 06:10:37 +03:00
hteppl 9be291c1e8 Oops legacy codestyle 2025-01-31 05:19:52 +03:00
hteppl 6871f2d15e Get back cluster sync option 2025-01-31 05:14:40 +03:00
hteppl 60c9a4f1e6 Get back cluster sender option 2025-01-31 05:03:07 +03:00
hteppl 5a894e0e10 Fix type mismatch for build_admin_back_btn 2025-01-31 04:21:03 +03:00
Zakhar Izmaylov 45b9c56282 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 03:14:14 +03:00
Zahar Izmailov 88c4d940e8 Merge branch 'Vladless:main' into main 2025-01-31 03:09:45 +03:00
Zakhar Izmaylov 3741e4f3bf Standardize import order in handler modules
- Reorder imports in multiple handler files to follow a consistent pattern
- Move type-specific imports like Message and CallbackQuery to be more organized
- Ensure clean and uniform import statements across admin and handler modules
2025-01-31 03:01:46 +03:00
Zakhar Izmaylov 28e7c78601 Update type hints in admin and handler modules
- Replace types.Message and types.CallbackQuery with explicit Message and CallbackQuery imports
- Improve type annotations in multiple handler files
- Ensure consistent type hinting across admin and handler modules
2025-01-31 02:59:57 +03:00
Zakhar Izmaylov 37fbd35e8f Add trailing comma in handlers router inclusion
- Minor formatting adjustment in router configuration
- Ensure consistent code style with trailing comma
2025-01-31 02:44:48 +03:00
Zakhar Izmaylov c41b77d365 Update database query and key handling in admin user editor
- Modify get_keys function to return all columns in keys table
- Update admin user editor to handle full key record instead of just email
- Cast tg_id to integer in user record processing
- Adjust key display and callback data generation to use full key record
2025-01-31 02:41:40 +03:00
GitHub Actions 1d19d8b744 Auto-format code with Ruff using pyproject.toml 2025-01-30 21:15:14 +00:00
Zakhar Izmaylov b04bc4dca5 Remove user router and related blocking/unblocking logic
- Deleted handlers/user.py file
- Removed user_router from main handlers router
- Eliminated user blocking/unblocking event handlers
2025-01-31 00:14:56 +03:00
Vladislav Lisitsyn 4dd4d829d7 Merge pull request #139 from izzzzzi/main
Fix
2025-01-31 00:09:44 +03:00
Zakhar Izmaylov bf35156455 Remove check_online_users function and minor import reorganization
- Removed unused check_online_users function from notifications handler
- Reorganized imports in notifications.py and utils.py
- Fixed TimeoutError import in subscriptions.py
2025-01-31 00:08:31 +03:00
Zakhar Izmaylov e6a1b55045 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:37:28 +03:00
Zakhar Izmaylov 455ab76c8a Minor text formatting in captcha generation message 2025-01-30 23:36:56 +03:00
GitHub Actions 720c145aa3 Auto-format code with Ruff using pyproject.toml 2025-01-30 20:36:20 +00:00
Zakhar Izmaylov 580ce90442 Remove unused is_bot_blocked function from notifications handler 2025-01-30 23:36:02 +03:00
Zakhar Izmaylov 239426f6d5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:35:50 +03:00
Zakhar Izmaylov 3c4ae34997 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 22:36:10 +03:00
Zakhar Izmaylov 899da825cb Refactor and improve security with cryptographically secure random generation
- Replace `random` module with `secrets` for cryptographically secure random choices
- Update type hints for async API methods in client.py
- Optimize file reading with aiofiles in multiple handlers
- Improve error handling and logging in various modules
- Update Python target version to 3.12 in pyproject.toml
- Remove unused imports and improve code quality
2025-01-30 22:35:09 +03:00
Vladislav Lisitsyn 59e0d965da Merge pull request #138 from Capybara-z/patch-2
Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
2025-01-30 21:58:33 +03:00
Vladislav Lisitsyn b70e27932d Merge pull request #137 from Capybara-z/patch-1
Задержка перед удалением подписки.
2025-01-30 21:58:22 +03:00
Capybara-z 69dcef073e Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
Добавлена format_time_until_deletion для расчета времени при уведомлении когда подписка истекала.
2025-01-30 21:50:29 +03:00
Capybara-z 89394ef630 Задержка перед удалением подписки.
Добавлена переменная:
DELETE_KEYS_DELAY = 0  # Задержка в секундах перед удалением ключа после истечения. Установите 0, если функция не требуется.

При ее работе не удаляет ключ, а ждет опредленное время.

Поправлена работа def handle_expired_keys, ранее брался запас 45 мин для выгрузки, сейчас EXPIRED_KEYS_CHECK_INTERVAL * 1.5 для корректной проверки и запаса при повторных проверках.

При работе DELETE_KEYS_DELAY добавляется кнопка продления.
2025-01-30 21:48:19 +03:00
GitHub Actions 79a9a95ffc Auto-format code with Ruff using pyproject.toml 2025-01-29 04:18:09 +00:00
Zakhar Izmaylov ee9e65d692 Minor whitespace adjustment in database connection closing 2025-01-29 07:17:53 +03:00
Zakhar Izmaylov 9bc135a51a Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:17:28 +03:00
Zakhar Izmaylov fabdfc6ab6 Improve type hinting for database backup function
Update return type annotation for `_create_database_backup()` to use explicit tuple type syntax, enhancing type clarity and compatibility with modern Python type hinting practices.
2025-01-29 07:16:21 +03:00
GitHub Actions dff378ad81 Auto-format code with Ruff using pyproject.toml 2025-01-29 04:15:40 +00:00
Zakhar Izmaylov b1739cf4ea Optimize import statements and remove unused imports across multiple files
This commit focuses on cleaning up import statements by:
- Removing unused imports
- Organizing import order
- Removing redundant imports
- Ensuring consistent import style across different modules
2025-01-29 07:15:20 +03:00
GitHub Actions d47952fe3e Auto-format code with Ruff using pyproject.toml 2025-01-29 04:01:20 +00:00
Zakhar Izmaylov 74d58ce310 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:01:01 +03:00
Vladislav Lisitsyn 5f43bb6e55 add admin-panel by hteppl
reorganizing and optimizing the admin panel
2025-01-28 19:36:54 +03:00
Oleg Kraev f7c906f608 Merge branch 'main' into dev 2025-01-24 23:35:19 +03:00
Zakhar Izmaylov e97e3060f7 Add inline button for user profile in admin panel message processing. Enhanced user interaction by including a button that directs users to their personal account, improving the overall user experience. 2025-01-23 14:43:39 +03:00
Zakhar Izmaylov b6ebfd5c1e Enhance error handling in backup cleanup process. Added a generic exception handler to log errors during the removal of old backups, improving robustness and maintainability of the backup functionality. 2025-01-23 12:10:08 +03:00
Zakhar Izmaylov 3d67311e61 Update button labels from "🔙 Назад" to "⬅️ Назад" across multiple admin handlers and key management files for improved user interface consistency. This change enhances the visual clarity of navigation options within the application. 2025-01-23 12:06:38 +03:00
Zakhar Izmaylov 7bfbf89054 Refactor trial management function names for clarity. Renamed set_trial to update_trial in database.py and updated all related handler calls in start.py, admin_user_editor.py, and trial_key.py to reflect this change. This improves code readability and consistency across the codebase. 2025-01-23 12:03:01 +03:00
GitHub Actions 43959a996e Auto-format code with Ruff using pyproject.toml 2025-01-23 09:00:26 +00:00
Zakhar Izmaylov 591370ce60 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 12:00:10 +03:00
Zakhar Izmaylov bb803ab145 Implement new database functions for server and coupon management. Added check_server_name_by_cluster, get_coupon_details, get_referral_by_referred_id, and get_all_keys to enhance data retrieval capabilities. Updated handlers to utilize these new functions, improving code clarity and maintainability. Refactored existing database calls in start.py, utils.py, and keys.py for consistency and better session management. 2025-01-23 11:58:35 +03:00
GitHub Actions 07d14a301b Auto-format code with Ruff using pyproject.toml 2025-01-23 08:40:44 +00:00
Zakhar Izmaylov 56af0a7885 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 11:40:25 +03:00
Zakhar Izmaylov c2ba4e9ab6 Refactor server management and database interactions. Renamed get_servers_from_db to get_servers for clarity and updated related handlers to utilize session management. Refactored add_server_to_db to create_server for consistency. Improved code readability and maintainability across coupon and notification handlers by streamlining database calls and enhancing error handling. 2025-01-23 11:36:15 +03:00
GitHub Actions 7b372e095e Auto-format code with Ruff using pyproject.toml 2025-01-23 08:28:39 +00:00
Zakhar Izmaylov 33f98276ce Enhance database interaction in coupon and server management. Updated check_unique_server_name to accept session and cluster name parameters for improved uniqueness checks. Introduced new functions: get_coupon_by_code, delete_server, create_coupon_usage, check_coupon_usage, and update_coupon_usage_count for better coupon management. Refactored handlers to utilize these new functions, improving code clarity and maintainability. 2025-01-23 11:28:21 +03:00
Zakhar Izmaylov 8818d91232 Refactor database interaction in key management functions to utilize session objects for improved performance and consistency. Updated get_keys, get_keys_by_server, update_balance, and update_key_expiry functions to accept a session parameter, allowing for better session management across handlers. Adjusted related handler functions to pass the session, enhancing code clarity and maintainability. 2025-01-23 11:06:25 +03:00
Zakhar Izmaylov 0aab2096a5 Refactor database interaction functions in database.py and update related handlers. Renamed functions for clarity: save_temporary_data to create_temporary_data, add_blocked_user to create_blocked_user, remove_blocked_user to delete_blocked_user, and delete_coupon_from_db to delete_coupon. Updated trial management function from restore_trial to set_trial to better reflect its purpose. Adjusted corresponding calls in notifications, start, user, and admin handlers for consistency and improved readability. 2025-01-23 10:38:32 +03:00
Zakhar Izmaylov 5a3f54c3a7 Refactor client management in key_utils.py and trial_key.py to utilize ClientConfig dataclass for improved parameter handling. This change enhances code clarity and maintainability by consolidating client configuration parameters into a single structured object, streamlining the add_client function calls across multiple handlers. 2025-01-23 10:24:16 +03:00
Zakhar Izmaylov 79ebe6b6bc Refactor backup sending logic in backup.py to remove redundant document sending for admin IDs. Streamlined the process by ensuring only the necessary admin IDs receive the backup document, enhancing code clarity and reducing potential errors in the backup notification process. 2025-01-23 08:54:22 +03:00
Zakhar Izmaylov 6c323dc7df Refactor client configuration and improve code clarity in client.py and admin_servers.py. Added a new line in ClientConfig for better readability and updated the extend_client_key function signature for consistency. Corrected a TODO comment format in admin_servers.py for improved code standards. 2025-01-23 08:50:04 +03:00
Zakhar Izmaylov 94980d4524 Refactor client management functions in client.py to use a dataclass for client configuration. Update add_client, extend_client_key, and delete_client functions to improve parameter handling and logging. Enhance error handling and documentation for better clarity and maintainability. 2025-01-23 08:49:03 +03:00
GitHub Actions 78c00b5454 Auto-format code with Ruff using pyproject.toml 2025-01-23 05:42:01 +00:00
Zakhar Izmaylov 9437ee9c90 Refactor key retrieval and management logic across multiple handlers. Update get_key_details to include email in the fetched record and replace direct SQL queries with dedicated functions for improved code clarity and maintainability. Add a TODO comment in admin_servers.py for future enhancements. 2025-01-23 08:41:44 +03:00
Zakhar Izmaylov 4addb772a0 Add server management functionality by introducing add_server_to_db function for streamlined database interactions. Refactor server addition logic in sync_servers_with_db and admin handlers to utilize the new function, enhancing code clarity and session management. 2025-01-23 08:03:58 +03:00
Zakhar Izmaylov a1d051a3f1 Refactor key management functions to improve database interactions by consolidating key deletion logic into a single function. Introduce timezone-aware expiry date calculations for keys and enhance key retrieval processes across various handlers. Update the start command to utilize a dedicated function for adding connections, ensuring cleaner code and better session management. 2025-01-23 07:49:21 +03:00
Zakhar Izmaylov c4d3c921bd Refactor code to utilize aiofiles for asynchronous file handling across multiple modules. Added a new function to remove blocked users from the database and updated user blocking/unblocking logic to use this new function. Improved database interaction in admin panel handlers by passing session objects instead of creating new connections. This enhances performance and maintains consistency in file operations. 2025-01-23 06:34:45 +03:00
GitHub Actions 1596c85bee Auto-format code with Ruff using pyproject.toml 2025-01-23 03:15:11 +00:00
Zakhar Izmaylov 3d4a72d3fd Update .gitignore to include logs and setup.py, and remove unnecessary entries 2025-01-23 06:14:48 +03:00
Zakhar Izmaylov ad9c49f841 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 06:14:33 +03:00
Zakhar Izmaylov cc2b9b52c5 Update balance retrieval to round values to one decimal place in database.py 2025-01-23 05:49:24 +03:00
Vladless 5a1d4c1b24 minor_fixes 2025-01-22 18:46:16 +03:00
Zakhar Izmaylov 99cc4b26bc Add directory creation for database backups in backup.py 2025-01-22 10:31:03 +03:00
Vladless 970511ca1d location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
Vladless b81a0f289f location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
hteppl 13259b435b Implement .github folder for github-related data 2025-01-21 18:04:41 +03:00
Zakhar Izmaylov 44cadc29ba Refactor file handling to use aiofiles for asynchronous operations
- Replaced synchronous file reading with asynchronous file handling using aiofiles in multiple handlers and the backup module.
- Improved performance and responsiveness when sending images and backups by utilizing async file operations.
- Ensured consistent use of BufferedInputFile across all relevant functions for better code maintainability.
2025-01-21 12:03:56 +03:00
Zakhar Izmaylov a2c71a49f2 Refactor and clean up code formatting across multiple files
- Consolidated and simplified code formatting by removing unnecessary line breaks and improving readability in various functions.
- Updated the handling of backup file sending in backup.py for better clarity.
- Streamlined error handling and logging messages in several handlers to enhance consistency.
- Adjusted the Makefile to exclude specific files during Ruff checks and formatting.
- Made minor adjustments to function signatures and parameter handling for improved clarity and consistency.

This commit enhances code maintainability and readability without altering functionality.
2025-01-21 08:57:12 +03:00
Zakhar Izmaylov e7d54ae317 Refactor private message filter in bot.py
- Renamed IsPrivate to IsPrivateFilter for clarity and consistency.
- Updated message and callback query filters to use the new IsPrivateFilter class.
2025-01-20 22:38:52 +03:00
Zakhar Izmaylov 1459bdacfa Refactor captcha response handling in check_captcha function
- Removed the deletion of the original message upon correct captcha response.
- Changed the method of sending the captcha text and markup from edit_text to answer for better user experience.
2025-01-20 22:31:14 +03:00
Zakhar Izmaylov a9e111bd4f Add captcha functionality to start command handler
- Included captcha router in main handlers.
- Integrated captcha generation in the start command, conditional on CAPTCHA_ENABLE setting.
2025-01-20 22:27:39 +03:00
Zakhar Izmaylov b63c3fb115 Update work from blocked_users 2025-01-20 03:47:16 +03:00
Vladislav Lisitsyn a0a6686058 Merge pull request #133 from hteppl/admin-redo
Обновление логики работы панели для Администраторов
2025-01-20 03:39:38 +03:00
Zakhar Izmaylov 4c4a1a297c Fix errors_handler 2025-01-19 09:16:56 +03:00
Zakhar Izmaylov fc4bb88db8 Fix and update Middleware 2025-01-19 09:15:42 +03:00
Zakhar Izmaylov e17cde7c72 Update errors_handler 2025-01-19 08:59:43 +03:00
Zakhar Izmaylov 3d7684b72b Small fix 2025-01-19 08:49:46 +03:00
Zakhar Izmaylov 51558aeeda Update requirements.txt 2025-01-19 08:36:18 +03:00
Zakhar Izmaylov d83da5daaa Add filter and middleware 2025-01-19 08:29:10 +03:00
hteppl 67b2da3447 Implement expiry time printing in key names v2 2025-01-19 06:05:45 +03:00
hteppl eeb330b21f Implement expiry time printing in key names 2025-01-19 06:05:22 +03:00
hteppl efff92b2b3 Fix some imports 2025-01-19 05:37:01 +03:00
hteppl 4d5cc954b5 Introduce changing key expiry date keyboard 2025-01-19 05:35:20 +03:00
hteppl 1e204a3e29 Remove get_key_details unused fields for now 2025-01-18 04:54:37 +03:00
hteppl 98d0c03aee Feat https://github.com/Vladless/Solo_bot/commit/b6184d4c90deed7162f618bdebefa44f3e0ee348 2025-01-18 04:36:59 +03:00
hteppl 1a29bb6dd5 Fix bouncing messages while handle user input for changing balance 2025-01-18 04:21:40 +03:00
hteppl 0959742f5b Fix some telegram exceptions while editing the same content + add forward option to users search 2025-01-18 04:09:21 +03:00
hteppl 1c505b74d5 Add and fix some keyboards while creating new cluster or server 2025-01-18 04:08:31 +03:00
Oleg Kraev 96a6612124 Merge pull request #9 from hteppl/main
Changes from upstream
2025-01-17 21:51:08 +03:00
hteppl 78181e7230 Changes from upstream 2025-01-17 21:50:35 +03:00
hteppl 66bba43ec4 Reimage update_subscription callback to work as function 2025-01-17 21:44:54 +03:00
hteppl 4140fbae78 Update balance changing system + fix some formatting 2025-01-17 20:42:45 +03:00
hteppl e4a4d68758 Oops fix imports 2025-01-17 18:25:50 +03:00
hteppl 478d2ee0e6 Introduce utils module + move csv exports logic 2025-01-17 18:25:09 +03:00
Zakhar Izmaylov 588c32453d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-17 01:54:11 +03:00
Zakhar Izmaylov ce6186fa17 Update backlog 2025-01-17 01:49:24 +03:00
Zahar Izmailov a617b940c1 Merge branch 'Vladless:main' into main 2025-01-17 01:43:03 +03:00
hteppl c370d32154 Reorganise backups creation 2025-01-16 11:00:38 +03:00
hteppl 89f329458c Rename handlers functions with actual names + delete parse_mode 2025-01-16 09:20:33 +03:00
hteppl 4df9140cf8 Delete legacy admin_user_editor.py 2025-01-16 09:11:18 +03:00
hteppl 8e74c12bca Implement bot version in admin main menu 2025-01-16 09:08:35 +03:00
Vladislav Lisitsyn 4e280d1b3e Merge pull request #132 from JustYay/main
optimize subscriptions
2025-01-16 04:16:15 +03:00
Vladless c158c1339f backup_update 2025-01-16 04:15:17 +03:00
Vladless 9db8fdf79b expired_time 2025-01-16 04:03:53 +03:00
Vladless b6184d4c90 fix cluster_name in admin_panel 2025-01-16 03:03:37 +03:00
Vladless 981dc0db01 pytz for notifications 2025-01-16 01:50:34 +03:00
Oleg Kraev 0403ad5688 Merge pull request #6 from hteppl/main
Changes from upstream
2025-01-15 22:05:55 +03:00
hteppl f05b7416ef Admin update 2025-01-15 22:05:32 +03:00
Vladless 1d433d2cfd fix trial_key for SuperNode 2025-01-15 20:16:52 +03:00
Vladless 17a0fa0b21 SuperNode/SyncTimeout/pytz 2025-01-15 18:46:33 +03:00
hteppl 7154374fdc Update stats, fix some text input flows to working with delete middleware 2025-01-15 14:29:33 +03:00
hteppl 34b1927c22 Some missing logic and fixups 2025-01-15 14:07:49 +03:00
Boris Kovalskii 1e9367ab93 optimize subscriptions
Get and update subscriptions faster
2025-01-15 21:05:00 +10:00
hteppl a7d0cca66c Do not import bot directly in admin sender 2025-01-15 09:01:56 +03:00
hteppl e3bb7f6663 Remove direct key search, use common user searcher anytime 2025-01-15 09:01:10 +03:00
hteppl 450b8e17f0 Update delete middleware: pass all admin callbacks and states 2025-01-15 08:59:54 +03:00
hteppl f176a11ff9 Remove legacy admin_user_editor from release 2025-01-15 08:02:04 +03:00
hteppl aadd2edf45 Optimize and fix admin handlers logic 2025-01-15 08:01:16 +03:00
hteppl a02062a4b4 Optimize and fix keyboards 2025-01-15 08:00:41 +03:00
hteppl 0092764f8f Implement banned by users handler 2025-01-15 07:58:46 +03:00
hteppl 55b065f784 Admin keyboards adjustments 2025-01-15 03:37:01 +03:00
Zakhar Izmaylov eafe0a78d2 Add page coupons 2025-01-15 02:39:01 +03:00
Zakhar Izmaylov 9da808a024 Delete parse_mode 2025-01-15 02:15:21 +03:00
Vladless 3d295743e2 SuperNode/SyncTimeout/pytz 2025-01-14 23:35:19 +03:00
Vladless 6f8bc2b08d SuperNode/SyncTimeout/pytz 2025-01-14 15:53:27 +03:00
Vladless 32d0ac81bd SuperNode/SyncTimeout/pytz 2025-01-14 15:12:06 +03:00
Vladless 35f214a45f SuperNode/SyncTimeout/pytz 2025-01-14 12:16:14 +03:00
Vladless 8fdc78c2e8 FlowPayRenew/fixNotify/pay_history/custom/statistic/minorFixes 2025-01-14 04:06:55 +03:00
Vladless 6943794e50 add_stats 2025-01-13 03:58:58 +03:00
Vladless 086e3fe5b0 fix_profile 2025-01-13 02:03:09 +03:00
Vladislav Lisitsyn d247b764f7 Merge pull request #130 from izzzzzi/main
Backlog
2025-01-13 02:00:32 +03:00
Zakhar Izmaylov 283016f043 Delete Throttle 2025-01-12 18:19:49 +03:00
hteppl 7c19492c05 Fix some typo and quotes 2025-01-12 17:35:42 +03:00
hteppl 054d710d6c Register all the missing routers 2025-01-12 17:32:11 +03:00
hteppl f11b014acf Implement admin sender handler 2025-01-12 17:29:48 +03:00
hteppl ab27328cb7 Implement admin servers handler 2025-01-12 17:07:57 +03:00
hteppl 9b98faedfd Rename admin users editor handler to users handler 2025-01-12 17:01:39 +03:00
hteppl e957fbdfa1 Implement admin users editor handler 2025-01-12 16:58:17 +03:00
Oleg Kraev 94273b5ee2 Merge pull request #5 from hteppl/upstream1
Changes from upstream
2025-01-12 16:56:52 +03:00
Oleg Kraev 87d92b7dae Merge branch 'admin-redo' into upstream1 2025-01-12 16:56:41 +03:00
Zakhar Izmaylov 9a823eddf3 Add check_users_and_update_blocked 2025-01-11 20:29:01 +03:00
Zakhar Izmaylov 52d124c879 Add timeout 2025-01-11 18:43:14 +03:00
Zakhar Izmaylov 287d0a635b Update stats 2025-01-11 18:10:43 +03:00
Zakhar Izmaylov 1057fd6ef6 Add balance_history 2025-01-11 18:03:59 +03:00
Zakhar Izmaylov d935a9cd49 Update config lint 2025-01-11 17:51:24 +03:00
hteppl 97d598a293 Implement admin stats handler 2025-01-11 02:24:16 +03:00
hteppl c9664c136b Implement admin restart handler + also handle AdminPanelCallback for back buttons 2025-01-11 02:17:54 +03:00
hteppl 0b0fca8648 Add more base functions for creating singleton admin keyboards 2025-01-11 02:17:02 +03:00
Vladislav Lisitsyn 9577c8272f Merge pull request #129 from izzzzzi/main
Add stats
2025-01-11 02:01:40 +03:00
Zakhar Izmaylov 0d82294bdb Add antiflood and lint 2025-01-10 14:51:13 +03:00
Zakhar Izmaylov d40c845d62 Add stats 2025-01-10 14:38:28 +03:00
Vladless 70e3f5c714 syncClaster/picGifts/mailingCluster/renewButtin and more 2025-01-10 00:02:13 +03:00
hteppl 8a15d0320c Implement admin backups handler 2025-01-09 15:49:23 +03:00
hteppl 73bf682f97 Update admin coupons handler 2025-01-09 15:48:32 +03:00
hteppl ba53e9e199 Update admin main panel 2025-01-09 15:43:22 +03:00
hteppl e13937b965 Introducing keyboards package 2025-01-09 15:13:36 +03:00
Vladless 7758415ddf fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 06:00:50 +03:00
Vladless a19c325e15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 03:16:53 +03:00
Vladless 10df771a15 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 02:40:39 +03:00
Vladislav Lisitsyn 6cd7e8acee Merge pull request #128 from izzzzzi/main
Fix process_message_to_all
2025-01-06 19:20:45 +03:00
Zakhar Izmaylov d8944a9c52 Update version 2025-01-06 15:29:24 +03:00
Zahar Izmailov 930ce0fcdc Merge branch 'Vladless:main' into main 2025-01-06 12:10:08 +03:00
Zakhar Izmaylov fceff14d0a Fix process_message_to_all 2025-01-06 12:09:15 +03:00
Vladislav Lisitsyn 16e31d4abb Merge pull request #127 from izzzzzi/main
Backlog
2025-01-06 02:57:04 +03:00
Zakhar Izmaylov 5289548fd9 Fix float get_usd_rate 2025-01-05 23:46:45 +03:00
Zakhar Izmaylov 681fd7d92c Add get_usd_rate 2025-01-05 23:43:04 +03:00
Zakhar Izmaylov 932ef51de4 Add update_all_keys 2025-01-05 23:32:18 +03:00
Zakhar Izmaylov 080a99cd55 Update update_subscription user 2025-01-05 23:24:01 +03:00
Zakhar Izmaylov 93ac01592f Update send_to_alls 2025-01-05 23:19:45 +03:00
Zakhar Izmaylov 1742519659 Add update user 2025-01-05 23:11:05 +03:00
Vladislav Lisitsyn 954c6b6ae7 Merge pull request #126 from hteppl/fix-trial
Фикс проверки тестовых подписок
2025-01-05 18:54:58 +03:00
hteppl 50811afa30 fix: typo in error message 2025-01-04 20:34:49 +03:00
hteppl afc913a2ff fix: check trial key already used 2025-01-04 15:30:34 +03:00
Vladislav Lisitsyn bab6a807f3 Merge pull request #125 from hteppl/fix-tv-preview
Удаление превью ссылок в сообщении текста для ТВ
2024-12-29 02:26:05 +03:00
hteppl 6329dfd3ef Fix CONNECT_TV_TEXT: add disable_web_page_preview=True 2024-12-29 02:05:47 +03:00
Vladless 6484bf2c6e TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:46:20 +03:00
Vladless 1e64817e3f TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:28:38 +03:00
Vladless 15fef698d9 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:22:56 +03:00
Vladislav Lisitsyn c108ec4b2d Merge pull request #124 from hteppl/mics-updates
Небольшие изменения
2024-12-26 23:03:38 +03:00
hteppl 5d70576582 Fix key back button 2024-12-26 03:40:26 +03:00
hteppl 177cbb8881 Remove Telegram URL prefix when finding a user by username 2024-12-26 03:36:54 +03:00
hteppl 7d8050ce1c Monthly payments in admin stats 2024-12-26 03:36:28 +03:00
Vladislav Lisitsyn fadafecb43 Merge pull request #123 from hteppl/license
Лицензия и обновленный README
2024-12-24 17:40:48 +03:00
Vladless ab6af88114 gift_links 2024-12-24 15:02:48 +03:00
Vladless 3b7bb5436b buttons_pay/security_update/MacOS 2024-12-24 02:30:53 +03:00
Vladless bfe8ed8b3c buttons_pay/security_update 2024-12-24 02:30:09 +03:00
hteppl 95b338a1c2 Update README.md 2024-12-23 16:01:16 +03:00
hteppl 072a90a028 Add CC BY-NC 4.0 LICENSE 2024-12-23 16:00:48 +03:00
hteppl 933c8100e4 Remove legacy installation scripts 2024-12-23 16:00:26 +03:00
Vladless 4951a5cf78 minor update 2024-12-23 01:23:34 +03:00
Vladless 5e904f3045 gift_code/buttons/commands 2024-12-23 01:14:28 +03:00
Vladless 0840a07ed9 add support for business/optimization 2024-12-20 23:29:27 +03:00
Vladless 2becee7f02 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:39:07 +03:00
Vladless ae22950ce3 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:37:41 +03:00
Vladislav Lisitsyn 169b276ef1 Merge pull request #122 from hteppl/fix-referrals
WIP: фикс уявзимости, где айди приглашенного реферала, совпадает с айди пригласившего
2024-12-16 23:41:29 +03:00
hteppl 5966555ae9 Fix self-referred bug 2024-12-16 23:19:39 +03:00
Vladislav Lisitsyn de27fa24d4 Merge pull request #120 from crell2100/main
add CHANEL_EXISTS variable
2024-12-16 23:09:42 +03:00
crell2100 0b0b002c82 donats_enable->donations_enable (rename variable) 2024-12-16 16:40:01 +03:00
crell2100 0e4f59a242 Merge branch 'main' into main 2024-12-15 22:51:32 +03:00
crell2100 051714e613 add DONATS_ENABLE variable 2024-12-15 22:30:20 +03:00
crell2100 c780b1ffd7 add CHANEL_EXISTS variable 2024-12-15 18:07:52 +03:00
Vladless 88caa0bab3 remove gifts 2024-12-15 00:23:08 +03:00
Vladless e6b071f64d add import 2024-12-15 00:23:08 +03:00
Vladless 9a809aa646 add import 2024-12-15 00:23:08 +03:00
Vladislav Lisitsyn 52bdf10e0e Update README.md 2024-12-14 21:24:42 +03:00
Vladislav Lisitsyn 1642bde410 Merge pull request #119 from TrackLine/main
Add Yoomoney pay service
2024-12-14 19:07:25 +03:00
TrackLine 2fdb29d517 Add Yoomoney pay service 2024-12-14 19:49:54 +04:00
Vladless ad8d28f7f0 sub_name 2024-12-14 18:46:43 +03:00
Vladless a4776d7682 fix middleware 2024-12-14 17:39:39 +03:00
Vladislav Lisitsyn 3c7451e8fc Merge pull request #118 from izzzzzi/main
Migrate to ruff
2024-12-14 02:02:09 +03:00
Vladislav Lisitsyn f65ed3eada Merge branch 'main' into main 2024-12-14 02:02:00 +03:00
Vladless b4c47faac9 vat_code 2024-12-14 00:37:20 +03:00
Vladless 217bb457a1 sub_name/delete user 2024-12-13 23:01:58 +03:00
Zakhar Izmaylov ce79caa58d Fix requirements.txt 2024-12-13 12:13:50 +03:00
Zakhar Izmaylov 76d72ba203 Migrate to ruff 2024-12-13 12:09:20 +03:00
Vladless 035ef47619 rm wrench 2024-12-11 15:41:45 +03:00
Vladislav Lisitsyn 4792abc6d5 Merge pull request #113 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 04:14:24 +02:00
Vladless 42ac740589 pic for tariffs 2024-12-10 05:13:54 +03:00
Vladless d5371e4c04 pic for tariffs 2024-12-10 05:12:49 +03:00
Vladless 7c883b07f9 Revert "Merge pull request #112 from Vladless/hteppl-optimize-sources"
This reverts commit fa681fac92, reversing
changes made to b31c9d2e10.
2024-12-10 03:53:07 +03:00
Vladislav Lisitsyn fa681fac92 Merge pull request #112 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 02:27:34 +02:00
Vladless d1a9691c87 Merge branch 'optimize-sources' of https://github.com/hteppl/Solo_bot into hteppl-optimize-sources 2024-12-10 03:26:42 +03:00
Vladislav Lisitsyn b31c9d2e10 Merge pull request #108 from hteppl/optimize-routing-system-v2
Оптимизация системы рутинга 2 // LP
2024-12-10 00:54:32 +02:00
Vladislav Lisitsyn 11176a16bc Merge pull request #104 from hteppl/create-sql-schema
Оптимизация создания базы данных // LP
2024-12-10 00:54:22 +02:00
hteppl 39aaa88532 Make utils as package + fix python unresolved imports 2024-12-08 05:32:23 +03:00
hteppl cf6fcdbb76 Move payments utils into utils folder 2024-12-08 05:13:38 +03:00
hteppl 4e09e04dd9 Move keys utils into utils folder 2024-12-08 05:11:31 +03:00
hteppl e0639c7116 Move utils.py to the utils folder 2024-12-08 05:02:41 +03:00
hteppl 1d915f849c Move util-related files into separated folder + refactor imports 2024-12-08 04:57:20 +03:00
Oleg Kraev 404d502d2f Merge branch 'Vladless:main' into optimize-routing-system-v2 2024-12-08 04:37:31 +03:00
hteppl bcfb4c7942 Implement global router 2024-12-08 04:36:59 +03:00
hteppl bd5cc56441 Include routers only if payment method enabled in the config 2024-12-08 04:23:59 +03:00
hteppl bc42b0e52b Create and implement router for admin 2024-12-08 04:21:37 +03:00
hteppl 71e549866b Create and implement router for instructions 2024-12-08 04:20:09 +03:00
hteppl ca9dd17450 Create and implement router for keys 2024-12-08 04:19:16 +03:00
hteppl 91f12fe627 Create and implement router for payments 2024-12-08 04:17:50 +03:00
hteppl 1406e87ab3 Create basic main router for handlers 2024-12-08 04:15:52 +03:00
Vladislav Lisitsyn 9398199f25 Merge pull request #107 from hteppl/linux-start-scripts
Linux cкрипты для автоматической настройки зависимостей и запуска
2024-12-07 15:32:56 +02:00
Vladless 6ed83097de add pay button 2024-12-07 16:32:16 +03:00
Vladislav Lisitsyn 4d38ab0d3a Update README.md 2024-12-07 00:27:53 +03:00
Vladislav Lisitsyn d955a6ccfc Update README.md 2024-12-07 00:17:48 +03:00
hteppl 82da0cbf53 Add linux shell scripts to configure dependencies automatically 2024-12-06 18:39:35 +03:00
Vladless 360d73dd26 ping servers and notifications 2024-12-06 05:19:44 +03:00
Vladless 14400018c9 ping servers and notifications 2024-12-06 04:48:58 +03:00
Vladless f845baa3d0 back button in custom pay 2024-12-06 02:25:40 +03:00
Vladless 54e6c2aaaa back button in custom pay 2024-12-06 02:25:18 +03:00
Vladless 240ad42beb preview pc_instruction disable 2024-12-06 01:42:14 +03:00
Vladless a8d8972855 Server availability 2024-12-06 01:14:25 +03:00
Vladless b70b432d05 logs folder and autoremove 2024-12-06 00:46:54 +03:00
Vladless 77d6b8c67c sales in texts 2024-12-05 23:56:06 +03:00
Vladless 9251087d2c backup for 3x-ui 2024-12-05 23:31:48 +03:00
Vladless 5f2f3ad683 fix refferal 2024-12-05 22:59:34 +03:00
Vladless 0f507aa101 fix refferal 2024-12-05 22:45:10 +03:00
Vladless 99574ce751 backup time 2024-12-05 21:09:37 +03:00
hteppl 441e7ef696 Create schema.sql database template + optimize init_db 2024-12-05 07:59:49 +03:00
Vladless 8395c29ae2 fix trial 2024-12-04 17:25:32 +03:00
Vladless ff37c48579 change version 2024-12-04 03:10:15 +03:00
Vladless 8e778eb7af unique server_name/ link moderation 2024-12-04 03:02:29 +03:00
Vladless 2f6b85d4a6 unique server_name 2024-12-04 02:46:57 +03:00
Vladless bb034ba6c6 plan on create new key after trial 2024-12-04 01:28:28 +03:00
Vladless d86acbfb9d admin_update 2024-12-04 00:16:23 +03:00
Vladless baf0e2d695 add button for MacOS 2024-12-04 00:06:21 +03:00
Vladless b58079d86f Clusters on/off in config 2024-12-02 04:58:14 +03:00
Vladless ee2ae3f14d profile/reorganization and news 2024-12-02 04:46:05 +03:00
Vladless 5420c38592 sale size 2024-12-02 04:20:32 +03:00
Vladless 2a3d2098c9 rename server info 2024-12-02 04:08:36 +03:00
Vladless 36a44f3669 move button 2024-12-02 04:06:46 +03:00
Vladless ccbe82a140 pc button for trial_key 2024-12-02 03:54:07 +03:00
Vladless 8b6afcf571 pc button for trial_key 2024-12-02 03:37:28 +03:00
Vladless 74353143cf add/delete servers in admin 2024-12-02 03:25:06 +03:00
Vladless 7f48f5b7b5 servers in admin_editor 2024-12-02 00:28:33 +03:00
Vladless 5bb83fe904 servers in database 2024-12-01 23:40:37 +03:00
Vladless bfdf5f9f41 servers in database 2024-12-01 23:24:53 +03:00
Vladless d7d0e3da42 autoconfig servers in database 2024-12-01 23:24:13 +03:00
Vladless 11c84eae82 notification inactive users 2024-12-01 22:24:00 +03:00
Vladless cd7db1330e sudo in restart 2024-11-29 00:01:48 +03:00
Vladless 24c1f8b9cb autoname for trial in profile 2024-11-28 20:48:34 +03:00
Vladless 8c62573664 /start 2024-11-28 15:37:21 +03:00
Vladless fc1dda5597 link Successful payment 2024-11-27 21:18:07 +03:00
Vladless 9e899f4ec6 fix stars 2024-11-27 17:30:53 +03:00
Vladless bb2fea1396 update subs 2024-11-26 20:10:14 +03:00
Vladless 9a05ff82e4 fix admin 2024-11-26 20:03:21 +03:00
Vladless 21e82eed79 notifications 2024-11-26 17:48:41 +03:00
Vladless ca1a279144 fix renew 2024-11-26 17:30:41 +03:00
Vladless 4e6eb4ea7f random email for add device 2024-11-26 17:21:15 +03:00
Vladislav Lisitsyn e3787a041c Merge pull request #102 from izzzzzi/main
Refactor
2024-11-26 03:25:33 +03:00
Zakhar Izmaylov cd411c6b49 Fix start 2024-11-26 03:21:43 +03:00
Zakhar Izmaylov 9acc9d8304 Small fix 2024-11-26 02:54:32 +03:00
Zakhar Izmaylov cb19c3829f Enhanced notifications and admin: CSV export for users/payments, better feedback/navigation, DEV_MODE checks for notifications, .csv in .gitignore, fixed trial expiry with TRIAL_TIME. 2024-11-26 02:52:15 +03:00
Zahar Izmailov b42cc6b484 Merge branch 'Vladless:main' into main 2024-11-26 00:27:58 +03:00
Vladless 4cf1f5dad8 back button 2024-11-25 01:16:06 +03:00
Vladless d863eb1ed9 fix session 2024-11-25 00:58:46 +03:00
Zakhar Izmaylov cbd0902e42 Refactor notification and admin user editor handlers to improve user feedback. Comment out inactive trial user notifications in notifications.py. Enhance error messages with consistent "Назад" button for better navigation in admin_user_editor.py. 2024-11-25 00:29:06 +03:00
Zakhar Izmaylov fa2b45104c Update button texts in start and admin user editor handlers for improved navigation. Change "Личный кабинет" to "Поддержка" in start.py and adjust "Назад" callback data in admin_user_editor.py for consistency. 2024-11-25 00:16:12 +03:00
Zakhar Izmaylov bac2dbaf85 Refactor import statements in notification, profile, key management, and keys handlers for improved organization and consistency. Ensure RENEWAL_PLANS is included in all relevant files. 2024-11-24 23:45:38 +03:00
Zakhar Izmaylov f9e29f4fa9 Refactor notification and profile handlers to include RENEWAL_PLANS from config. Update key management and keys handlers to streamline imports and enhance code organization. 2024-11-24 23:45:27 +03:00
Zahar Izmailov 82985aa96e Merge branch 'Vladless:main' into main 2024-11-24 23:37:58 +03:00
Vladless d34f49c9b2 3.2 2024-11-24 23:10:22 +03:00
Vladless bbe355008a reset traffic 2024-11-24 23:02:34 +03:00
Zakhar Izmaylov ae115ba13b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 22:37:23 +03:00
Zakhar Izmaylov dc7da4441a Remove unused __init__.py file and update payment handler callback buttons to unify navigation. Change "back_to_profile" callback to "pay" in multiple payment processing files for consistency. 2024-11-24 22:35:43 +03:00
Vladless d91465c435 ID_Inbound 2024-11-24 22:29:32 +03:00
Vladless 9cb9b9441a ID_Inbound 2024-11-24 22:29:05 +03:00
Vladislav Lisitsyn ac3a4a397f Merge pull request #100 from izzzzzi/main
Refactoring and Update
2024-11-24 21:54:53 +03:00
Vladislav Lisitsyn 995964f70a Merge branch 'main' into main 2024-11-24 21:54:30 +03:00
Zakhar Izmaylov 708b0be31f Refactor database notification query to remove unnecessary string conversion for hours parameter, improving data handling efficiency. 2024-11-24 20:48:16 +03:00
Zakhar Izmaylov 052124d483 Refactor database and notification handlers for improved readability and consistency. Clean up import statements, enhance logging messages, and streamline function calls across multiple files. Ensure proper session handling in payment processing functions. 2024-11-24 20:29:22 +03:00
Vladless b40bf3f833 fix back cryptobot 2024-11-24 20:19:23 +03:00
Zakhar Izmaylov 11d7005954 Enhance notification system for inactive trial users by adding notification time checks and logging. Implement functionality to record notifications sent to users, ensuring they are notified no more than once every 24 hours. 2024-11-24 20:02:40 +03:00
Zakhar Izmaylov 7893be6ef8 Refactor referral system to support multi-level bonuses and enhance logging. Update database interactions for referral handling and statistics retrieval. Change key display format in notifications and admin user editor for better readability. 2024-11-24 19:38:59 +03:00
Zakhar Izmaylov 18ca2b275c Update table 2024-11-24 14:14:06 +03:00
Zakhar Izmaylov bc333b12c2 Fix delete msg 2024-11-24 13:51:18 +03:00
Zakhar Izmaylov c3e052f73c notify_inactive_trial_users 2024-11-24 11:58:43 +03:00
Zakhar Izmaylov 29f606736b Fix admin 2024-11-24 11:51:17 +03:00
Zakhar Izmaylov ce17181be5 Update connection_exists 2024-11-24 11:42:29 +03:00
Zakhar Izmaylov 041c8e1511 Fix srssion 2024-11-24 11:26:54 +03:00
Zakhar Izmaylov 270f01511d Update id 2024-11-24 11:07:52 +03:00
Zakhar Izmaylov a352901d85 Fix 2024-11-24 10:54:08 +03:00
Zakhar Izmaylov 8e5ce5768a Fix get_trial 2024-11-24 10:35:12 +03:00
Zakhar Izmaylov 97c03d0396 Add logging 2024-11-24 09:15:09 +03:00
Zakhar Izmaylov 1b9b3e6729 Fix trial_status 2024-11-24 08:27:27 +03:00
Zakhar Izmaylov 3f336919ad Fix 2024-11-24 08:13:52 +03:00
Zakhar Izmaylov 8f8e89667d Update InlineKeyboardBuilder 2024-11-24 01:57:42 +03:00
Zakhar Izmaylov 88b05547de Fix 2024-11-24 01:47:37 +03:00
Zakhar Izmaylov c35d067306 Add notify_inactive_trial_users 2024-11-24 01:34:39 +03:00
Zakhar Izmaylov 06489967f8 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:24:10 +03:00
Zakhar Izmaylov 47e45cf596 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:19:06 +03:00
Zakhar Izmaylov bc77c6a5ff Fix typing 2024-11-24 01:19:00 +03:00
Zakhar Izmaylov 31eb551b6d Refactoring 2024-11-24 01:18:42 +03:00
Vladless 122b82253e update clusters 2024-11-23 20:21:47 +03:00
Vladless 5c79302cd6 fix users 2024-11-23 17:30:39 +03:00
Vladislav Lisitsyn 34a99ceb98 Merge pull request #98 from izzzzzi/main
fix ref/serach from username and new stats in admin
2024-11-23 08:18:10 +03:00
Zakhar Izmaylov 576a315baa Fix admin 2024-11-23 07:14:58 +03:00
Zakhar Izmaylov 73965b64dd Add pylint 2024-11-22 21:34:56 +03:00
Zakhar Izmaylov 9673f10af7 Update config 2024-11-22 21:24:55 +03:00
Zakhar Izmaylov 8a9825f629 Fix ref 2024-11-22 17:45:38 +03:00
Zakhar Izmaylov fc830a427c Update text admin 2024-11-22 01:14:23 +03:00
Zakhar Izmaylov 26e8e91cb7 Update push 2024-11-22 01:12:19 +03:00
Zakhar Izmaylov 4cae37ef33 Add search from username 2024-11-22 00:43:23 +03:00
Zakhar Izmaylov a04a6bd5d2 Update user_stats_menu 2024-11-21 21:14:44 +03:00
Zakhar Izmaylov ac632a5e96 Update instructions 2024-11-21 20:49:58 +03:00
Zakhar Izmaylov 6701222697 Update text and add view_tariffs 2024-11-21 20:31:24 +03:00
Zakhar Izmaylov f42fe4236e Fix send_to_alls 2024-11-20 17:30:10 +03:00
Zakhar Izmaylov 3041e0a208 Update admin 2024-11-20 17:18:28 +03:00
Zakhar Izmaylov 61bf3cd129 Add new table and add stats in admin 2024-11-20 16:47:21 +03:00
Zakhar Izmaylov fb34af114f Update logger 2024-11-20 15:46:46 +03:00
Zakhar Izmaylov 56678abbb4 Fix img and logger add button back for donate 2024-11-20 15:36:25 +03:00
Zahar Izmailov 347805a8ae Merge branch 'Vladless:main' into main 2024-11-20 15:28:44 +03:00
Zakhar Izmaylov 2438ac58e1 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-20 09:43:22 +03:00
Zakhar Izmaylov 23b60389c0 Fix 2024-11-20 09:43:16 +03:00
Vladless a58e45133a fix bugs 2024-11-20 00:18:29 +03:00
Vladless 6fc26754bf clusters update 2024-11-19 19:31:31 +03:00
Vladless 8bfdc6e319 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-19 14:58:25 +03:00
Vladless 8d4e32c86c security update 1.0 2024-11-19 14:58:07 +03:00
Vladislav Lisitsyn f16226e48e Update start.py 2024-11-19 11:52:06 +03:00
Vladless 05bef3d389 security update 1.0 2024-11-19 11:43:22 +03:00
Vladless d811ba5623 security update 1.0 2024-11-19 11:21:34 +03:00
Vladislav Lisitsyn abd6c03ba0 Merge pull request #97 from izzzzzi/main
Fix key and session
2024-11-19 09:03:20 +03:00
Zakhar Izmaylov 84a7c1c930 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-19 08:32:33 +03:00
Zakhar Izmaylov 96b8ad8555 Fix key and session 2024-11-19 08:32:30 +03:00
Vladislav Lisitsyn e4f9301591 Merge pull request #96 from izzzzzi/main
Add back button for coupons
2024-11-18 22:51:16 +03:00
Vladless 1eb60dc0c2 renew/delete 2024-11-18 22:40:59 +03:00
Zakhar Izmaylov cbcae665e4 Fix admin 2024-11-18 22:19:17 +03:00
Zakhar Izmaylov 4da82282de Add back button for coupons 2024-11-18 22:03:51 +03:00
Vladless 1548907ead add time in robokassa 2024-11-18 20:03:45 +03:00
Vladless 854c6fa536 fix update_sub 2024-11-18 16:04:29 +03:00
Vladless 09ca65de0c Fixed unexpected behavior of the device selection button 2024-11-18 14:57:12 +03:00
Vladless 52613d9feb Fixed unexpected behavior of the device selection button 2024-11-18 14:56:41 +03:00
Vladislav Lisitsyn 4878cc2eab Merge pull request #95 from izzzzzi/main
Add admin middleware
2024-11-18 12:54:05 +03:00
Zakhar Izmaylov 7e2f56f20b Update Middleware 2024-11-17 23:42:09 +03:00
Zakhar Izmaylov 6b2914bff4 Fix 2024-11-17 16:24:12 +03:00
Zakhar Izmaylov 10b45ec226 Add button 2024-11-17 16:19:47 +03:00
Zakhar Izmaylov 23ec7e3ccb Formatting 2024-11-17 16:15:51 +03:00
Zakhar Izmaylov 3aaa8d1ea3 Fix Middleware 2024-11-17 16:14:35 +03:00
Zakhar Izmaylov a46e8d3a5a Fix admin 2024-11-17 16:10:58 +03:00
Zakhar Izmaylov 6f9ceb1a89 Fix admin 2024-11-17 16:05:20 +03:00
Zakhar Izmaylov d5dbb39444 Add default admin 2024-11-17 16:01:18 +03:00
Zakhar Izmaylov 0f0af0c194 Fix start 2024-11-17 15:53:31 +03:00
Zakhar Izmaylov 545345a020 Fix commands 2024-11-17 15:47:31 +03:00
Zakhar Izmaylov 0766cda29e Fix start 2024-11-17 15:44:41 +03:00
Zakhar Izmaylov 6563b7dc67 Fix 2024-11-17 15:39:53 +03:00
Zakhar Izmaylov 5b8b950aaf Fix admin middleware 2024-11-17 15:39:27 +03:00
Zakhar Izmaylov 8020fd9a7d Fix send backup 2024-11-17 15:29:56 +03:00
Zakhar Izmaylov 8836dc577e Fix admin and create coupons 2024-11-17 15:26:03 +03:00
Zakhar Izmaylov a2be86b134 Fix admin 2024-11-17 15:10:28 +03:00
Zakhar Izmaylov e9bc47680a Update img 2024-11-17 14:17:51 +03:00
Zakhar Izmaylov da62ab5a90 Fix img 2024-11-17 14:15:27 +03:00
Zakhar Izmaylov dd84c8dd3a Fix 2024-11-17 14:09:05 +03:00
Zakhar Izmaylov 7e40a1f96c Update delete models 2024-11-17 14:08:51 +03:00
Zakhar Izmaylov 69f880af2b Add new table and middleware 2024-11-17 14:05:31 +03:00
Zakhar Izmaylov 7c93c8cc31 Refactoring and fix 2024-11-17 13:33:17 +03:00
Zakhar Izmaylov c2d06e10b8 Add admin middleware 2024-11-17 13:11:49 +03:00
Zahar Izmailov 9c8fafabb1 Merge branch 'Vladless:main' into main 2024-11-17 10:42:43 +03:00
Vladless c63708cb83 version 2024-11-17 03:20:06 +03:00
Vladless 8f9b84277b add version 2024-11-17 01:32:52 +03:00
Vladless 8363d319c2 robokassa release 2024-11-17 00:01:26 +03:00
Zakhar Izmaylov a353d41738 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-16 23:56:54 +03:00
Zakhar Izmaylov 0cfd656d86 #TODO Admin and bash 2024-11-16 23:56:45 +03:00
Vladislav Lisitsyn 5001fc94fe Merge pull request #94 from izzzzzi/main
Add scripts
2024-11-16 22:54:50 +03:00
Zahar Izmailov a014596d51 Merge branch 'Vladless:main' into main 2024-11-16 21:06:04 +03:00
Zakhar Izmaylov 64b4cfd1b2 Delete nginx config 2024-11-16 21:04:06 +03:00
Zakhar Izmaylov dcc7d5e218 Update 2024-11-16 21:03:39 +03:00
Zakhar Izmaylov ca8c5691d3 Add bash for node 2024-11-16 19:05:09 +03:00
Vladislav Lisitsyn b257d8c66d Merge pull request #93 from izzzzzi/main
Add dry logging
2024-11-16 18:46:12 +03:00
Zakhar Izmaylov 601598dfd2 Add dry logging 2024-11-16 18:01:28 +03:00
Vladless aef9c344ab fix delete 2024-11-16 00:44:12 +03:00
Vladless f6f1227dd8 ip 2024-11-15 23:59:42 +03:00
Vladless 6d750c91f8 fix back_button 2024-11-15 23:27:08 +03:00
Vladless 533d4fa64c Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 23:15:41 +03:00
Vladless 0be6ed0631 fix logging 2024-11-15 23:15:19 +03:00
Vladislav Lisitsyn 346dfaee2e Update start.py 2024-11-15 22:06:23 +03:00
Vladless e2796d22e2 pc instructions and button 2024-11-15 21:49:27 +03:00
Vladless b96c42c153 fix cluster 2024-11-15 20:30:57 +03:00
Vladless 83b5a8608a changing notifications 2024-11-15 20:15:19 +03:00
Vladless 1abe1c7166 logging for add client 2024-11-15 19:24:09 +03:00
Vladless c35bd82ebe formatting 2024-11-15 19:19:13 +03:00
Vladless b6ed49ad82 rm DEBUG 2024-11-15 19:16:37 +03:00
Vladless e70a2d74ba total_gb 2024-11-15 19:16:06 +03:00
Vladislav Lisitsyn d3f00acd5c Merge pull request #92 from izzzzzi/main
Fix pay stars
2024-11-15 18:03:04 +03:00
Zakhar Izmaylov 00b0823eb3 Fix router 2024-11-15 17:28:54 +03:00
Zakhar Izmaylov ab488ed0a3 Fix pay stars 2024-11-15 17:28:06 +03:00
Zakhar Izmaylov 12046b9c5c Fix pay stars 2024-11-15 17:25:42 +03:00
Vladless a457598109 fix donate 2024-11-15 15:48:52 +03:00
Vladless 1f2f6ee82a fix donate 2024-11-15 15:40:20 +03:00
Vladislav Lisitsyn 8d41b19767 Merge pull request #91 from izzzzzi/main
Add donate
2024-11-15 15:09:46 +03:00
Vladless cb59a1d176 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 15:09:01 +03:00
Vladless 2961477ecf robokassa_beta 2024-11-15 15:07:51 +03:00
Zakhar Izmaylov 5db30c05f7 Add donate 2024-11-15 14:38:14 +03:00
Vladislav Lisitsyn 90aa095621 Merge pull request #90 from izzzzzi/main
Added support for the list of admins
2024-11-15 13:51:33 +03:00
Vladless c7d5ff94c0 fix message delete 2024-11-15 13:51:12 +03:00
Zahar Izmailov 136832b22a Merge branch 'Vladless:main' into main 2024-11-15 13:37:57 +03:00
Zakhar Izmaylov 79c723ec06 Added support for the list of admins 2024-11-15 13:37:24 +03:00
Vladislav Lisitsyn 43883a3c06 Merge pull request #89 from izzzzzi/main
Fix stars
2024-11-15 13:30:46 +03:00
Zakhar Izmaylov 0eba072826 Fix stars 2024-11-15 13:25:57 +03:00
Vladislav Lisitsyn 0604675be6 Merge pull request #88 from izzzzzi/main
Add dev mode and fix menu and fix stars and cryptobot
2024-11-15 12:53:05 +03:00
Zakhar Izmaylov 75147eb5a1 Add multy pay 2024-11-15 11:58:48 +03:00
Zakhar Izmaylov 5dd1fab7e9 Add dev mode and fix menu and fix stars and cryptobot 2024-11-15 11:40:25 +03:00
Vladless 31fa90aef1 coupons initial 2024-11-15 03:03:24 +03:00
Vladless 4f0df51abd fix payments 2024-11-14 15:13:52 +03:00
Vladless 584fec2dc8 servers in clusters/total_gb 2024-11-14 14:17:16 +03:00
Vladless 5744d03ad9 servers in clusters/total_gb 2024-11-14 03:04:23 +03:00
Vladless 4aa521c6b9 servers in clusters/total_gb 2024-11-14 03:03:32 +03:00
Vladless 545ceac0b8 custom trial time 2024-11-13 18:51:25 +03:00
Vladislav Lisitsyn bd699b5700 Merge pull request #87 from izzzzzi/main
Update keyboards and restructuring for filters
2024-11-13 18:04:38 +03:00
Vladless cb7684b015 custom bonus 2024-11-13 17:27:08 +03:00
Zakhar Izmaylov 567c04d74e Add models.py #TODO 2024-11-13 17:10:25 +03:00
Zakhar Izmaylov ac3451a209 Add DatabaseMiddleware #TODO 2024-11-13 17:03:18 +03:00
Zakhar Izmaylov 8f4403accd Restructuring for filters 2024-11-13 16:53:23 +03:00
Zakhar Izmaylov 8b19cf0956 Update keyboards, update texts.py 2024-11-13 16:48:56 +03:00
Vladless 5db5c8377b add TOTAL_GB 2024-11-13 16:18:16 +03:00
Vladless 59ca46908e small fix 2024-11-13 15:38:20 +03:00
Vladless efd56e513d small fix 2024-11-13 05:41:49 +03:00
Vladless 6857e2b9c8 redesign/bug fixes/admin trial 2024-11-13 04:05:43 +03:00
Vladless 72438fea7e redesign/bug fixes/admin trial 2024-11-13 04:04:40 +03:00
Vladless 7a4aa5099d redesign/bug fixes/admin trial 2024-11-13 04:01:13 +03:00
Vladless 13efb0cc45 redesign/bug fixes/admin trial 2024-11-13 03:59:34 +03:00
Vladislav Lisitsyn 0c65d378cd Merge pull request #86 from izzzzzi/main
Refactoring admin
2024-11-12 13:11:09 +03:00
Zakhar Izmaylov e73b8e6538 Refactoring user editor 2024-11-12 08:10:40 +03:00
Zakhar Izmaylov 5bbc248a4b Refactoring 2024-11-12 08:10:28 +03:00
Zakhar Izmaylov b37c724aa0 Refactoring admin 2024-11-12 07:51:54 +03:00
Vladislav Lisitsyn b89b0fe1ce Merge pull request #85 from izzzzzi/main
Fix formating and freekassa
2024-11-12 00:17:33 +03:00
Zakhar Izmaylov 577592479b Fix formating and freekassa 2024-11-12 00:11:33 +03:00
Vladless 20031eb843 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot into izzzzzi-main 2024-11-11 23:35:49 +03:00
Zakhar Izmaylov 35939d89b8 Fix texts 2024-11-11 23:35:01 +03:00
Zakhar Izmaylov 2ae03cb6b0 Small refactoring admin 2024-11-11 23:17:09 +03:00
Zakhar Izmaylov 47be269ddf Fix bot.py 2024-11-11 23:11:14 +03:00
Zakhar Izmaylov ee30f5ab95 Fix, Update menu 2024-11-11 23:07:54 +03:00
Vladislav Lisitsyn e473988750 Merge pull request #83 from izzzzzi/main
Add multypay
2024-11-11 23:03:26 +03:00
Zakhar Izmaylov 2a768568a9 Remove btn 2024-11-11 22:49:45 +03:00
Zakhar Izmaylov 1b45580390 Add multypay 2024-11-11 22:45:11 +03:00
Vladislav Lisitsyn 37f8cf9881 Merge pull request #82 from izzzzzi/main
Add stars pay(beta)
2024-11-11 22:38:17 +03:00
Vladless 6c84c873cf fix freekassa 2024-11-11 22:37:41 +03:00
Zakhar Izmaylov a2a3d85cf5 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-11 22:33:03 +03:00
Zakhar Izmaylov 59db7344a0 Fix cryptobot 2024-11-11 22:31:52 +03:00
Zahar Izmailov 8f2d027a83 Merge branch 'Vladless:main' into main 2024-11-11 22:28:00 +03:00
Zakhar Izmaylov 0a37596471 Add stars pay(beta) 2024-11-11 22:26:10 +03:00
Vladislav Lisitsyn 2f0208a905 Merge pull request #81 from izzzzzi/main
Add crypto(beta), fix import, fix requirements.txt
2024-11-11 22:25:45 +03:00
Zakhar Izmaylov af8ecffb6d Add crypto(beta), fix import, fix requirements.txt 2024-11-11 21:51:34 +03:00
Vladislav Lisitsyn 08d38e651b Update README.md 2024-11-11 17:51:44 +03:00
Vladislav Lisitsyn 0db832473a Update README.md 2024-11-11 17:51:12 +03:00
Vladislav Lisitsyn e7f682f02d Merge pull request #80 from izzzzzi/main
Add base cryptobot
2024-11-11 17:48:31 +03:00
Vladless e002035518 minPython 3.10/py3xui/subs/rm_artefacts 2024-11-11 17:47:26 +03:00
Zakhar Izmaylov c16f2e8c72 Add base cryptobot 2024-11-11 14:26:00 +03:00
Vladless d588726838 isort settings 2024-11-11 09:22:14 +03:00
Vladless b5c8f4fa28 bug fix 2024-11-11 07:42:22 +03:00
Vladislav Lisitsyn dab1a8d754 Merge pull request #79 from izzzzzi/main
Add make and config for formatting
2024-11-10 23:12:42 +03:00
Zakhar Izmaylov 27f5b9d43a Add make and config for formatting 2024-11-10 11:02:15 +03:00
Vladless 4bf5b3a87d reset traffic 2024-11-10 03:31:19 +03:00
Vladless adaae41826 bug and fix 2024-11-10 02:25:36 +03:00
Vladislav Lisitsyn aaa754f66f Merge pull request #78 from izzzzzi/main
Refactoring and fix
2024-11-09 23:59:29 +03:00
Zakhar Izmaylov a294f123d2 Fix 2024-11-09 23:20:08 +03:00
Zakhar Izmaylov 617e599e74 Small fix 2024-11-09 16:23:39 +03:00
Zakhar Izmaylov 426e2a881a Refactoring admin_panel.py 2024-11-09 15:54:16 +03:00
Zakhar Izmaylov 96c9792dce Refactoring notifications.py and commands.py 2024-11-09 15:49:05 +03:00
Zakhar Izmaylov 0864a8a1ce Refactoring profile.py 2024-11-09 15:35:22 +03:00
Zakhar Izmaylov 5963cdc854 Refactoring start.py 2024-11-09 14:38:30 +03:00
Zakhar Izmaylov 04c7616661 Add middleware logging 2024-11-09 12:20:07 +03:00
Zakhar Izmaylov ca68eb58d3 Add middlewaer datatabase # TODO 2024-11-09 12:17:07 +03:00
Zakhar Izmaylov b8b6f29c48 Delete wraps 2024-11-09 12:01:24 +03:00
Zakhar Izmaylov 1d9aa3b432 isort and black 2024-11-09 11:44:15 +03:00
Zakhar Izmaylov b12bb33242 Migrate logging for loguru 2024-11-09 11:41:48 +03:00
Zakhar Izmaylov 1f7d0a263f Fix logging 2024-11-09 11:33:17 +03:00
Zakhar Izmaylov 34e213cc78 Fix callback_query 2024-11-09 11:17:11 +03:00
Zakhar Izmaylov 6651034413 Fix PAYMENT_OPTIONS and RENEWAL_PLANS 2024-11-09 10:52:41 +03:00
Zakhar Izmaylov 800a86c500 Refactoring and fix 2024-11-09 10:36:16 +03:00
Vladislav Lisitsyn 1806a3db4b Update README.md 2024-11-08 07:13:42 +03:00
Vladislav Lisitsyn 6a1f3f810e Update README.md 2024-11-08 07:08:20 +03:00
Vladislav Lisitsyn cf923e1e9a Merge pull request #77 from Vladless/subscriptions-feature
preview v2.
2024-11-08 07:07:00 +03:00
Vladless d122b59c6d preview v2. 2024-11-08 07:06:32 +03:00
Vladislav Lisitsyn 2bd4d802a3 Merge pull request #76 from Vladless/subscriptions-feature
preview v3.
2024-11-08 07:05:30 +03:00
Vladless 012ed787e8 preview v3. 2024-11-08 07:04:51 +03:00
Vladislav Lisitsyn 6ada73b6cb Merge pull request #75 from Vladless/subscriptions-feature
v3. Поправлены уведомления для новой версии.
2024-11-08 06:53:12 +03:00
Vladless 719865fe83 v3. Поправлены уведомления для новой версии. 2024-11-08 06:52:37 +03:00
Vladislav Lisitsyn c34c5ef7af Merge pull request #74 from Vladless/subscriptions-feature
v3. Админка переведена на новую версию.
2024-11-08 06:43:15 +03:00
Vladless 9c85ae31e5 v3. Админка переведена на новую версию. 2024-11-08 06:42:12 +03:00
Vladislav Lisitsyn 987602551e Merge pull request #73 from Vladless/subscriptions-feature
Админка
2024-11-08 06:03:34 +03:00
Vladless 9dcbc7d736 v3. Админка переведена на новую версию. 2024-11-08 06:02:29 +03:00
Vladless 92fbe6ced5 v3. Админка переведена на новую версию. 2024-11-08 06:02:07 +03:00
Vladislav Lisitsyn d79d7004ec Update README.md 2024-11-07 07:58:58 +03:00
Vladislav Lisitsyn 4d1a75939e Update README.md 2024-11-07 07:58:41 +03:00
Vladislav Lisitsyn 1c9af07a7d Merge pull request #72 from Vladless/subscriptions-feature
v3. Полноценные подписки
2024-11-07 07:56:55 +03:00
Vladless 8f16fa4c1a v3. Полноценные подписки 2024-11-07 07:53:29 +03:00
Vladislav Lisitsyn fe2ae18f20 Merge pull request #71 from Vladless/subscriptions-feature
Проверка ключей
2024-11-05 22:02:36 +03:00
Vladless aa73bdd066 Проверка ключей 2024-11-05 22:00:32 +03:00
Vladislav Lisitsyn a48bc4a0d8 Merge pull request #70 from Vladless/subscriptions-feature
Админка. Расширение
2024-11-04 01:10:40 +03:00
Vladless 3461f47cfd Админка. Расширение 2024-11-04 01:10:02 +03:00
Vladislav Lisitsyn 38d99b2a47 Merge pull request #69 from Vladless/subscriptions-feature
Переработка уведомлений
2024-11-03 23:42:07 +03:00
Vladless 25a51549ac Переработка уведомлений 2024-11-03 23:41:26 +03:00
Vladislav Lisitsyn bca136a55e Merge pull request #68 from Vladless/subscriptions-feature
Профиль. Логи
2024-11-03 21:54:11 +03:00
Vladless 1953e7c0cb Профиль. Логи 2024-11-03 21:52:59 +03:00
Vladislav Lisitsyn 6754a7da93 Merge pull request #67 from Vladless/subscriptions-feature
Уведомления/Профиль. Логгирование
2024-11-03 21:40:41 +03:00
Vladless 8fb3b1691f Профиль. Логи 2024-11-03 21:39:56 +03:00
Vladless 91bc384508 Уведомления. Правки 2024-11-03 21:38:26 +03:00
Vladislav Lisitsyn 666c3b34af Merge pull request #66 from Vladless/subscriptions-feature
Уведомления. Правки
2024-11-03 01:49:54 +03:00
Vladless 38817c82f7 Уведомления. Правки 2024-11-03 01:49:02 +03:00
Vladislav Lisitsyn 10a6179b7c Update README.md 2024-11-03 00:34:00 +03:00
Vladislav Lisitsyn 411647585d Merge pull request #65 from Vladless/subscriptions-feature
добавление freekassa
2024-11-03 00:32:38 +03:00
Vladless f5c15546e2 добавление freekassa 2024-11-03 00:31:57 +03:00
Vladislav Lisitsyn 109a968941 Update README.md 2024-11-02 04:30:23 +03:00
Vladislav Lisitsyn b32cf0a9a0 Update README.md 2024-11-02 04:30:01 +03:00
Vladislav Lisitsyn bbd68becfc Update README.md 2024-11-02 04:29:12 +03:00
Vladislav Lisitsyn c4400466dd Merge pull request #64 from Vladless/subscriptions-feature
Подписки. Обновление ключей. Кнопки
2024-11-02 04:25:49 +03:00
Vladislav Lisitsyn da4ed7dd22 Merge branch 'main' into subscriptions-feature 2024-11-02 04:25:39 +03:00
Vladless c0fc972ce5 удобная смена подписки 2024-11-02 04:17:54 +03:00
Vladless cdcfcb7987 перевод на v2raytun 2024-11-02 03:10:49 +03:00
Vladless 48528e611f перевод на v2raytun 2024-11-02 01:17:04 +03:00
Vladless 0e982e8361 перевод на v2raytun 2024-11-01 23:52:21 +03:00
Vladislav Lisitsyn cc9715ca7b Update README.md 2024-10-31 23:59:20 +03:00
Vladislav Lisitsyn 6cb6aa4c67 Merge pull request #63 from Vladless/subscriptions-feature
Кнопка автодобавления конфигурации в клиентское приложение
2024-10-30 20:07:12 +03:00
Vladless 040e32a50a Кнопка автодобавления конфигурации в клиентское приложение 2024-10-30 20:05:38 +03:00
Vladislav Lisitsyn 40092c7085 Merge pull request #62 from Vladless/subscriptions-feature
Подписки. Полная версия
2024-10-30 18:03:36 +03:00
Vladless 1bd138e78b Подписки. Полная версия 2024-10-30 18:03:04 +03:00
Vladless fae10faf7e ReadMe 2024-10-30 04:50:53 +03:00
Vladless 51d0b71e1d ReadMe 2024-10-30 03:57:27 +03:00
Vladless 9cf29cc59c Подписки. initial commit 2024-10-30 02:35:27 +03:00
Vladislav Lisitsyn 3e680fd31d Merge pull request #61 from Vladless/new1
правки
2024-10-30 00:02:43 +03:00
Vladislav Lisitsyn 2e5d581fb5 Delete handlers/__pycache__ directory 2024-10-30 00:02:27 +03:00
Vladless 8a235e129d правки 2024-10-30 00:00:18 +03:00
Vladislav Lisitsyn d899ddf663 Merge pull request #60 from Vladless/new1
исправлен траффик при продлении
2024-10-29 23:46:37 +03:00
Vladless cd6d05320e исправлен траффик при продлении 2024-10-29 23:45:27 +03:00
Vladislav Lisitsyn 4ef0ce9d19 Merge pull request #59 from Vladless/new1
Уведомления. Имя сервера вместо id
2024-10-29 23:20:22 +03:00
Vladless c78fee4a6b Уведомления. Имя сервера вместо id 2024-10-29 23:19:54 +03:00
Vladislav Lisitsyn abc46bf172 Merge pull request #57 from Vladless/new1
rm update_bot
2024-10-28 19:50:29 +03:00
Vladless 3680d0606a rm update_bot 2024-10-28 19:49:03 +03:00
Vladislav Lisitsyn 45e4b2f696 Merge pull request #56 from Vladless/new1
New1
2024-10-28 01:01:44 +03:00
Vladislav Lisitsyn f11e3f255e Merge branch 'main' into new1 2024-10-28 01:01:34 +03:00
Vladless 3915f37e55 ReadMe 2024-10-28 01:00:20 +03:00
Vladless 0a4e1e8c50 rm 2024-10-28 00:54:22 +03:00
Vladless 0d390bb9cc rm 2024-10-28 00:47:09 +03:00
Vladislav Lisitsyn 15f3e89e61 Merge pull request #55 from Vladless/new1
rm
2024-10-28 00:39:58 +03:00
Vladislav Lisitsyn 869a0e8472 rm 2024-10-28 00:35:07 +03:00
Vladislav Lisitsyn 4f0c64b786 rm 2024-10-28 00:30:27 +03:00
Vladislav Lisitsyn 10e10b7157 Merge pull request #54 from Vladless/new1
Уведомления. Продление по кнопке
2024-10-27 23:48:03 +03:00
Vladless b192b43bd3 Уведомления. Продление по кнопке 2024-10-27 22:58:56 +03:00
Vladislav Lisitsyn e129a06d9b Merge pull request #53 from Vladless/new1
/admin. GUI интерфейс в боте
2024-10-27 22:47:09 +03:00
Vladless c678a9bfce /admin. GUI интерфейс в боте 2024-10-27 22:45:16 +03:00
Vladislav Lisitsyn c8cdfc980b Merge pull request #52 from Vladless/new1
Админ. Установка времени ключей
2024-10-27 06:16:55 +03:00
Vladless 146512b2ab Админ. Установка времени ключей 2024-10-27 06:15:34 +03:00
Vladislav Lisitsyn 30c03677fe Merge pull request #51 from Vladless/new1
New1
2024-10-27 03:55:35 +03:00
Vladless 2baf63dccd обложка 2024-10-27 03:54:52 +03:00
Vladless 363bf80bf4 обложка 2024-10-27 03:54:19 +03:00
Vladislav Lisitsyn 50146be4a6 Delete handlers/__pycache__ directory 2024-10-27 03:20:45 +03:00
Vladislav Lisitsyn 09673a145c Merge pull request #50 from Vladless/new1
Полная реструктуризация. Тексты все в texts.py
2024-10-27 03:17:40 +03:00
Vladless 16561610b6 Полная реструктуризация. Тексты все в texts.py 2024-10-27 03:16:58 +03:00
Vladislav Lisitsyn a71afc554e Merge pull request #49 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:30:08 +03:00
Vladless 7512868219 Порядок. Исправления мелких недочетов 2024-10-27 01:29:34 +03:00
Vladislav Lisitsyn 9008ba3b99 Merge pull request #48 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:10:58 +03:00
Vladless 83d589fff5 Порядок. Исправления мелких недочетов 2024-10-27 01:03:00 +03:00
Vladislav Lisitsyn 5cb07aa68a Update trial_key.py 2024-10-26 16:05:58 +03:00
Vladislav Lisitsyn dd5463881d Merge pull request #47 from Vladless/new1
админ. команда баланса
2024-10-26 03:11:00 +03:00
Vladless 77084b1ff2 админ. команда баланса 2024-10-26 03:10:28 +03:00
Vladislav Lisitsyn e965652e1f Merge pull request #46 from Vladless/new1
админка в боте
2024-10-26 02:45:43 +03:00
Vladless 1c3d24527f админка в боте 2024-10-26 02:45:19 +03:00
Vladislav Lisitsyn 9bfb39f1ae Merge pull request #45 from Vladless/new1
админка в боте
2024-10-26 02:40:03 +03:00
Vladless 0c46a70f53 админка в боте 2024-10-26 02:39:35 +03:00
Vladislav Lisitsyn dedeac394b Merge pull request #44 from Vladless/new1
админка в боте
2024-10-26 02:34:39 +03:00
Vladless 57e813e3d4 админка в боте 2024-10-26 02:34:05 +03:00
Vladislav Lisitsyn 02847dbb93 Merge pull request #43 from Vladless/new1
ReadMe
2024-10-26 02:19:28 +03:00
Vladless 470a05018e ReadMe 2024-10-26 02:18:48 +03:00
Vladislav Lisitsyn 649d8060df Merge pull request #42 from Vladless/new1
Docker и docker-compose
2024-10-26 02:13:46 +03:00
Vladless 5a04b360db Docker и docker-compose 2024-10-26 02:12:34 +03:00
Vladislav Lisitsyn 76d2f28128 Merge pull request #41 from Vladless/new1
создание ключа в 1 кнопку
2024-10-26 00:57:22 +03:00
Vladless 37561c086f создание ключа в 1 кнопку 2024-10-26 00:55:03 +03:00
Vladislav Lisitsyn 262b7c9489 Merge pull request #40 from Vladless/new1
Автопродление ключа
2024-10-25 22:21:07 +03:00
Vladless 73cfe74c1c Автопродление ключа 2024-10-25 22:20:12 +03:00
Vladislav Lisitsyn 68f6994b94 Merge pull request #39 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:56:48 +03:00
Vladless 57ecdbbda5 Логи. Уведомления. Тексты 2024-10-25 20:56:02 +03:00
Vladislav Lisitsyn 3ff07e4c76 Merge pull request #38 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:46:56 +03:00
Vladless 8d4bec79b3 Логи. Уведомления. Тексты 2024-10-25 20:45:12 +03:00
Vladislav Lisitsyn 546f4c52cb Merge pull request #37 from Vladless/new1
New1
2024-10-22 02:07:33 +03:00
Vladless b223b4463a основной текст в отдельном файле 2024-10-22 02:06:58 +03:00
Vladless 258f8fe2f7 основной текст в отдельном файле 2024-10-22 02:02:17 +03:00
Vladislav Lisitsyn c697498cb4 Merge pull request #36 from Vladless/new1
основной текст в отдельном файле
2024-10-22 01:55:10 +03:00
Vladless 4b1e8b555a основной текст в отдельном файле 2024-10-22 01:53:50 +03:00
Vladless ec4217fc4f поправка подсчета людей 2024-10-22 01:00:56 +03:00
Vladless 49fa3693ad автоввод паролей 2024-10-22 00:30:30 +03:00
Vladless 845a7a287b Бэкапы. команда ручного бэкапа 2024-10-22 00:25:16 +03:00
Vladless b5928722eb Бэкапы. команда ручного бэкапа 2024-10-22 00:13:13 +03:00
Vladless 9f0d0acb53 Бэкапы. Отправка админу 2024-10-21 23:59:26 +03:00
Vladless b4af296384 requirements 2024-10-21 23:40:50 +03:00
Vladless 3466157550 версия бота. Релиз 2024-10-21 03:36:29 +03:00
Vladless 754a33cbc7 версия бота. Релиз 2024-10-21 02:52:34 +03:00
Vladless 8538050fb5 напоминание за сутки у активного клиента 2024-10-21 02:47:59 +03:00
Vladless 165e2955e0 ReadMe.md 2024-10-21 02:35:53 +03:00
Vladless bcce80a52d напоминание за сутки у активного клиента 2024-10-21 02:26:15 +03:00
Vladless 811ca4ff30 ReadMe.md 2024-10-21 02:16:37 +03:00
Vladless 80249609af завершение работы без убивания процесса 2024-10-21 02:12:15 +03:00
Vladless 63039e3537 завершщение работы без убивания процесса 2024-10-21 02:08:47 +03:00
Vladless def9d5a016 резервные копии базы данных и уведомления 2024-10-21 01:23:27 +03:00
Vladless 41dd64f7c8 автопродление ключа 2024-10-13 23:11:39 +03:00
Vladless a0bada3e76 уведомление о неиспользованном триале 2024-10-13 04:02:47 +03:00
Vladless a405f0a5ad ввод своей суммы для пополнения баланса 2024-10-13 03:37:14 +03:00
Vladless 7bd484528a значительная переработка интерфейса 2024-10-13 02:51:45 +03:00
Vladless 2c6b2b9784 значительная переработка интерфейса 2024-10-12 03:14:01 +03:00
Vladless 41ca9b704f ReadMe 2024-10-09 04:01:08 +03:00
Vladislav Lisitsyn 06a1360714 Merge pull request #35 from Vladless/youkassa
ReadMe
2024-10-09 03:41:23 +03:00
Vladislav Lisitsyn 6583f2e6cc Merge branch 'main' into youkassa 2024-10-09 03:41:11 +03:00
Vladless eae768615c ReadMe 2024-10-09 03:40:31 +03:00
Vladislav Lisitsyn 10b4562982 Merge pull request #34 from Vladless/youkassa
Уведомления
2024-10-09 02:16:07 +03:00
Vladless ed5a129b6a логика, отправка уведомлений всем 2024-10-09 02:14:41 +03:00
Vladless dc661dabcc логика, отправка уведомлений всем 2024-10-09 02:13:42 +03:00
Vladless c5e9cb9b18 логика, отправка уведомлений всем 2024-10-09 02:09:22 +03:00
Vladislav Lisitsyn 576a389028 Merge pull request #33 from Vladless/youkassa
aiohttp, уведомления, правки
2024-10-09 01:10:30 +03:00
Vladislav Lisitsyn 709c1064a5 Merge branch 'main' into youkassa 2024-10-09 01:10:23 +03:00
Vladless 8588b89421 aiohttp, уведомления, правки 2024-10-09 00:51:22 +03:00
Vladislav Lisitsyn f7886dcfea Merge pull request #31 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 03:10:09 +03:00
Vladless 60fa6cb4f1 правка сообщения пригласить 2024-10-04 03:09:32 +03:00
Vladislav Lisitsyn 98832d7ade Merge pull request #30 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 02:45:26 +03:00
Vladless dbfed0dc2a правка сообщения пригласить 2024-10-04 02:44:32 +03:00
Vladislav Lisitsyn a674c321de Merge pull request #29 from Vladless/youkassa
Youkassa
2024-10-04 02:39:12 +03:00
Vladless d91e9180ea правка сообщения пригласить 2024-10-04 02:35:48 +03:00
Vladless fbe7e9f820 реферальная система 2024-10-04 02:31:05 +03:00
Vladislav Lisitsyn c7fb1045c2 Merge pull request #28 from Vladless/youkassa
Youkassa
2024-10-04 02:13:12 +03:00
Vladless 4ba3e14961 реферальная система 2024-10-04 02:06:16 +03:00
Vlad 7746835832 чистота кода 2024-10-03 07:20:13 +03:00
Vladislav Lisitsyn 264d6186fa Merge pull request #27 from Vladless/youkassa
Youkassa
2024-10-03 07:09:08 +03:00
Vlad 3f5c449817 чистота кода 2024-10-03 07:08:17 +03:00
Vlad d03907b43c уведомления за 10 часов и при истечении 2024-10-03 06:52:24 +03:00
Vladislav Lisitsyn 56bd72c3bc Merge pull request #26 from Vladless/youkassa
Выбор сервера
2024-10-03 06:45:59 +03:00
Vlad f42ca88e01 поправлен тег 2024-10-03 06:45:07 +03:00
Vlad 87095772fe заполненность серверов в процентах 2024-10-03 06:42:40 +03:00
Vlad bfca706b31 работающий перенос ключей 2024-10-03 06:31:44 +03:00
Vlad bba0e3ebdb работающий перенос ключей 2024-10-03 06:20:38 +03:00
Vlad 4958061a19 выбор сервера, переработка ссылок 2024-10-03 04:43:55 +03:00
Vladislav Lisitsyn 8aec92fe43 Merge pull request #25 from Vladless/youkassa
смена слогана
2024-10-02 05:16:33 +03:00
Vlad f84293f270 смена слогана 2024-10-02 05:12:55 +03:00
Vladislav Lisitsyn aa830c9a37 Merge pull request #24 from Vladless/youkassa
Youkassa
2024-10-02 05:04:01 +03:00
Vlad 58357f2f8b смена слогана 2024-10-02 05:03:08 +03:00
Vlad 19f40c8fad смена слогана 2024-10-02 04:51:48 +03:00
Vlad e1aadf9781 смена слогана 2024-10-02 04:46:05 +03:00
Vladislav Lisitsyn 3198cd13f4 Merge pull request #23 from Vladless/youkassa
новостные сообщения, поправленный requirements
2024-10-02 04:16:36 +03:00
Vlad 67b85478aa новостные сообщения, поправленный requirements 2024-10-02 04:16:04 +03:00
Vladislav Lisitsyn 08cbeceea4 Merge pull request #22 from Vladless/youkassa
истекшие ключи автоматически удаляются
2024-10-02 02:29:44 +03:00
Vlad 60d7e89452 истекшие ключи автоматически удаляются 2024-10-02 02:06:42 +03:00
Vlad 3937cb105c порядок в стурктуре проекта, isort, импорты 2024-10-02 01:35:39 +03:00
Vlad 16c776bf82 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:19:48 +03:00
Vladislav Lisitsyn 467557fb59 Merge pull request #21 from Vladless/youkassa
Доработка платежей, уведомления и переработка интерфейса
2024-10-02 01:13:35 +03:00
Vlad 140020838b Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:13:04 +03:00
Vladislav Lisitsyn 18f329b529 Merge pull request #20 from Vladless/youkassa
Оплата и пополнение баланса через Юкасса
2024-10-01 14:27:58 +03:00
Vlad 271ea09380 Оплата и пополнение баланса через Юкасса 2024-10-01 14:27:16 +03:00
Vladislav Lisitsyn d6dc9ab365 Merge pull request #19 from Vladless/new
WebHooks for YouKassa
2024-10-01 02:10:28 +03:00
Vlad 376cd07b5c WebHooks for YouKassa 2024-10-01 01:20:29 +03:00
Vladislav Lisitsyn 9fe28059c6 Merge pull request #18 from Vladless/new
дополнительные варианты оплаты и продления
2024-10-01 00:47:58 +03:00
Vlad 5742e507d0 дополнительные варианты оплаты и продления 2024-10-01 00:15:01 +03:00
Vladislav Lisitsyn 5549d0a55c Update README.md 2024-09-29 18:04:48 +03:00
Vladislav Lisitsyn dc76929283 Update README.md 2024-09-29 02:31:26 +03:00
Vladislav Lisitsyn 5d5f2b0556 Update README.md 2024-09-29 02:31:06 +03:00
Vladislav Lisitsyn 9d759b949d Update README.md 2024-09-29 02:23:52 +03:00
Vladislav Lisitsyn 69b4da4d43 Merge pull request #17 from Vladless/new
isort и правка импортов
2024-09-29 02:15:16 +03:00
Vlad f03f8a3711 isort и правка импортов 2024-09-29 02:14:49 +03:00
Vladislav Lisitsyn 594b97a752 Update README.md 2024-09-29 02:13:32 +03:00
Vladislav Lisitsyn 04cbcbd817 Create README.md 2024-09-29 02:11:50 +03:00
Vladislav Lisitsyn 7849d8c99f Merge pull request #15 from Vladless/new
правки и доработки интерфейса
2024-09-28 13:13:23 +03:00
Vlad d7ae68e17a правки и доработки интерфейса 2024-09-28 13:12:04 +03:00
Vladislav Lisitsyn 7adb81ca28 Merge pull request #14 from Vladless/new
правки и доработки интерфейса
2024-09-28 04:09:11 +03:00
Vlad d207b64a97 правки и доработки интерфейса 2024-09-28 03:49:43 +03:00
Vlad a104867a35 правки и доработки интерфейса 2024-09-28 03:44:08 +03:00
Vlad 7dd00a2f24 правки и доработки интерфейса 2024-09-28 03:26:28 +03:00
Vladislav Lisitsyn 0e4ca2ac47 Merge pull request #13 from Vladless/new
пополнение баланса даже без ключа
2024-09-26 03:12:12 +03:00
Vlad 19af987979 пополнение баланса даже без ключа 2024-09-26 03:11:22 +03:00
Vladislav Lisitsyn edaeb3a212 Merge pull request #12 from Vladless/new
отображение ключей и создание на сутки
2024-09-26 02:39:03 +03:00
Vlad 0e43b36901 отображение ключей и создание на сутки 2024-09-26 02:38:32 +03:00
Vladislav Lisitsyn cebcad4be1 Merge pull request #11 from Vladless/new
меню
2024-09-25 03:42:39 +03:00
Vlad 34f0205f6d меню 2024-09-25 02:24:25 +03:00
Vladislav Lisitsyn fdf5b5e93d Merge pull request #10 from Vladless/new
New
2024-09-25 01:58:47 +03:00
Vlad 015fbf1d72 добавление уведомлений 2024-09-25 01:58:10 +03:00
Vlad b3fb4984b6 изменена логика поиска ключей в базе данных 2024-09-25 01:51:26 +03:00
Vlad 146b61a1a8 изменена логика поиска ключей в базе данных 2024-09-25 01:45:37 +03:00
Vlad 5dd4fda577 правки 2024-09-24 02:36:30 +03:00
Vlad f83278af90 правки 2024-09-24 02:35:54 +03:00
Vlad 3fa93a9b72 правки 2024-09-23 19:08:41 +03:00
Vlad be35180703 правки 2024-09-23 18:54:16 +03:00
Vlad 4e8f5e71a7 правки 2024-09-23 16:48:32 +03:00
Vladislav Lisitsyn b89db922f7 Merge pull request #9 from Vladless/new
New
2024-09-23 16:42:31 +03:00
Vlad 60c7fa14c8 правки 2024-09-23 16:35:46 +03:00
Vlad 9413065ea3 isort 2024-09-21 03:08:57 +03:00
Vladislav Lisitsyn 98fdf362c3 Merge pull request #8 from Vladless/new
New
2024-09-21 03:00:53 +03:00
Vlad 9c2b732d55 валидация email 2024-09-21 02:59:25 +03:00
Vlad b95013eb8f удалены лишние импорты 2024-09-21 01:29:33 +03:00
Vlad b1e9b257c6 основные функции завершены 2024-09-21 01:21:35 +03:00
Vlad 22a462d193 пробник создается всего один раз 2024-09-21 01:13:12 +03:00
Vlad 16a497a8df правильная логика продления ключей 2024-09-21 00:56:08 +03:00
Vlad 51b46bfe65 переход на postresql 2024-09-21 00:21:23 +03:00
Vlad 4316f44390 исправление ошибки в моих ключах 2024-09-20 23:24:39 +03:00
Vlad 470f9a2f38 исправление ошибки в моих ключах 2024-09-20 14:25:22 +03:00
Vlad fe6fef3174 доработка кода 2024-09-20 05:48:09 +03:00
Vlad f8015ced70 редизайн, кнопки назад 2024-09-20 03:48:41 +03:00
Vlad a6d2203e78 апдейт юзеров 2024-09-19 03:41:44 +03:00
Vlad 22b1342d43 оптимизация 2024-09-18 04:09:38 +03:00
Vladislav Lisitsyn 0f321aab3a Merge pull request #7 from Vladless/new
New
2024-09-18 01:45:13 +03:00
Vlad c3e9cc2275 переработка интерфейса 2024-09-18 01:44:27 +03:00
Vlad cb9b6381ca работающая оплата с подтверждением после перевода 2024-09-17 23:51:03 +03:00
Vlad 7fa8d6f346 структура проекта 2024-09-17 03:11:36 +03:00
Vladislav Lisitsyn 9398d1ec7e Merge pull request #6 from Vladless/new
New
2024-09-17 03:08:57 +03:00
Vlad b81d515551 структура проекта 2024-09-17 03:08:07 +03:00
Vlad 2f523bf196 мои ключи 2024-09-17 02:38:52 +03:00
Vladislav Lisitsyn f82e9af84e Merge pull request #5 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 01:45:40 +03:00
Vlad 15181f0ef5 рабочий вариант выдачи ключей через подтверждение 2024-09-17 01:45:12 +03:00
Vladislav Lisitsyn 12295d276a Merge pull request #4 from Vladless/new
рабочий вариант выдачи ключей через подтверждение
2024-09-17 00:40:01 +03:00
Vlad c98c6af14c рабочий вариант выдачи ключей через подтверждение 2024-09-17 00:39:06 +03:00
Vladislav Lisitsyn fc5606faa8 Merge pull request #3 from Vladless/new
New
2024-09-16 23:01:51 +03:00
Vlad 412893b689 опрос 2024-09-16 23:01:15 +03:00
Vlad 4797759f9b опрос 2024-09-16 07:03:41 +03:00
Vlad 9398b78238 bot.1.0 2024-09-16 06:58:36 +03:00
Vlad 86c86c9198 ключ, статистика, срок 2024-09-16 06:50:53 +03:00
Vladislav Lisitsyn 40365a5324 Merge pull request #2 from Vladless/base
Base
2024-09-16 05:03:02 +03:00
Vlad 454ca55fd6 добавлена дата окончания подписки 2024-09-16 04:02:17 +03:00
Vlad 38aeaa9d17 логика статистики 2024-09-16 03:43:25 +03:00
Vlad 1e259eb6e3 кнопка статистики 2024-09-16 03:34:05 +03:00
Vladislav Lisitsyn b340d07f5c Delete bot_old.py 2024-09-16 03:16:01 +03:00
Vladislav Lisitsyn 7c57cf8102 Merge pull request #1 from Vladless/base
Добавлена начальная база данных
2024-09-13 03:40:05 +03:00
Vlad ae819e33dc Добавлена начальная база данных 2024-09-13 03:39:21 +03:00
Vlad c9f3aa4a20 Начальный этап бота с выдачей ключей 2024-09-13 02:13:24 +03:00
261 changed files with 29446 additions and 152705 deletions
+36
View File
@@ -0,0 +1,36 @@
---
name: Issue
about: Сообщить об ошибке, предложить улучшение или указать на уязвимость
title: "[ISSUE] "
labels: triage
assignees: ''
---
### Тип
- [ ] Баг
- [ ] Фича
- [ ] Безопасность
### Кратко
Опишите суть задачи.
### Шаги воспроизведения (для багов/безопасности)
1.
2.
3.
### Ожидаемое / Фактическое
-
### Окружение
- Ветка: main/dev
- Коммит:
- ОС:
- Python:
### Дополнительно (для фич)
Опишите мотивацию, альтернативы.
### Примечание по безопасности
Не публикуйте PoC публично.
Для сообщений об уязвимостях свяжитесь напрямую: [@solonet_sup](https://t.me/solonet_sup).
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Сохраняем текущие изменения
echo "Сохранение текущих изменений..."
git stash -q --keep-index
# Запускаем форматирование кода
echo "Запуск Ruff format..."
ruff format . --config pyproject.toml --exclude main.py,handlers/payments
# Запускаем проверку и исправление кода
echo "Запуск Ruff check с автоисправлением..."
ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
# Добавляем изменения, внесенные форматированием
git add -u
# Восстанавливаем сохраненные изменения
echo "Восстановление сохраненных изменений..."
git stash pop -q
# Выход с кодом 0 (успешно)
exit 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

+21
View File
@@ -0,0 +1,21 @@
### Описание изменений
Опишите, что изменилось и зачем.
### Ветка назначения
- [ ] dev (основная ветка разработки)
- [ ] main (только для критических исправлений безопасности)
### Тип изменений
- [ ] Новая функциональность
- [ ] Исправление ошибки
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
### Чек-лист
- [ ] Изменения протестированы локально
- [ ] Документация обновлена (если требуется)
- [ ] Нет лишних изменений в коде
### Связанные задачи
Укажите номер issue, если PR его закрывает (например: Closes #123).
+9 -2
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,6 +53,11 @@ Thumbs.db
nginx.conf
scripts
models.py
Dockerfile
.csv
.csv
/logs
setup.py
.ruff_cache
.github/workflows/
modules/
storage/
+25
View File
@@ -0,0 +1,25 @@
# Code of Conduct
## Общие правила
- Уважайте друг друга. Недопустимы оскорбления, дискриминация или токсичное поведение.
- Обсуждения должны оставаться по теме и в конструктивной форме.
- Личные и конфиденциальные данные публиковать запрещено.
- Нарушения могут привести к предупреждению, ограничению участия или блокировке по усмотрению мейнтейнеров.
## Участники GitHub
- Могут предлагать изменения через **Issues** и **Pull Requests**.
- Их вклад рассматривается и принимается по решению мейнтейнеров.
- Дискуссии ограничены рамками кода и технических вопросов.
## Участники сообщества в Telegram
- Имеют приоритетное взаимодействие с разработчиком.
- Могут обсуждать проект шире: идеи, улучшения, новые направления.
- Влияние на развитие кода выше, чем у участников извне.
- Общение остаётся в рамках уважения и конструктивности.
## Контакты
По вопросам поведения используйте Issues с меткой `conduct` или обращайтесь через сообщество в Telegram.
+29
View File
@@ -0,0 +1,29 @@
# Contributing Guidelines
Спасибо, что хотите внести вклад в развитие проекта!
## Как помочь
- Создавайте **Issues** для конкретных целей или описания найденных багов.
- Отправляйте **Pull Requests** с улучшениями и исправлениями. Каждое предложение будет рассмотрено, и мы примем решение о включении.
- Делайте **форки** репозитория и дорабатывайте код — лучшие практики мы обязательно объединим в общее дело.
## Стиль кода
- Соблюдайте стандарт **PEP8**.
- Для автоматической проверки можно использовать **ruff**.
## Правила коммитов
- Сообщения должны быть осмысленными и отражать суть изменений.
- Если в одном коммите несколько изменений, перечисляйте их через `/`.
## Лицензия
Creative Commons Attribution-NonCommercial 4.0 International Public License © Vladless, 2024.
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
---
Ваш вклад помогает проекту расти и становиться лучше.
+330
View File
@@ -0,0 +1,330 @@
Creative Commons Attribution-NonCommercial 4.0 International Public
License © Vladless, 2024.
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
j. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
+8 -4
View File
@@ -1,6 +1,10 @@
formatting:
@echo "Running Ruff..." && ruff check . --fix
@echo "Running Ruff format..." && ruff format .
format:
@echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
lint:
@echo "Running Ruff checks..." && ruff check .
@echo "Running Ruff checks..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments
format-payments:
@echo "Running Ruff format ONLY on handlers/payments..." && ruff format handlers/payments --config pyproject.toml
@echo "Running Ruff check ONLY on handlers/payments..." && ruff check handlers/payments --config pyproject.toml --fix
+180 -115
View File
@@ -1,146 +1,211 @@
# 🚀 SoloBot
<div align="center" style="max-width:980px;margin:0 auto;padding:0 12px">
**SoloBot** — ваш идеальный помощник для управления API 3x-UI VPN на протоколе VLESS.
![IMG_1565](https://github.com/user-attachments/assets/71cfb259-9889-4066-8626-b804ae4cf7fb)
Три версии — море возможностей:
- v1.4 — бот для продажи ключей vless:
![Основные окна](preview.jpg)
- v2.3.1 — стабильная версия подписок вместо ключей с кнопками автодобавления в приложение:
![Основные окна](preview_v_2.jpg)
- v3.1 — настолько большая, что смотрите в [релизе](https://github.com/Vladless/Solo_bot/releases/tag/v3.1):
<h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
<p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
<p style="margin:16px 0 0">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Website-visit-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Website">
</a>&nbsp;
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Try_in_Telegram-demo-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Telegram">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/features" target="_blank">
<img height="46" src="https://img.shields.io/badge/Features-overview-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=readthedocs&logoColor=ff6a00" alt="Features">
</a>&nbsp;
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/Releases-history-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Releases">
</a>
</p>
Если вам не хватает функций — направьте их в issue, мы реализуем
<p style="margin:10px 0 0">
<a href="LICENSE" target="_blank">
<img height="32" src="https://img.shields.io/badge/License-CC_BY--NC_4.0-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=creative-commons&logoColor=ff6a00" alt="License">
</a>
</p>
## 📋 Оглавление
1. [Описание](#описание)
2. [Стек технологий](#стек-технологий)
3. [Установка](#установка)
4. [Конфигурация](#конфигурация)
5. [Запуск](#запуск)
6. [Контакты](#контакты)
---
## 📖 Описание
SoloBot реализует множество функций, включая:
- **Выдача подписок на пробный и длительные периоды** (1 месяц, 3 месяца, полгода, год).
- **Продление ключей** на указанные периоды.
- Полный контроль клиента над своими ключами:
- Удаление ключей.
- Продление ключей.
- Просмотр информации о ключе (сервер, оставшееся время, сам ключ).
- **Смена локации** (перемещение ключа между серверами).
- Поддержка нескольких ключей для одного клиента (несколько устройств).
- **Реферальная программа** с пригласительной ссылкой.
- Доступ к **инструкциям**.
- **Пополнение баланса**:
* через Юкасса (самозанятость и ИП)
* через freekassa (Физические Лица)
* Криптовалюта (Thanks [izzzzzi](https://github.com/izzzzzi))
* Звезды Telegram
* Юмани (физические лица) (Thanks [TrackLine](https://github.com/TrackLine))
- Периодические **бэкапы базы данных клиентов**.
- Уведомления:
- Произвольные сообщения через админку.
- Уведомления о неиспользованных пробниках.
- Уведомления о истекающих ключах (за сутки, за 6 часов и в момент истечения).
- **Чат поддержки** и канал для связи.
- **Автоматическое продление ключа** при наличии достаточного баланса.
- **Удобная админка прямо в боте**
- Серверы:
- **мультисерверность**- добавление сервера прямо в админке
- **Проверка на доступность** - бот следит за серверами и их откликом
- **Балансировка** - бот выдает подписку на наименее загруженный сервер
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
</div>
---
<h2 align="center">Основные возможности</h2>
## 💻 Стек технологий
<table align="center" style="max-width:980px;width:100%">
<tr>
<th align="left" style="width:30%">📌 Раздел</th>
<th align="left">💡 Возможности</th>
</tr>
Проект использует следующие технологии:
<tr>
<td><b>Мультипанель</b></td>
<td>3x-ui<br>Remnawave<br>Совместимый режим</td>
</tr>
- **Python** версии 3.10 или выше.
- **Git** для клонирования репозитория.
- **Virtualenv** для создания виртуального окружения (рекомендуется).
- **PostgreSQL** для хранения данных.
- **Nginx** для работы с вебхуками.
- **aiogram** (версия 3.13) для взаимодействия с Telegram API.
- **youkassa** для обработки платежей.
- **aiohttp** для API запросов к панелям.
<tr>
<td><b>Подписки</b></td>
<td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
</tr>
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
<tr>
<td><b>Тарификация</b></td>
<td>Лимиты устройств<br>Лимиты трафика<br>Лимиты серверов в подписках</td>
</tr>
---
<tr>
<td><b>Клиент</b></td>
<td>Ключ / сервер / TTL<br>Продление / удаление / начисление дней / отключение<br>Смена локации<br>Несколько устройств</td>
</tr>
## ⚙️ Установка
<tr>
<td><b>Поддержка роутеров</b></td>
<td>Выдача VLESS подписки за отдельную цену</td>
</tr>
### 1️⃣ Шаг 1: Клонирование репозитория
<tr>
<td><b>Рефералы</b></td>
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
</tr>
Клонируйте репозиторий и перейдите в его директорию:
<tr>
<td><b>UTM-аналитика</b></td>
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
</tr>
```bash
git clone https://github.com/Vladless/Solo_bot.git
cd solo_bot
```
<tr>
<td><b>Оплата</b></td>
<td>YooKassa (ИП/Самозанятые)<br>YooMoney (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Robokassa (ИП)<br>Telegram Stars<br>Heleket (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>Wata (Физ. лица) <a href="https://github.com/TrackLine">@TrackLine</a><br>Kassai (Физ. лица) <a href="https://github.com/JustYay">@JustYay</a><br>И другие <a href="https://pocomacho.ru/solonetbot/modules/">см. Модули</a></td>
</tr>
### 2️⃣ Шаг 2: Создание и активация виртуального окружения
<tr>
<td><b>Валютность</b></td>
<td>Оплата в разных валютах</td>
</tr>
```
python3 -m venv venv
source venv/bin/activate
```
<tr>
<td><b>Надёжность</b></td>
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
</tr>
### 3️⃣ Шаг 3: Установка зависимостей
<tr>
<td><b>Уведомления</b></td>
<td>О скором окончании подписки<br>О непотраченном трафике</td>
</tr>
```
pip install -r requirements.txt
```
<tr>
<td><b>Рассылки</b></td>
<td>По заданным группам<br>С картинками или видео</td>
</tr>
### 🛠️ Конфигурация
<tr>
<td><b>Серверы</b></td>
<td>Мультисерверность<br>Одна локация или подписка<br>Автопроверка и балансировка<br>Синхронизация и лимиты<br>Вкл/выкл</td>
</tr>
Для правильной работы вам нужно:
<tr>
<td><b>Воронка продаж</b></td>
<td>Доп. дни к триалу: не взял триал → оффер → активация<br>Горячие лиды: платил → перестал → таргет/КП → возврат</td>
</tr>
* установить и запустить postgresql, создать пользователя для работы с базой данных и выдать ему права
* Настроить ваш сервер на работу с ботом, выпустить SSL сертификат для домена
* Настроить вебхуки и пути до них в NGINX
<tr>
<td><b>Админка</b></td>
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
</tr>
* Создать файл config.py в корневой папке проекта с вашими данными:
<tr>
<td><b>Модульность</b></td>
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
</tr>
```
<tr>
<td><b>Кастомизация</b></td>
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
</tr>
</table>
API_TOKEN = токен вашего бота телеграм
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
ADMIN_USERNAME = логин от вашей панели x-ray
ADMIN_PASSWORD = пароль от вашей панели x-ray
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
AUTH_URL = f"{API_URL}/login/"
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
```
**Мы высылаем детальный гайд и недостающие файлы в поддержке бота**
<h2 align="center">Обновления</h2>
<p align="center">Регулярные релизы. Обновление через CLI. Ветки: <code>main</code> — стабильные версии, <code>dev</code> — бета.</p>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/releases" target="_blank">
<img height="46" src="https://img.shields.io/badge/➡_Все_версии-смотреть-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=github&logoColor=ff6a00" alt="Все версии">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Гайды и расширения</h2>
<p align="center" style="max-width:900px;margin:8px auto 14px">
На сайте всегда лежат актуальные файлы для запуска разных версий бота. Есть собственное Wiki с пошаговыми инструкциями по установке и обновлениям. Доступна система модулей: бот легко дополняется готовыми расширениями и вашим кастомным функционалом.
</p>
<p align="center">
<img src="https://github.com/user-attachments/assets/7d0a954d-41b7-4d7c-8618-aee1f4459792" alt="Guides" width="100%">
</p>
<p align="center">
<a href="https://pocomacho.ru/solonetbot/" target="_blank">
<img height="44" src="https://img.shields.io/badge/➡_Сайт_SoloBot-перейти-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=firefox&logoColor=ff6a00" alt="Site">
</a>&nbsp;
<a href="https://pocomacho.ru/solonetbot/reviews/" target="_blank">
<img height="44" src="https://img.shields.io/badge/Отзывы-на_сайте-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=discourse&logoColor=ff6a00" alt="Reviews">
</a>
</p>
<div style="height:22px"></div>
<div style="height:1px;background:linear-gradient(90deg,rgba(0,0,0,0),#ff6a00,rgba(0,0,0,0));filter:drop-shadow(0 0 10px rgba(255,106,0,.6))"></div>
<div style="height:22px"></div>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Полная версия</h2>
<p align="center">
<img src="https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30" alt="Community" width="100%">
</p>
<p align="center" style="max-width:900px;margin:8px auto 14px">
SoloBot уже помог более чем 500 пользователям из сообщества. Присоединяйтесь: попробуйте бота или напишите в поддержку, чтобы получить доступ и консультацию.
</p>
<p align="center">
<a href="https://t.me/SoloNetVPN_bot" target="_blank">
<img height="46" src="https://img.shields.io/badge/Попробовать_бота-открыть_Telegram-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Try bot">
</a>&nbsp;
<a href="https://t.me/solonet_sup" target="_blank">
<img height="46" src="https://img.shields.io/badge/Написать_в_поддержку-получить_доступ-ff6a00?style=for-the-badge&labelColor=1f1f1f&logo=telegram&logoColor=ff6a00" alt="Support">
</a>
</p>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Лицензия</h2>
<table align="center" style="max-width:900px;width:100%">
<tr>
<td style="width:8%;text-align:center">❗</td>
<td><b>Этот проект распространяется по лицензии <a href="LICENSE">CC BY-NC 4.0</a></b></td>
</tr>
<tr>
<td style="text-align:center">⛔</td>
<td><b>Перепродажа кода запрещена.</b> Нельзя продавать или перепродавать код без разрешения автора.</td>
</tr>
<tr>
<td style="text-align:center">✅</td>
<td><b>Для личного использования.</b> Код можно использовать и модифицировать для личных проектов.</td>
</tr>
</table>
<hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
<h2 align="center">Участники</h2>
<p align="center">
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" alt="Contributors">
</a>
</p>
### 🚀 Запуск
введите команду из виртуального окружения
```
python main.py
```
### 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/737fbc21-e4b1-4c69-9323-71284d34e454)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин и пароль от сайта, а также задать необходимые вопросы!
+32
View File
@@ -0,0 +1,32 @@
# Security Policy
## Поддерживаемые ветки
- Рассматриваются только `main` (последняя стабильная) и `dev` (текущая разработка).
- Другие ветки и теги не поддерживаются.
## Как сообщить об уязвимости
- Не создавайте публичные Issues или Pull Requests.
- Сообщите напрямую владельцу: [@solonet_sup](https://t.me/solonet_sup).
- Также можно уведомить через сообщество в Telegram (доступно после вступления).
- Укажите: ветку (`main`/`dev`), версию/коммит, шаги воспроизведения, ожидаемое и фактическое поведение, возможное влияние, логи или скриншоты.
## Процесс
1. Подтверждаем получение отчёта.
2. Анализируем и определяем критичность.
3. Исправления выпускаются в `dev`.
4. Если уязвимость критическая — фикс публикуется напрямую в `main`.
5. После релиза уведомляем сообщество и при необходимости публикуем рекомендации по обновлению.
## Область действия
- Бот (aiogram), веб-админка (flask), внутренние модули/плагины и API проекта.
- Вне области: сторонние сервисы и библиотеки (платёжные провайдеры, Telegram, панели и т. п.). Для них обращайтесь к поставщику напрямую.
## Ответственное раскрытие
- Просим не публиковать детали до выхода исправления.
- Тесты проводите только на своих данных и аккаунтах.
- Атаки на инфраструктуру или пользователей строго запрещены.
## Лучшие практики при отчёте
- Минимальный PoC без риска для данных и доступности.
- Чёткие и воспроизводимые шаги.
- Возможные предложения по снижению риска до исправления.
+32
View File
@@ -0,0 +1,32 @@
import hashlib
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, Header, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import async_session_maker
from database.models import Admin
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
session: AsyncSession = Depends(get_session),
) -> Admin:
hashed = hash_token(token)
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
return admin
+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"}
+145
View File
@@ -0,0 +1,145 @@
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"],
)
+65
View File
@@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.models import Admin, Gift, GiftUsage
router = APIRouter()
gift_router = generate_crud_router(
model=Gift,
schema_response=GiftResponse,
schema_create=GiftBase,
schema_update=GiftUpdate,
identifier_field="gift_id",
parameter_name="gift_id",
enabled_methods=["get_all", "get_one", "create", "update"],
)
router.include_router(gift_router, prefix="", tags=["Gifts"])
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.sender_tg_id == tg_id))
gifts = result.scalars().all()
if not gifts:
raise HTTPException(status_code=404, detail="Gifts not found")
return gifts
gift_usage_router = generate_crud_router(
model=GiftUsage,
schema_response=GiftUsageResponse,
schema_create=None,
schema_update=None,
identifier_field="gift_id",
enabled_methods=["get_all", "get_one", "delete"],
)
router.include_router(gift_usage_router, prefix="/usages", tags=["GiftUsages"])
router.include_router(gift_usage_router, prefix="/usages", tags=["Gifts"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift:
raise HTTPException(status_code=404, detail="Gift not found")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
await session.commit()
return {"message": "Подарок и связанные использования удалены"}
+141
View File
@@ -0,0 +1,141 @@
from datetime import datetime
from fastapi import Body, Depends, HTTPException, Path, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.models import Admin, Key, Tariff
from handlers.keys.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
from logger import logger
router = generate_crud_router(
model=Key,
schema_response=KeyResponse,
schema_create=KeyBase,
schema_update=KeyUpdate,
identifier_field="tg_id",
extra_get_by_email=True,
enabled_methods=["get_all", "get_one", "get_by_email", "get_all_by_field"],
)
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
try:
await delete_key_from_cluster(
session=session,
email=db_key.email,
client_id=db_key.client_id,
cluster_id=db_key.server_id,
)
await session.delete(db_key)
await session.commit()
logger.info(f"[API] Ключ удалён: {db_key.client_id}")
return {"message": "Ключ успешно удалён"}
except Exception as e:
logger.error(f"[API] Ошибка при удалении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении ключа")
@router.get("/routers/{tg_id}", response_model=list[KeyResponse])
async def get_router_keys_by_tg_id(
tg_id: int = Path(..., description="Telegram ID пользователя"),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
tariffs_result = await session.execute(select(Tariff.id).where(Tariff.group_code == "routers"))
tariff_ids = [row[0] for row in tariffs_result.all()]
if not tariff_ids:
return []
keys_result = await session.execute(select(Key).where(Key.tg_id == tg_id, Key.tariff_id.in_(tariff_ids)))
keys = keys_result.scalars().all()
return keys
@router.patch("/edit/by_email/{email}", response_model=KeyResponse)
async def edit_key_by_email(
email: str = Path(..., description="Email клиента"),
key_update: KeyUpdate = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
result = await session.execute(select(Key).where(Key.email == email))
db_key = result.scalar_one_or_none()
if not db_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
for field, value in key_update.dict(exclude_unset=True).items():
if field == "expiry_time" and value is not None:
if isinstance(value, int):
ms = value
elif isinstance(value, datetime):
ms = int(value.timestamp() * 1000)
else:
raise HTTPException(status_code=400, detail="Некорректный формат времени")
setattr(db_key, field, ms)
else:
setattr(db_key, field, value)
try:
new_expiry_time = db_key.expiry_time
await renew_key_in_cluster(
cluster_id=db_key.server_id,
email=db_key.email,
client_id=db_key.client_id,
new_expiry_time=new_expiry_time,
total_gb=getattr(db_key, "traffic_limit", None),
session=session,
hwid_device_limit=getattr(db_key, "device_limit", None),
reset_traffic=True,
)
await session.commit()
logger.info(f"[API] Ключ обновлён: {db_key.client_id}")
return db_key
except Exception as e:
logger.error(f"[API] Ошибка при обновлении ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при обновлении ключа")
@router.post("/create", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_key_api(
payload: KeyCreateRequest = Body(...),
session: AsyncSession = Depends(get_session),
admin: Admin = Depends(verify_admin_token),
):
try:
await create_key_on_cluster(
cluster_id=payload.cluster_id,
tg_id=payload.tg_id,
client_id=payload.client_id,
email=payload.email or f"{payload.tg_id}_key",
expiry_timestamp=payload.expiry_timestamp,
plan=payload.tariff_id,
session=session,
remnawave_link=payload.remnawave_link,
hwid_limit=payload.hwid_limit,
traffic_limit_bytes=payload.traffic_limit_bytes,
is_trial=payload.is_trial or False,
)
return {"message": "Ключ успешно создан"}
except Exception as e:
logger.error(f"[API] Ошибка при создании ключа: {e}")
raise HTTPException(status_code=500, detail="Ошибка при создании ключа")
+171
View File
@@ -0,0 +1,171 @@
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 import get_tracking_source_stats
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", "delete"],
),
prefix="/tracking-sources",
tags=["TrackingSources"],
dependencies=[Depends(verify_admin_token)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_admin_token)]
)
async def get_tracking_source_with_stats(
code: str,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(TrackingSource).where(TrackingSource.code == code))
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Tracking source not found")
stats = await get_tracking_source_stats(session, code)
return TrackingSourceResponse(
id=source.id,
name=source.name,
code=source.code,
type=source.type,
created_by=source.created_by,
created_at=source.created_at,
registrations=(stats["registrations"] if stats else 0),
trials=(stats["trials"] if stats else 0),
payments=(stats["payments"] if stats else 0),
total_amount=(float(stats["total_amount"]) if stats else 0.0),
monthly=(stats["monthly"] if stats and "monthly" in stats else []),
)
+36
View File
@@ -0,0 +1,36 @@
from fastapi import Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas import ReferralResponse
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_tg_id",
enabled_methods=["get_all", "get_one", "get_all_by_field"],
)
@router.delete("/one")
async def delete_one_referral(
referrer_tg_id: int = Query(..., description="ID пригласившего"),
referred_tg_id: int = Query(..., description="ID приглашённого"),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(Referral).where(Referral.referrer_tg_id == referrer_tg_id, Referral.referred_tg_id == referred_tg_id)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
await session.commit()
return {"status": "deleted_one"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router: APIRouter = generate_crud_router(
model=Server,
schema_response=ServerResponse,
schema_create=ServerBase,
schema_update=ServerUpdate,
identifier_field="server_name",
parameter_name="server_name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.routes.base_crud import generate_crud_router
from api.schemas import TariffBase, TariffResponse, TariffUpdate
from database.models import Tariff
router: APIRouter = generate_crud_router(
model=Tariff,
schema_response=TariffResponse,
schema_create=TariffBase,
schema_update=TariffUpdate,
identifier_field="name",
parameter_name="name",
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
)
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from fastapi import Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.routes.base_crud import generate_crud_router
from api.schemas.users import UserBase, UserResponse, UserUpdate
from database import delete_user_data, get_servers
from database.models import Key, User
from handlers.keys.operations import delete_key_from_cluster
from logger import logger
router = generate_crud_router(
model=User,
schema_response=UserResponse,
schema_create=UserBase,
schema_update=UserUpdate,
identifier_field="tg_id",
enabled_methods=["get_all", "get_one", "get_by_email", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
try:
result = await session.execute(select(Key.email, Key.client_id).where(Key.tg_id == tg_id))
key_records = result.all()
async def delete_keys_from_servers():
try:
servers = await get_servers(session=session)
tasks = []
for email, client_id in key_records:
for cluster_id in servers:
tasks.append(delete_key_from_cluster(cluster_id, email, client_id, session))
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
await delete_user_data(session, tg_id)
return {"detail": f"Пользователь {tg_id} и его ключи успешно удалены."}
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении пользователя {tg_id}: {e}")
raise HTTPException(status_code=500, detail="Ошибка при удалении пользователя")
+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
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime
from pydantic import BaseModel, Field, model_validator
class CouponBase(BaseModel):
code: str
amount: int | None = Field(default=None)
usage_limit: int
usage_count: int = 0
is_used: bool = False
days: int | None = Field(default=None)
@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
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool | None = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class GiftUpdate(BaseModel):
recipient_tg_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
is_used: bool | None = None
is_unlimited: bool | None = None
max_usages: int | None = None
tariff_id: int | None = None
class Config:
from_attributes = True
+76
View File
@@ -0,0 +1,76 @@
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="Флаг триального ключа")
+122
View File
@@ -0,0 +1,122 @@
from datetime import datetime
from pydantic import BaseModel
class PaymentBase(BaseModel):
tg_id: int
amount: float
payment_system: str
status: str
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
class NotificationResponse(BaseModel):
tg_id: int
notification_type: str
last_notification_time: datetime
class Config:
from_attributes = True
class GiftBase(BaseModel):
sender_tg_id: int
recipient_tg_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
tariff_id: int | None = None
class GiftResponse(GiftBase):
gift_id: str
created_at: datetime
class Config:
from_attributes = True
class GiftUsageResponse(BaseModel):
gift_id: str
tg_id: int
used_at: datetime
class Config:
from_attributes = True
class ManualBanResponse(BaseModel):
tg_id: int
banned_at: datetime
reason: str
banned_by: int
until: datetime | None = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
tg_id: int
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
tg_id: int
class Config:
from_attributes = True
class MonthlyStats(BaseModel):
month: str
registrations: int
trials: int
new_purchases_count: int
new_purchases_amount: float
repeat_purchases_count: int
repeat_purchases_amount: float
class TrackingSourceResponse(BaseModel):
id: int
name: str
code: str
type: str
created_by: int
created_at: datetime
registrations: int = 0
trials: int = 0
payments: int = 0
total_amount: float = 0.0
monthly: list[MonthlyStats] = []
class Config:
from_attributes = True
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class ReferralResponse(BaseModel):
referred_tg_id: int
referrer_tg_id: int
reward_issued: bool = False
class Config:
from_attributes = True
+35
View File
@@ -0,0 +1,35 @@
from pydantic import BaseModel
class ServerBase(BaseModel):
cluster_name: str
server_name: str
api_url: str
subscription_url: str | None = None
inbound_id: str
panel_type: str
max_keys: int | None = None
tariff_group: str | None = ""
enabled: bool = True
class ServerResponse(ServerBase):
id: int
class Config:
from_attributes = True
class ServerUpdate(BaseModel):
cluster_name: str | None = None
server_name: str | None = None
api_url: str | None = None
subscription_url: str | None = None
inbound_id: str | None = None
panel_type: str | None = None
max_keys: int | None = None
tariff_group: str | None = None
enabled: bool | None = None
class Config:
from_attributes = True
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel
class TariffBase(BaseModel):
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool = True
subgroup_title: str | None = None
sort_order: int | None = None
class TariffResponse(TariffBase):
id: int
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffUpdate(BaseModel):
name: str | None = None
group_code: str | None = None
duration_days: int | None = None
price_rub: int | None = None
traffic_limit: int | None = None
device_limit: int | None = None
is_active: bool | None = None
subgroup_title: str | None = None
sort_order: int | None = None
class Config:
from_attributes = True
+37
View File
@@ -0,0 +1,37 @@
from datetime import datetime
from pydantic import BaseModel
class UserBase(BaseModel):
tg_id: int
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = False
balance: float = 0.0
trial: int = 0
source_code: str | None = None
class UserResponse(UserBase):
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class UserUpdate(BaseModel):
username: str | None = None
first_name: str | None = None
last_name: str | None = None
language_code: str | None = None
is_bot: bool | None = None
balance: float | None = None
trial: int | None = None
source_code: str | None = None
class Config:
from_attributes = True
-102
View File
@@ -1,102 +0,0 @@
CREATE TABLE IF NOT EXISTS users
(
tg_id BIGINT PRIMARY KEY NOT NULL,
username TEXT,
first_name TEXT,
last_name TEXT,
language_code TEXT,
is_bot BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections
(
tg_id BIGINT PRIMARY KEY NOT NULL,
balance REAL NOT NULL DEFAULT 0.0,
trial INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payments
(
id SERIAL PRIMARY KEY,
tg_id BIGINT NOT NULL,
amount REAL NOT NULL,
payment_system TEXT NOT NULL,
status TEXT DEFAULT 'success',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tg_id) REFERENCES users (tg_id)
);
CREATE TABLE IF NOT EXISTS keys
(
tg_id BIGINT NOT NULL,
client_id TEXT NOT NULL,
email TEXT NOT NULL,
created_at BIGINT NOT NULL,
expiry_time BIGINT NOT NULL,
key TEXT NOT NULL,
server_id TEXT NOT NULL DEFAULT 'cluster1',
notified BOOLEAN NOT NULL DEFAULT FALSE,
notified_24h BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (tg_id, client_id)
);
CREATE TABLE IF NOT EXISTS referrals
(
referred_tg_id BIGINT PRIMARY KEY NOT NULL,
referrer_tg_id BIGINT NOT NULL,
reward_issued BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupons
(
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0,
is_used BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS coupon_usages
(
coupon_id INTEGER NOT NULL REFERENCES coupons (id) ON DELETE CASCADE,
user_id BIGINT NOT NULL,
used_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (coupon_id, user_id)
);
CREATE TABLE IF NOT EXISTS notifications
(
tg_id BIGINT NOT NULL,
last_notification_time TIMESTAMP NOT NULL DEFAULT NOW(),
notification_type TEXT NOT NULL,
PRIMARY KEY (tg_id, notification_type)
);
CREATE TABLE IF NOT EXISTS servers
(
id SERIAL PRIMARY KEY,
cluster_name TEXT NOT NULL,
server_name TEXT NOT NULL,
api_url TEXT NOT NULL,
subscription_url TEXT NOT NULL,
inbound_id TEXT NOT NULL,
UNIQUE (cluster_name, server_name)
);
CREATE TABLE IF NOT EXISTS gifts
(
gift_id TEXT PRIMARY KEY NOT NULL,
sender_tg_id BIGINT NOT NULL,
selected_months INTEGER NOT NULL,
expiry_time TIMESTAMP WITH TIME ZONE NOT NULL,
gift_link TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
recipient_tg_id BIGINT,
CONSTRAINT fk_sender FOREIGN KEY (sender_tg_id) REFERENCES users (tg_id),
CONSTRAINT fk_recipient FOREIGN KEY (recipient_tg_id) REFERENCES users (tg_id)
);
-100
View File
@@ -1,100 +0,0 @@
import os
import subprocess
from datetime import datetime
from aiogram.types import BufferedInputFile
from config import ADMIN_ID, BACK_DIR, DB_NAME, DB_PASSWORD, DB_USER
from logger import logger
async def backup_database():
from bot import bot
try:
if backup_file_path := _create_database_backup():
await _send_backup_to_admin(bot, backup_file_path)
_cleanup_old_backups()
except Exception as e:
logger.error(f"Ошибка при создании или отправке бэкапа: {e}")
def _create_database_backup():
USER = DB_USER
HOST = "localhost"
BACKUP_DIR = BACK_DIR
DATE = datetime.now().strftime("%Y-%m-%d-%H%M%S")
BACKUP_FILE = f"{BACKUP_DIR}/{DB_NAME}-backup-{DATE}.sql"
os.environ["PGPASSWORD"] = DB_PASSWORD
try:
subprocess.run(
[
"pg_dump",
"-U",
USER,
"-h",
HOST,
"-F",
"c",
"-f",
BACKUP_FILE,
DB_NAME,
],
check=True,
)
logger.info(f"Бэкап базы данных создан: {BACKUP_FILE}")
return BACKUP_FILE
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при создании бэкапа базы данных: {e}")
return None
finally:
del os.environ["PGPASSWORD"]
async def _send_backup_to_admin(bot, backup_file_path):
try:
with open(backup_file_path, "rb") as backup_file:
backup_input_file = BufferedInputFile(
backup_file.read(), filename=os.path.basename(backup_file_path)
)
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
for id in admin_ids:
await bot.send_document(id, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {id}")
else:
await bot.send_document(admin_ids, backup_input_file)
logger.info(f"Бэкап базы данных отправлен админу: {ADMIN_ID}")
except Exception as e:
logger.error(f"Ошибка при отправке бэкапа в Telegram: {e}")
def _cleanup_old_backups():
try:
subprocess.run(
[
"find",
BACK_DIR,
"-type",
"f",
"-name",
"*.sql",
"-mtime",
"+3",
"-exec",
"rm",
"{}",
";",
],
check=True,
)
logger.info("Старые бэкапы удалены.")
except subprocess.CalledProcessError as e:
logger.error(f"Ошибка при удалении старых бэкапов: {e}")
async def create_backup_and_send_to_admins(xui):
await xui.login()
await xui.database.export()
+134 -27
View File
@@ -3,42 +3,149 @@ import traceback
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import ExceptionTypeFilter
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import ErrorEvent
from aiogram.types import BufferedInputFile, ErrorEvent
from aiogram.utils.markdown import hbold
from config import API_TOKEN
from config import ADMIN_ID, API_TOKEN
from database import async_session_maker
from filters.private import IsPrivateFilter
from logger import logger
from middlewares.admin import AdminMiddleware
from middlewares.database import DatabaseMiddleware
from middlewares.delete import DeleteMessageMiddleware
from middlewares.logging import LoggingMiddleware
from middlewares.user import UserMiddleware
from utils.modules_loader import load_modules_from_folder, modules_hub
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.include_router(modules_hub)
dp.message.middleware(AdminMiddleware())
dp.callback_query.middleware(AdminMiddleware())
dp.message.middleware(UserMiddleware())
dp.callback_query.middleware(UserMiddleware())
load_modules_from_folder()
dp.message.middleware(DatabaseMiddleware())
dp.callback_query.middleware(DatabaseMiddleware())
dp.message.outer_middleware(DeleteMessageMiddleware())
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
dp.message.filter(IsPrivateFilter())
dp.callback_query.filter(IsPrivateFilter())
@dp.error()
async def error_handler(event: ErrorEvent):
logger.error(
"Ошибка в боте:\n"
f"Исключение: {event.exception}\n"
f"Тип: {type(event.exception)}\n"
f"Update: {event.update}\n"
f"Трассировка:\n{traceback.format_exc()}"
)
@dp.errors(ExceptionTypeFilter(Exception))
async def errors_handler(event: ErrorEvent, bot: Bot) -> bool:
if isinstance(event.exception, TelegramForbiddenError):
logger.info(f"User {event.update.message.from_user.id} заблокировал бота.")
return True
if isinstance(event.exception, TelegramBadRequest):
error_message = str(event.exception)
if (
"query is too old and response timeout expired or query ID is invalid" in error_message
or "message can't be deleted for everyone" in error_message
or "message to delete not found" in error_message
):
try:
tb = "".join(
traceback.format_exception(type(event.exception), event.exception, event.exception.__traceback__)
)
logger.warning(f"Показываем стартовое меню из-за TelegramBadRequest: {error_message}")
logger.error(f"Traceback:\n{tb}")
if ADMIN_ID:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
tb.encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {error_message[:1021]}...",
)
except Exception as e:
logger.error(f"Сбой при логировании/отправке ошибки админу: {e}", exc_info=True)
try:
from handlers.start import start_entry
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,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
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,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except Exception as e:
logger.error(f"Ошибка при показе стартового меню после ошибки: {e}", exc_info=True)
return True
logger.exception(f"Update: {event.update}\nException: {event.exception}")
if not ADMIN_ID:
return True
try:
for admin_id in ADMIN_ID:
await bot.send_document(
chat_id=admin_id,
document=BufferedInputFile(
traceback.format_exc().encode(),
filename=f"error_{event.update.update_id}.txt",
),
caption=f"{hbold(type(event.exception).__name__)}: {str(event.exception)[:1021]}...",
)
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,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.message,
state=fsm_context,
session=session,
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,
)
async with async_session_maker() as session:
await start_entry(
event=event.update.callback_query,
state=fsm_context,
session=session,
admin=False,
captcha=False,
)
except TelegramBadRequest as exception:
logger.warning(f"Не удалось отправить детали ошибки: {exception}")
except Exception as exception:
logger.error(f"Неожиданная ошибка в error handler: {exception}")
return True
+633
View File
@@ -0,0 +1,633 @@
import locale
import os
import re
import shutil
import subprocess
import sys
from time import sleep
import requests
from rich.console import Console, Group
from rich.live import Live
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Confirm, Prompt
from rich.table import Table
from config import BOT_SERVICE
def ensure_utf8_locale():
try:
current_locale = locale.getlocale()
if current_locale and current_locale[1] == "UTF-8":
return
except Exception:
pass
console.print("[yellow]⏳ Проверка и установка локали UTF-8...[/yellow]")
os.environ["LC_ALL"] = "en_US.UTF-8"
os.environ["LANG"] = "en_US.UTF-8"
result = subprocess.run(["locale", "-a"], capture_output=True, text=True)
if "en_US.utf8" not in result.stdout.lower():
console.print("[blue]Добавляю локаль en_US.UTF-8 в систему...[/blue]")
try:
subprocess.run(["sudo", "locale-gen", "en_US.UTF-8"], check=True)
subprocess.run(["sudo", "update-locale", "LANG=en_US.UTF-8"], check=True)
console.print("[green]Локаль успешно установлена.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке локали: {e}[/red]")
else:
console.print("[green]Локаль UTF-8 уже доступна в системе.[/green]")
try:
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
console = Console()
ensure_utf8_locale()
BACK_DIR = os.path.expanduser("~/.solobot_backups")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR:
console.print("[bold red]КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
console.print("[red]Обнаружена установка бота прямо в корневой папке (/root).[/red]")
console.print("[red]Это крайне опасно и может привести к потере данных![/red]")
console.print("[red]Рекомендуется перенести бота в отдельную папку, например /root/solobot[/red]")
console.print("[red]Обновление заблокировано в целях безопасности.[/red]")
sys.exit(1)
GITHUB_REPO = "https://github.com/Vladless/Solo_bot"
SERVICE_NAME = BOT_SERVICE
def is_service_exists(service_name):
result = subprocess.run(["systemctl", "list-unit-files", service_name], capture_output=True, text=True)
return service_name in result.stdout
def print_logo():
logo_lines = [
"███████╗ ██████╗ ██╗ ██████╗ ██████╗ ██████╗ ████████╗",
"██╔════╝██╔═══██╗██║ ██╔═══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
"███████╗██║ ██║██║ ██║ ██║██████╔╝██║ ██║ ██║ ",
"╚════██║██║ ██║██║ ██║ ██║██╔══██╗██║ ██║ ██║ ",
"███████║╚██████╔╝███████╗╚██████╔╝██████╔╝╚██████╔╝ ██║ ",
"╚══════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ",
]
with Live(refresh_per_second=10) as live:
display = []
for line in logo_lines:
display.append(f"[bold cyan]{line}[/bold cyan]")
panel = Panel(Group(*display), border_style="cyan", padding=(0, 2), expand=False)
live.update(panel)
sleep(0.07)
console.print(f"[bold green]Директория бота:[/bold green] [yellow]{PROJECT_DIR}[/yellow]\n")
def list_backups():
if not os.path.isdir(BACK_DIR):
return []
pairs = []
for name in os.listdir(BACK_DIR):
path = os.path.join(BACK_DIR, name)
if os.path.isdir(path):
try:
mtime = os.path.getmtime(path)
except Exception:
mtime = 0
pairs.append((mtime, path))
pairs.sort(reverse=True)
return [p for _, p in pairs]
def prune_old_backups():
backups = list_backups()
for path in backups[3:]:
try:
shutil.rmtree(path, ignore_errors=True)
except Exception:
subprocess.run(["sudo", "rm", "-rf", path])
def backup_project():
from datetime import datetime
os.makedirs(BACK_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACK_DIR, f"backup-{ts}")
console.print("[yellow]Создаётся резервная копия проекта...[/yellow]")
with console.status("[bold cyan]Копирование файлов...[/bold cyan]"):
subprocess.run(["cp", "-r", PROJECT_DIR, dst])
console.print(f"[green]Бэкап сохранён в: {dst}[/green]")
prune_old_backups()
def restore_from_backup():
from datetime import datetime
backups = list_backups()[:3]
if not backups:
console.print(f"[red]❌ Бэкапы не найдены: {BACK_DIR}[/red]")
return
console.print("\n[bold green]Доступные бэкапы:[/bold green]")
shown = []
for idx, path in enumerate(backups, 1):
try:
mtime = os.path.getmtime(path)
dt = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
dt = "unknown"
console.print(f"[cyan]{idx}.[/cyan] {os.path.basename(path)} [dim]{dt}[/dim]")
shown.append((idx, path))
try:
choice = Prompt.ask(
"[bold blue]Выберите номер бэкапа[/bold blue]",
choices=[str(i) for i, _ in shown],
)
except Exception:
return
sel_path = shown[int(choice) - 1][1]
console.print("[red]Внимание: текущие файлы проекта будут перезаписаны выбранным бэкапом.[/red]")
if not Confirm.ask("[yellow]Продолжить восстановление из бэкапа?[/yellow]"):
return
if is_service_exists(SERVICE_NAME):
console.print("[blue]Останавливаю службу перед восстановлением...[/blue]")
subprocess.run(["sudo", "systemctl", "stop", SERVICE_NAME])
install_rsync_if_needed()
console.print("[yellow]Копирую файлы из бэкапа в проект...[/yellow]")
rc = subprocess.run(f"rsync -a --delete {sel_path}/ {PROJECT_DIR}/", shell=True).returncode
if rc != 0:
console.print("[red]❌ Ошибка rsync при восстановлении[/red]")
return
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]✅ Восстановление из бэкапа завершено[/green]")
def auto_update_cli():
console.print("[yellow]Проверка обновлений CLI...[/yellow]")
try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10)
if response.status_code != 200:
console.print("[red]Не удалось получить обновление CLI[/red]")
return
latest_text = response.text
current_path = os.path.realpath(__file__)
with open(current_path, encoding="utf-8") as f:
current_text = f.read()
if current_text != latest_text:
console.print("[green]Доступна новая версия CLI. Обновляю...[/green]")
with open(current_path, "w", encoding="utf-8") as f:
f.write(latest_text)
os.chmod(current_path, 0o755)
console.print("[green]CLI обновлён. Перезапуск...[/green]")
os.execv(sys.executable, [sys.executable, current_path])
else:
console.print("[green]CLI уже актуален[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при автообновлении CLI: {e}[/red]")
def fix_permissions():
console.print("[yellow]Восстанавливаю владельца и права доступа к проекту...[/yellow]")
try:
user = os.environ.get("SUDO_USER") or subprocess.check_output(["whoami"], text=True).strip()
console.log(f"[cyan]Используем пользователь: {user}[/cyan]")
for root, dirs, files in os.walk(PROJECT_DIR):
for dir in dirs:
if dir == "__pycache__":
pycache_path = os.path.join(root, dir)
subprocess.run(["sudo", "rm", "-rf", pycache_path], check=True)
for file in files:
if file.endswith(".pyc"):
pyc_path = os.path.join(root, file)
subprocess.run(["sudo", "rm", "-f", pyc_path], check=True)
console.log("[blue]Изменение владельца на весь проект...[/blue]")
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
console.log("[blue]Изменение прав доступа (u=rwX,go=rX)...[/blue]")
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
launcher_path = os.path.join(PROJECT_DIR, "cli_launcher.py")
if os.path.exists(launcher_path):
console.log("[blue]Установка флага +x для cli_launcher.py...[/blue]")
subprocess.run(["chmod", "+x", launcher_path], check=True)
console.print(f"[green]Все права восстановлены для пользователя [bold]{user}[/bold][/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
def install_rsync_if_needed():
if subprocess.run(["which", "rsync"], capture_output=True).returncode != 0:
console.print("[blue]Установка rsync...[/blue]")
os.system("sudo apt update && sudo apt install -y rsync")
def clean_project_dir_safe(update_buttons=False, update_img=False):
console.print("[yellow]Очистка проекта перед обновлением...[/yellow]")
preserved_paths = set()
preserved_paths.update([
os.path.join(PROJECT_DIR, "config.py"),
os.path.join(PROJECT_DIR, "handlers", "texts.py"),
os.path.join(PROJECT_DIR, ".git"),
os.path.join(PROJECT_DIR, "modules"),
])
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "modules")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
if not update_buttons:
preserved_paths.add(os.path.join(PROJECT_DIR, "handlers", "buttons.py"))
if not update_img:
preserved_paths.add(os.path.join(PROJECT_DIR, "img"))
for root, dirs, files in os.walk(os.path.join(PROJECT_DIR, "img")):
for name in dirs + files:
preserved_paths.add(os.path.join(root, name))
for root, dirs, files in os.walk(PROJECT_DIR, topdown=False):
for file in files:
path = os.path.join(root, file)
if path in preserved_paths:
continue
try:
os.remove(path)
except PermissionError:
subprocess.run(["sudo", "rm", "-f", path])
except Exception as e:
console.print(f"[red]Не удалось удалить файл: {path}: {e}[/red]")
for dir in dirs:
dir_path = os.path.join(root, dir)
if os.path.abspath(dir_path) in [
os.path.join(PROJECT_DIR, "handlers"),
os.path.join(PROJECT_DIR, "img"),
os.path.join(PROJECT_DIR, "modules"),
]:
continue
if os.path.abspath(dir_path).startswith(os.path.join(PROJECT_DIR, "modules") + os.sep):
continue
try:
os.rmdir(dir_path)
except Exception:
subprocess.run(["sudo", "rm", "-rf", dir_path])
def install_git_if_needed():
if subprocess.run(["which", "git"], capture_output=True).returncode != 0:
console.print("[blue]Установка Git...[/blue]")
os.system("sudo apt update && sudo apt install -y git")
def install_dependencies():
console.print("[blue]Установка зависимостей...[/blue]")
python312_path = shutil.which("python3.12")
if not python312_path:
console.print("[red]Не найден python3.12 в системе[/red]")
console.print("[yellow]Установите Python 3.12: sudo apt install python3.12 python3.12-venv[/yellow]")
sys.exit(1)
with Progress(
SpinnerColumn(style="green"),
TextColumn("[progress.description]{task.description}"),
transient=True,
) as progress:
task_id = progress.add_task(description="Создание виртуального окружения...", total=None)
try:
if os.path.exists("venv"):
shutil.rmtree("venv")
console.print("[yellow]Удалён старый venv[/yellow]")
subprocess.run(f"{python312_path} -m venv venv", shell=True, check=True)
progress.update(task_id, description="Установка зависимостей...")
subprocess.run(
"bash -c 'source venv/bin/activate && pip install -r requirements.txt'",
shell=True,
check=True,
)
progress.update(task_id, description="Установка завершена")
except subprocess.CalledProcessError as e:
progress.update(task_id, description="❌ Ошибка при установке")
console.print(f"[red]❌ Ошибка: {e}[/red]")
def restart_service():
if is_service_exists(SERVICE_NAME):
console.print("[blue]🚀 Перезапуск службы...[/blue]")
with console.status("[bold yellow]Перезапуск...[/bold yellow]"):
subprocess.run(f"sudo systemctl restart {SERVICE_NAME}", shell=True)
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
def get_local_version():
path = os.path.join(PROJECT_DIR, "bot.py")
if not os.path.isfile(path):
return None
with open(path, encoding="utf-8") as f:
for line in f:
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
return None
def get_remote_version(branch="main"):
try:
url = f"https://raw.githubusercontent.com/Vladless/Solo_bot/{branch}/bot.py"
response = requests.get(url, timeout=10)
if response.status_code == 200:
for line in response.text.splitlines():
match = re.search(r'version\s*=\s*["\'](.+?)["\']', line)
if match:
return match.group(1)
except Exception:
return None
return None
def update_from_beta():
local_version = get_local_version()
remote_version = get_remote_version(branch="dev")
if local_version and remote_version:
console.print(f"[cyan]Локальная версия: {local_version} | Последняя в dev: {remote_version}[/cyan]")
if local_version == remote_version:
if not Confirm.ask("[yellow]Версия актуальна. Обновить всё равно?[/yellow]"):
return
if not Confirm.ask("[yellow]Подтвердите обновление Solobot с ветки DEV[/yellow]"):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]Продолжить обновление?[/red]"):
return
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
os.chdir(PROJECT_DIR)
console.print("[cyan]Клонируем временный репозиторий...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
if os.system(f"git clone --depth=1000000 -b dev {GITHUB_REPO} {TEMP_DIR}") != 0:
console.print("[red]❌ Ошибка при клонировании. Обновление отменено.[/red]")
return
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print("[green]Обновление с ветки dev завершено.[/green]")
def update_from_release():
if not Confirm.ask("[yellow]Подтвердите обновление Solobot до одного из последних релизов[/yellow]"):
return
console.print("[red]ВНИМАНИЕ! Папка бота будет полностью перезаписана![/red]")
console.print("[red] Исключения: папка img и файл handlers/buttons.py[/red]")
if not Confirm.ask("[red]Вы точно хотите продолжить?[/red]"):
return
update_buttons = Confirm.ask("[yellow]Обновлять файл buttons.py?[/yellow]", default=False)
update_img = Confirm.ask("[yellow]Обновлять папку img?[/yellow]", default=False)
backup_project()
install_git_if_needed()
install_rsync_if_needed()
try:
response = requests.get("https://api.github.com/repos/Vladless/Solo_bot/releases", timeout=10)
releases = response.json()[:3]
tag_choices = [r["tag_name"] for r in releases]
if not tag_choices:
raise ValueError("Не удалось получить список релизов")
console.print("\n[bold green]Доступные релизы:[/bold green]")
for idx, tag in enumerate(tag_choices, 1):
console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]",
choices=[str(i) for i in range(1, len(tag_choices) + 1)],
)
tag_name = tag_choices[int(selected) - 1]
if not Confirm.ask(f"[yellow]Подтвердите установку релиза {tag_name}[/yellow]"):
return
console.print(f"[cyan]Клонируем релиз {tag_name} во временную папку...[/cyan]")
subprocess.run(["rm", "-rf", TEMP_DIR])
subprocess.run(
f"git clone --branch {tag_name} {GITHUB_REPO} {TEMP_DIR}",
shell=True,
check=True,
)
console.print("[red]Начинается перезапись файлов бота![/red]")
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons, update_img=update_img)
exclude_options = ""
if not update_img:
exclude_options += "--exclude=img "
if not update_buttons:
exclude_options += "--exclude=handlers/buttons.py "
exclude_options += "--exclude=modules "
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
modules_path = os.path.join(PROJECT_DIR, "modules")
if not os.path.exists(modules_path):
console.print("[yellow]Папка modules отсутствует — создаю вручную...[/yellow]")
try:
os.makedirs(modules_path, exist_ok=True)
console.print("[green]Папка modules успешно создана.[/green]")
except Exception as e:
console.print(f"[red]❌ Не удалось создать папку modules: {e}[/red]")
if os.path.exists(os.path.join(TEMP_DIR, ".git")):
subprocess.run(["cp", "-r", os.path.join(TEMP_DIR, ".git"), PROJECT_DIR])
subprocess.run(["rm", "-rf", TEMP_DIR])
install_dependencies()
fix_permissions()
restart_service()
console.print(f"[green]Обновление до релиза {tag_name} завершено.[/green]")
except Exception as e:
console.print(f"[red]❌ Ошибка при обновлении: {e}[/red]")
def show_update_menu():
if IS_ROOT_DIR:
console.print("[red]Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
return
table = Table(title="Выберите способ обновления", title_style="bold green")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Источник", style="white")
table.add_row("1", "Обновить до BETA")
table.add_row("2", "Обновить/откатить до релиза")
table.add_row("3", "Назад в меню")
console.print(table)
choice = Prompt.ask("[bold blue]Введите номер[/bold blue]", choices=["1", "2", "3"])
if choice == "1":
update_from_beta()
elif choice == "2":
update_from_release()
def show_menu():
table = Table(title="Solobot CLI v0.3.3", title_style="bold magenta", header_style="bold blue")
table.add_column("", justify="center", style="cyan", no_wrap=True)
table.add_column("Операция", style="white")
table.add_row("1", "Запустить бота (systemd)")
table.add_row("2", "Запустить напрямую: venv/bin/python main.py")
table.add_row("3", "Перезапустить бота (systemd)")
table.add_row("4", "Остановить бота (systemd)")
table.add_row("5", "Показать логи (80 строк)")
table.add_row("6", "Показать статус")
table.add_row("7", "Обновить Solobot")
table.add_row("8", "Восстановить из бэкапа")
table.add_row("9", "Выход")
console.print(table)
def main():
os.chdir(PROJECT_DIR)
auto_update_cli()
print_logo()
try:
while True:
show_menu()
choice = Prompt.ask(
"[bold blue]👉 Введите номер действия[/bold blue]",
choices=[str(i) for i in range(1, 10)],
show_choices=False,
)
if choice == "1":
if is_service_exists(SERVICE_NAME):
subprocess.run(["sudo", "systemctl", "start", SERVICE_NAME])
else:
console.print(f"[red]❌ Служба {SERVICE_NAME} не найдена.[/red]")
elif choice == "2":
if 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":
restore_from_backup()
elif choice == "9":
console.print("[bold cyan]Выход из CLI. Удачного дня![/bold cyan]")
break
except KeyboardInterrupt:
console.print("\n[bold red]⏹ Прерывание. Выход из CLI.[/bold red]")
if __name__ == "__main__":
main()
-115
View File
@@ -1,115 +0,0 @@
import py3xui
from config import LIMIT_IP
from logger import logger
async def add_client(
xui,
client_id: str,
email: str,
tg_id: str,
limit_ip: int,
total_gb: int,
expiry_time: int,
enable: bool,
flow: str,
inbound_id: int,
):
"""
Adds a client to the server via 3x-ui.
"""
try:
await xui.login()
client = py3xui.Client(
id=client_id,
email=email.lower(),
limit_ip=limit_ip,
total_gb=total_gb,
expiry_time=expiry_time,
enable=enable,
tg_id=tg_id,
sub_id=email,
flow=flow,
)
response = await xui.client.add(inbound_id, [client])
logger.info(f"Клиент {email} успешно добавлен с ID {client_id}.")
return response if response else {"status": "failed"}
except Exception as e:
logger.error(f"Ошибка при добавлении клиента {email}: {e}")
return {"status": "failed", "error": str(e)}
async def extend_client_key(
xui, inbound_id, email: str, new_expiry_time: int, client_id: str, total_gb: int
):
"""
Функция для обновления срока действия ключа клиента по email.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} не найден.")
return
if not client.id:
logger.warning(f"Ошибка: клиент {email} не имеет действительного ID.")
return
logger.info(
f"Обновление ключа клиента {client.email} с ID {client.id} до нового времени: {new_expiry_time}"
)
client.id = client_id
client.expiry_time = new_expiry_time
client.flow = "xtls-rprx-vision"
client.sub_id = email
client.total_gb = total_gb
client.enable = True
client.limit_ip = LIMIT_IP
client.inbound_id = inbound_id
await xui.client.update(client.id, client)
await xui.client.reset_stats(inbound_id, email)
logger.info(
f"Ключ клиента {client.email} успешно продлён до {new_expiry_time}."
)
except Exception as e:
logger.error(f"Ошибка при обновлении клиента с email {email}: {e}")
async def delete_client(
xui,
inbound_id: int,
email: str,
client_id: str,
) -> bool:
"""
Функция для удаления клиента с сервера 3x-ui.
Возвращает True при успешном удалении, иначе False.
"""
await xui.login()
try:
client = await xui.client.get_by_email(email)
if not client:
logger.warning(f"Клиент с email {email} и ID {client_id} не найден.")
return False
client.id = client_id
await xui.client.delete(inbound_id, client.id)
logger.info(f"Клиент с ID {client_id} был удален успешно.")
return True
except Exception as e:
logger.error(f"Ошибка при удалении клиента с ID {client_id}: {e}")
return False
View File
Binary file not shown.
Binary file not shown.
-1268
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
+24
View File
@@ -0,0 +1,24 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Payment, User
async def get_hot_leads(session: AsyncSession):
now_ms = func.extract("epoch", func.now()) * 1000
sub_active = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
stmt = (
select(Payment.tg_id)
.join(User, User.tg_id == Payment.tg_id)
.distinct()
.where(User.trial == 1)
.where(Payment.amount > 0)
.where(Payment.status == "success")
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(~Payment.tg_id.in_(sub_active))
)
result = await session.execute(stmt)
return result.scalars().all()
+123
View File
@@ -0,0 +1,123 @@
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.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
+39
View File
@@ -0,0 +1,39 @@
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
from database.tariffs import initialize_all_tariff_weights
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()
await initialize_all_tariff_weights(session)
+201
View File
@@ -0,0 +1,201 @@
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))
existing_key = exists.scalar_one_or_none()
if existing_key:
await session.execute(
update(Key)
.where(Key.tg_id == tg_id, Key.client_id == client_id)
.values(
email=email,
expiry_time=expiry_time,
key=key,
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=tariff_id,
alias=alias,
)
)
logger.info(f"[Store Key] Ключ обновлён: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
else:
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)
logger.info(f"[Store Key] Ключ создан: tg_id={tg_id}, client_id={client_id}, server_id={server_id}")
await session.commit()
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при сохранении ключа: {e}")
await session.rollback()
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))
await session.commit()
logger.info(f"Срок действия ключа {client_id} обновлён до {new_expiry_time}")
async def get_client_id_by_email(session: AsyncSession, email: str):
result = await session.execute(select(Key.client_id).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_notified(session: AsyncSession, tg_id: int, client_id: str):
await session.execute(update(Key).where(Key.tg_id == tg_id, Key.client_id == client_id).values(notified=True))
await session.commit()
async def mark_key_as_frozen(session: AsyncSession, tg_id: int, client_id: str, time_left: int):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = TRUE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": time_left, "tg_id": tg_id, "client_id": client_id},
)
async def mark_key_as_unfrozen(session: AsyncSession, tg_id: int, client_id: str, new_expiry_time: int):
await session.execute(
text(
"""
UPDATE keys
SET expiry_time = :expiry,
is_frozen = FALSE
WHERE tg_id = :tg_id
AND client_id = :client_id
"""
),
{"expiry": new_expiry_time, "tg_id": tg_id, "client_id": client_id},
)
async def update_key_tariff(session: AsyncSession, client_id: str, tariff_id: int):
await session.execute(update(Key).where(Key.client_id == client_id).values(tariff_id=tariff_id))
await session.commit()
logger.info(f"Тариф ключа {client_id} обновлён на {tariff_id}")
async def get_subscription_link(session: AsyncSession, email: str) -> str | None:
result = await session.execute(select(func.coalesce(Key.key, Key.remnawave_link)).where(Key.email == email))
return result.scalar_one_or_none()
async def update_key_client_id(session: AsyncSession, email: str, new_client_id: str):
await session.execute(update(Key).where(Key.email == email).values(client_id=new_client_id))
await session.commit()
logger.info(f"client_id обновлён для {email} -> {new_client_id}")
async def update_key_link(session: AsyncSession, email: str, link: str) -> bool:
q = update(Key).where(Key.email == email).values(key=link).returning(Key.client_id)
res = await session.execute(q)
await session.commit()
return res.scalar_one_or_none() is not None
+264
View File
@@ -0,0 +1,264 @@
import secrets
import uuid
from datetime import datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
Base = declarative_base()
class DictLikeMixin:
def __getitem__(self, key):
return getattr(self, key)
def get(self, key, default=None):
return getattr(self, key, default)
def to_dict(self):
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
class User(DictLikeMixin, Base):
__tablename__ = "users"
tg_id = Column(BigInteger, primary_key=True)
username = Column(String)
first_name = Column(String)
last_name = Column(String)
language_code = Column(String)
is_bot = Column(Boolean, default=False)
balance = Column(Float, default=0.0)
trial = Column(Integer, default=0)
preferred_currency = Column(String(10), nullable=False, server_default="RUB", index=True)
source_code = Column(
String,
ForeignKey(
"tracking_sources.code",
ondelete="SET NULL",
onupdate="CASCADE",
),
nullable=True,
)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
class Key(DictLikeMixin, Base):
__tablename__ = "keys"
tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=False)
client_id = Column(String, primary_key=True)
email = Column(String, unique=True)
created_at = Column(BigInteger)
expiry_time = Column(BigInteger)
key = Column(String)
server_id = Column(String)
remnawave_link = Column(String)
tariff_id = Column(Integer, ForeignKey("tariffs.id", ondelete="SET NULL"))
is_frozen = Column(Boolean, default=False)
alias = Column(String)
notified = Column(Boolean, default=False)
notified_24h = Column(Boolean, default=False)
class Tariff(DictLikeMixin, Base):
__tablename__ = "tariffs"
id = Column(Integer, primary_key=True)
name = Column(String)
group_code = Column(String)
duration_days = Column(Integer)
price_rub = Column(Integer)
traffic_limit = Column(BigInteger, nullable=True)
device_limit = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow)
subgroup_title = Column(String, nullable=True)
sort_order = Column(Integer, nullable=True)
vless = Column(Boolean, default=False)
class Server(DictLikeMixin, Base):
__tablename__ = "servers"
id = Column(Integer, primary_key=True, autoincrement=True)
cluster_name = Column(String)
server_name = Column(String, unique=True)
api_url = Column(String)
subscription_url = Column(String)
inbound_id = Column(String)
panel_type = Column(String)
max_keys = Column(Integer)
tariff_group = Column(String)
enabled = Column(Boolean, default=True)
subgroups = relationship("ServerSubgroup", back_populates="server", cascade="all, delete-orphan")
groups = relationship("ServerSpecialgroup", back_populates="server", cascade="all, delete-orphan")
class ServerSubgroup(DictLikeMixin, Base):
__tablename__ = "server_subgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
subgroup_title = Column(String, nullable=False)
server = relationship("Server", back_populates="subgroups")
__table_args__ = (UniqueConstraint("server_id", "subgroup_title", name="uq_server_subgroup"),)
class ServerSpecialgroup(DictLikeMixin, Base):
__tablename__ = "server_specialgroups"
id = Column(Integer, primary_key=True, autoincrement=True)
server_id = Column(Integer, ForeignKey("servers.id", ondelete="CASCADE"), index=True, nullable=False)
group_code = Column(String, nullable=False)
server = relationship("Server")
__table_args__ = (UniqueConstraint("server_id", "group_code", name="uq_server_group"),)
class Payment(DictLikeMixin, Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True)
tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
amount = Column(Float)
payment_system = Column(String)
status = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
original_amount = Column(Numeric(18, 8), nullable=True)
currency = Column(String(10), nullable=False, server_default="RUB")
payment_id = Column(String(128), nullable=True, index=True)
metadata_ = Column("metadata", JSONB, nullable=True)
class Coupon(DictLikeMixin, Base):
__tablename__ = "coupons"
id = Column(Integer, primary_key=True)
code = Column(String, unique=True)
amount = Column(Integer)
usage_limit = Column(Integer)
usage_count = Column(Integer, default=0)
is_used = Column(Boolean, default=False)
days = Column(Integer, nullable=True)
class CouponUsage(DictLikeMixin, Base):
__tablename__ = "coupon_usages"
coupon_id = Column(Integer, ForeignKey("coupons.id", ondelete="CASCADE"), primary_key=True)
user_id = Column(BigInteger, primary_key=True)
used_at = Column(DateTime, default=datetime.utcnow)
class Referral(DictLikeMixin, Base):
__tablename__ = "referrals"
referred_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
referrer_tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
reward_issued = Column(Boolean, default=False)
class Notification(DictLikeMixin, Base):
__tablename__ = "notifications"
tg_id = Column(BigInteger, ForeignKey("users.tg_id", ondelete="CASCADE"), primary_key=True)
notification_type = Column(String, primary_key=True)
last_notification_time = Column(DateTime, default=datetime.utcnow)
class Gift(DictLikeMixin, Base):
__tablename__ = "gifts"
gift_id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
sender_tg_id = Column(BigInteger, ForeignKey("users.tg_id"))
recipient_tg_id = Column(BigInteger, ForeignKey("users.tg_id"), nullable=True)
selected_months = Column(Integer)
expiry_time = Column(DateTime)
gift_link = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
is_used = Column(Boolean, default=False)
is_unlimited = Column(Boolean, default=False)
max_usages = Column(Integer, nullable=True)
tariff_id: Mapped[int | None] = mapped_column(ForeignKey("tariffs.id"))
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)
+168
View File
@@ -0,0 +1,168 @@
from datetime import datetime, timedelta
from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DISCOUNT_ACTIVE_HOURS
from 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.debug(f"🗑 Уведомление {notification_type} для пользователя {tg_id} удалено")
async def check_notification_time(session: AsyncSession, tg_id: int, notification_type: str, hours: int = 12) -> bool:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
last_time = result.scalar_one_or_none()
if not last_time:
return True
return datetime.utcnow() - last_time > timedelta(hours=hours)
async def get_last_notification_time(session: AsyncSession, tg_id: int, notification_type: str) -> int | None:
stmt = select(Notification.last_notification_time).where(
Notification.tg_id == tg_id, Notification.notification_type == notification_type
)
result = await session.execute(stmt)
ts = result.scalar_one_or_none()
if ts:
return int(ts.timestamp() * 1000)
return None
async def check_hot_lead_discount(session: AsyncSession, tg_id: int) -> dict:
try:
result = await session.execute(
select(Notification.notification_type, Notification.last_notification_time)
.where(Notification.tg_id == tg_id)
.where(Notification.notification_type.in_(["hot_lead_step_2", "hot_lead_step_3"]))
.order_by(Notification.last_notification_time.desc())
.limit(1)
)
row = result.first()
if not row:
return {"available": False}
notification_type, last_time = row
expires_at = last_time + timedelta(hours=DISCOUNT_ACTIVE_HOURS)
current_time = datetime.utcnow()
if current_time > expires_at:
return {"available": False}
tariff_group = "discounts" if notification_type == "hot_lead_step_2" else "discounts_max"
return {"available": True, "type": notification_type, "tariff_group": tariff_group, "expires_at": expires_at}
except Exception as e:
logger.error(f"❌ Ошибка при проверке скидки горячего лида для {tg_id}: {e}")
return {"available": False}
async def check_notifications_bulk(
session: AsyncSession,
notification_type: str,
hours: int,
tg_ids: list[int] = None,
emails: list[str] = None,
) -> list[dict]:
from sqlalchemy import select
from database.models import BlockedUser, Notification
try:
now = datetime.utcnow()
subq_last_notification = (
select(Notification.tg_id, func.max(Notification.last_notification_time).label("last_notification_time"))
.where(Notification.notification_type == notification_type)
.group_by(Notification.tg_id)
.subquery()
)
stmt = (
select(
User.tg_id,
Key.email,
User.username,
User.first_name,
User.last_name,
subq_last_notification.c.last_notification_time,
)
.outerjoin(Key, Key.tg_id == User.tg_id)
.outerjoin(subq_last_notification, subq_last_notification.c.tg_id == User.tg_id)
)
if notification_type == "inactive_trial":
stmt = stmt.where(
and_(
User.trial.in_([0, -1]),
~User.tg_id.in_(select(BlockedUser.tg_id)),
~User.tg_id.in_(select(Key.tg_id.distinct())),
)
)
if tg_ids:
stmt = stmt.where(User.tg_id.in_(tg_ids))
if emails:
stmt = stmt.where(Key.email.in_(emails))
result = await session.execute(stmt)
users = []
for row in result:
last_time = row.last_notification_time
can_notify = not last_time or (now - last_time > timedelta(hours=hours))
if can_notify:
users.append({
"tg_id": row.tg_id,
"email": row.email,
"username": row.username,
"first_name": row.first_name,
"last_name": row.last_name,
"last_notification_time": (int(last_time.timestamp() * 1000) if last_time else None),
})
logger.info(f"Найдено {len(users)} пользователей, готовых к уведомлению типа {notification_type}")
return users
except Exception as e:
logger.error(f"Ошибка при массовой проверке уведомлений типа {notification_type}: {e}")
return []
+165
View File
@@ -0,0 +1,165 @@
from datetime import datetime, timedelta
from typing import Optional
from pytz import timezone
from sqlalchemy import and_, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Payment
from logger import logger
MOSCOW_TZ = timezone("Europe/Moscow")
async def add_payment(
session: AsyncSession,
tg_id: int,
amount: float,
payment_system: str,
*,
status: str = "success",
currency: str = "RUB",
payment_id: str | None = None,
metadata: dict | None = None,
original_amount: float | None = None,
) -> int:
try:
now_moscow = datetime.now(MOSCOW_TZ).replace(tzinfo=None)
stmt = (
insert(Payment)
.values(
tg_id=tg_id,
amount=amount,
payment_system=payment_system,
status=status,
created_at=now_moscow,
currency=currency,
payment_id=payment_id,
metadata_=metadata,
original_amount=original_amount,
)
.returning(Payment.id)
)
result = await session.execute(stmt)
internal_id = result.scalar_one()
await session.commit()
logger.info(
f"Добавлен платёж id={internal_id}: tg_id={tg_id}, amount={amount}, system={payment_system}, status={status}"
)
return internal_id
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"Ошибка при добавлении платежа: {e}")
raise
async def get_last_payments(
session: AsyncSession,
tg_id: int,
limit: int = 3,
statuses: list[str] | None = None,
):
query = select(Payment).where(Payment.tg_id == tg_id)
if statuses:
query = query.where(Payment.status.in_(statuses))
query = query.order_by(Payment.created_at.desc()).limit(limit)
result = await session.execute(query)
payments = result.scalars().all()
return [
{
"id": p.id,
"tg_id": p.tg_id,
"amount": p.amount,
"currency": p.currency,
"status": p.status,
"payment_system": p.payment_system,
"payment_id": p.payment_id,
"created_at": p.created_at,
"metadata": p.metadata_,
"original_amount": p.original_amount,
}
for p in payments
]
async def get_payment_by_id(session: AsyncSession, internal_id: int) -> dict | None:
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж id={internal_id}")
return dict(payment.__dict__)
logger.info(f"Платёж id={internal_id} не найден")
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа id={internal_id}: {e}")
return None
async def update_payment_status(
session: AsyncSession,
internal_id: int,
new_status: str,
*,
payment_id: str | None = None,
metadata_patch: dict | None = None,
) -> bool:
try:
result = await session.execute(select(Payment).where(Payment.id == internal_id).limit(1))
payment = result.scalar_one_or_none()
if not payment:
logger.info(f"Не удалось сменить статус: платёж id={internal_id} не найден")
return False
payment.status = new_status
if payment_id is not None:
payment.payment_id = payment_id
if metadata_patch:
base = payment.metadata_ or {}
base.update(metadata_patch)
payment.metadata_ = base
await session.commit()
logger.info(f"Статус платежа id={internal_id} изменён на {new_status}")
return True
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"Ошибка при смене статуса платежа id={internal_id}: {e}")
return False
async def get_payment_by_payment_id(session: AsyncSession, pid: str) -> dict | None:
try:
result = await session.execute(select(Payment).where(Payment.payment_id == pid).limit(1))
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Найден платёж payment_id={pid}")
return dict(payment.__dict__)
logger.info(f"Платёж payment_id={pid} не найден")
return None
except SQLAlchemyError as e:
logger.error(f"Ошибка при поиске платежа payment_id={pid}: {e}")
return None
async def cancel_expired_pending_payments(session: AsyncSession) -> int:
cutoff = datetime.now(MOSCOW_TZ).replace(tzinfo=None) - timedelta(minutes=60)
stmt = (
update(Payment)
.where(
and_(
Payment.status.in_(("pending", "issued", "processing", "awaiting_choice")),
Payment.created_at < cutoff,
)
)
.values(status="cancelled")
.returning(Payment.id)
)
res = await session.execute(stmt)
await session.commit()
return len(res.fetchall())
+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()]
+315
View File
@@ -0,0 +1,315 @@
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Key, Server, ServerSpecialgroup, ServerSubgroup, Tariff
from logger import logger
async def create_server(
session: AsyncSession,
cluster_name: str,
server_name: str,
api_url: str,
subscription_url: str,
inbound_id: str,
):
try:
stmt = insert(Server).values(
cluster_name=cluster_name,
server_name=server_name,
api_url=api_url,
subscription_url=subscription_url,
inbound_id=inbound_id,
)
await session.execute(stmt)
await session.commit()
logger.info(f"✅ Сервер {server_name} добавлен в кластер {cluster_name}")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при добавлении сервера {server_name}: {e}")
await session.rollback()
raise
async def delete_server(session: AsyncSession, server_name: str):
try:
stmt = delete(Server).where(Server.server_name == server_name)
await session.execute(stmt)
await session.commit()
logger.info(f"🗑 Сервер {server_name} удалён")
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при удалении сервера {server_name}: {e}")
await session.rollback()
raise
async def get_servers(session: AsyncSession, include_enabled: bool = False) -> dict:
from handlers.utils import ALLOWED_GROUP_CODES
try:
stmt = select(Server)
result = await session.execute(stmt)
servers = result.scalars().all()
ids = [s.id for s in servers]
subs_map = {}
if ids:
r = await session.execute(
select(ServerSubgroup.server_id, ServerSubgroup.subgroup_title).where(ServerSubgroup.server_id.in_(ids))
)
for sid, sg in r.all():
subs_map.setdefault(sid, []).append(sg)
groups_map = {}
if ids:
r2 = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(ids)
)
)
for sid, gc in r2.all():
groups_map.setdefault(sid, []).append(gc)
allowed = set(ALLOWED_GROUP_CODES)
grouped = {}
for s in servers:
if not include_enabled and not s.enabled:
continue
cluster = s.cluster_name
special = sorted({g for g in groups_map.get(s.id, []) if g in allowed})
grouped.setdefault(cluster, []).append({
"server_name": s.server_name,
"api_url": s.api_url,
"subscription_url": s.subscription_url,
"inbound_id": s.inbound_id,
"panel_type": s.panel_type,
"enabled": s.enabled,
"max_keys": s.max_keys,
"tariff_group": s.tariff_group,
"tariff_subgroups": subs_map.get(s.id, []),
"special_groups": special,
"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()
await session.execute(
update(Server)
.where(Server.server_name == server_name)
.values(cluster_name=new_cluster, tariff_group=new_tariff_group)
)
if server_data.get("id") is None:
rid = await session.execute(select(Server.id).where(Server.server_name == server_name).limit(1))
server_id = rid.scalar_one_or_none()
else:
server_id = server_data["id"]
if server_id is not None and new_tariff_group is not None:
await session.execute(
update(ServerSubgroup).where(ServerSubgroup.server_id == server_id).values(group_code=new_tariff_group)
)
await session.commit()
logger.info(
f"✅ Сервер {server_name} перемещен в кластер {new_cluster} с обновлением тарифной группы и привязок подгрупп"
)
return True
except SQLAlchemyError as e:
logger.error(f"❌ Ошибка при обновлении кластера сервера {server_name}: {e}")
await session.rollback()
return False
async def resolve_device_limit_from_group(session: AsyncSession, server_id: str) -> int | None:
r = await session.execute(select(Server.tariff_group).where(Server.server_name == server_id))
group = r.scalar_one_or_none()
if not group:
return None
q = await session.execute(
select(Tariff.device_limit)
.where(Tariff.group_code == group, Tariff.is_active.is_(True))
.order_by(Tariff.duration_days.desc())
.limit(1)
)
dl = q.scalar_one_or_none()
return int(dl) if dl is not None else None
async def filter_cluster_by_subgroup(
session: AsyncSession, cluster: list, target_subgroup: str, cluster_id: str
) -> list:
names = [s.get("server_name") for s in cluster if s.get("server_name")]
if not names:
return []
q_allowed = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
ServerSubgroup.subgroup_title == target_subgroup,
)
)
allowed = {n for (n,) in q_allowed.all()}
if allowed:
return [s for s in cluster if s.get("server_name") in allowed]
total_for_subgroup = await session.scalar(
select(func.count()).select_from(ServerSubgroup).where(ServerSubgroup.subgroup_title == target_subgroup)
)
if not total_for_subgroup:
logger.info(f"Для подгруппы {target_subgroup} нет ни одного сервера. Используем весь кластер {cluster_id}.")
return cluster
q_any = await session.execute(
select(Server.server_name)
.join(ServerSubgroup, ServerSubgroup.server_id == Server.id)
.where(
Server.server_name.in_(names),
Server.enabled.is_(True),
)
)
any_bound = {n for (n,) in q_any.all()}
if any_bound:
logger.warning(f"Нет серверов под подгруппу {target_subgroup} в кластере {cluster_id}. Продление пропущено.")
return []
logger.info(f"В кластере {cluster_id} нет привязок подгрупп. Продлеваем по всему кластеру.")
return cluster
+141
View File
@@ -0,0 +1,141 @@
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:
trial_tariffs_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
return await session.scalar(select(func.count()).select_from(Key).where(Key.tariff_id.in_(trial_tariffs_subquery)))
async def get_tariff_distribution(
session: AsyncSession, include_unbound: bool = False
) -> tuple[list[tuple[int, int]], list[dict]]:
result = await session.execute(
select(Key.tariff_id, func.count(Key.client_id)).where(Key.tariff_id.isnot(None)).group_by(Key.tariff_id)
)
tariff_counts = result.all()
if not include_unbound:
return tariff_counts
result = await session.execute(select(Key.expiry_time).where(Key.tariff_id.is_(None)))
no_tariff_keys = [{"expiry_time": row[0]} for row in result.all()]
return tariff_counts, no_tariff_keys
async def get_tariff_names(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.name).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_groups(session: AsyncSession, tariff_ids: list[int]) -> dict[int, str]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.group_code).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def get_tariff_durations(session: AsyncSession, tariff_ids: list[int]) -> dict[int, int]:
if not tariff_ids:
return {}
result = await session.execute(select(Tariff.id, Tariff.duration_days).where(Tariff.id.in_(tariff_ids)))
return dict(result.all())
async def count_total_referrals(session: AsyncSession) -> int:
return await session.scalar(select(func.count()).select_from(Referral))
async def sum_payments_since(session: AsyncSession, since: date) -> float:
result = await session.scalar(
select(func.coalesce(func.sum(Payment.amount), 0)).where(
and_(
Payment.created_at >= since,
Payment.status == "success",
Payment.payment_system.notin_(["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.status == "success",
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(
and_(
Payment.status == "success",
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()
+266
View File
@@ -0,0 +1,266 @@
import hashlib
from collections import defaultdict
from datetime import datetime
from sqlalchemy import delete, func, insert, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Server, Tariff
from logger import logger
def create_subgroup_hash(subgroup_title: str, group_code: str) -> str:
if not subgroup_title:
return ""
unique_key = f"{subgroup_title}:{group_code}"
hash_object = hashlib.md5(unique_key.encode("utf-8"))
return hash_object.hexdigest()[:8]
async def find_subgroup_by_hash(session: AsyncSession, subgroup_hash: str, group_code: str) -> str | None:
result = await session.execute(
select(Tariff.subgroup_title)
.where(Tariff.group_code == group_code, Tariff.subgroup_title.isnot(None))
.distinct()
)
subgroups = [row[0] for row in result.fetchall()]
for subgroup_title in subgroups:
if create_subgroup_hash(subgroup_title, group_code) == subgroup_hash:
return subgroup_title
return None
async def get_tariffs(
session: AsyncSession, tariff_id: int = None, group_code: str = None, with_subgroup_weights: bool = False
):
try:
if tariff_id:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id))
elif group_code:
result = await session.execute(
select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.sort_order, Tariff.id)
)
else:
result = await session.execute(select(Tariff).order_by(Tariff.sort_order, Tariff.id))
tariffs = [dict(r.__dict__) for r in result.scalars().all()]
if with_subgroup_weights and group_code:
tariffs_without_order = [t for t in tariffs if t.get("sort_order") is None]
if tariffs_without_order:
for tariff in tariffs_without_order:
tariff["sort_order"] = 1
await session.execute(update(Tariff).where(Tariff.id == tariff["id"]).values(sort_order=1))
await session.commit()
grouped = defaultdict(list)
for t in tariffs:
grouped[t.get("subgroup_title")].append(t)
subgroup_weights = {}
for subgroup, tariffs_list in grouped.items():
if subgroup:
total_weight = sum(t.get("sort_order", 1) for t in tariffs_list)
subgroup_weights[subgroup] = total_weight
return {"tariffs": tariffs, "subgroup_weights": subgroup_weights}
return tariffs
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов: {e}")
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.sort_order, Tariff.id)
)
return [dict(r.__dict__) for r in result.scalars().all()]
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении тарифов для кластера {cluster_name}: {e}")
return []
async def create_tariff(session: AsyncSession, data: dict):
try:
data["created_at"] = datetime.utcnow()
data["updated_at"] = datetime.utcnow()
if "sort_order" not in data:
group_code = data.get("group_code")
if group_code:
result = await session.execute(
select(func.max(Tariff.sort_order)).where(
Tariff.group_code == group_code, Tariff.sort_order.isnot(None)
)
)
max_order = result.scalar() or 0
else:
result = await session.execute(select(func.max(Tariff.sort_order)).where(Tariff.sort_order.isnot(None)))
max_order = result.scalar() or 0
data["sort_order"] = max_order + 1
stmt = insert(Tariff).values(**data).returning(Tariff)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one()
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при создании тарифа: {e}")
await session.rollback()
return None
async def update_tariff(session: AsyncSession, tariff_id: int, updates: dict):
if not updates:
return False
try:
updates["updated_at"] = datetime.utcnow()
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(**updates))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при обновлении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def delete_tariff(session: AsyncSession, tariff_id: int):
try:
await session.execute(delete(Tariff).where(Tariff.id == tariff_id))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при удалении тарифа ID={tariff_id}: {e}")
await session.rollback()
return False
async def check_tariff_exists(session: AsyncSession, tariff_id: int):
try:
result = await session.execute(select(Tariff).where(Tariff.id == tariff_id, Tariff.is_active.is_(True)))
tariff = result.scalar_one_or_none()
if tariff:
return True
logger.warning(f"[TARIFF] Тариф {tariff_id} не найден в БД")
return False
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при проверке тарифа {tariff_id}: {e}")
return False
async def get_tariff_sort_order(session: AsyncSession, tariff_id: int) -> int:
try:
result = await session.execute(select(Tariff.sort_order).where(Tariff.id == tariff_id))
sort_order = result.scalar_one_or_none()
if sort_order is None:
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=1))
await session.commit()
return 1
return sort_order
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при получении sort_order для тарифа {tariff_id}: {e}")
return None
async def move_tariff_up(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = max(1, current_order - 1)
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вверх: {e}")
await session.rollback()
return False
async def move_tariff_down(session: AsyncSession, tariff_id: int) -> bool:
try:
current_order = await get_tariff_sort_order(session, tariff_id)
new_order = current_order + 1
await session.execute(update(Tariff).where(Tariff.id == tariff_id).values(sort_order=new_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при перемещении тарифа {tariff_id} вниз: {e}")
await session.rollback()
return False
async def initialize_tariff_sort_orders(session: AsyncSession, group_code: str) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.group_code == group_code).order_by(Tariff.id))
tariffs = result.scalars().all()
if not tariffs:
return True
for i, tariff in enumerate(tariffs):
new_sort_order = 1 + i
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=new_sort_order))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации sort_order для группы {group_code}: {e}")
await session.rollback()
return False
async def initialize_all_tariff_weights(session: AsyncSession) -> bool:
try:
result = await session.execute(select(Tariff).where(Tariff.sort_order.is_(None)))
tariffs_without_weight = result.scalars().all()
if not tariffs_without_weight:
return True
for tariff in tariffs_without_weight:
await session.execute(update(Tariff).where(Tariff.id == tariff.id).values(sort_order=1))
await session.commit()
return True
except SQLAlchemyError as e:
logger.error(f"[TARIFF] Ошибка при инициализации весов тарифов: {e}")
await session.rollback()
return False
+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}")
+252
View File
@@ -0,0 +1,252 @@
from sqlalchemy import and_, 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
EXCLUDED_PAYMENT_MARKERS = ["coupon", "referral", "cashback"]
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:
def _month_key(dt) -> str:
return dt.strftime("%Y-%m")
src_row = await session.execute(
select(TrackingSource.name, TrackingSource.code, TrackingSource.created_at).where(TrackingSource.code == code)
)
src = src_row.first()
if not src:
return None
_src_name, _src_code, created_at = src
reg_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.created_at >= created_at))
.scalar_subquery()
)
trial_subq = (
select(func.count(func.distinct(User.tg_id)))
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.scalar_subquery()
)
payments_subq = (
select(func.count(func.distinct(Payment.tg_id)))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
amount_subq = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.scalar_subquery()
)
header_q = select(
TrackingSource.name,
TrackingSource.code,
TrackingSource.created_at,
reg_subq.label("registrations"),
trial_subq.label("trials"),
payments_subq.label("payments"),
amount_subq.label("total_amount"),
).where(TrackingSource.code == code)
header_res = await session.execute(header_q)
row = header_res.first()
if not row:
return None
payments_base = (
select(
Payment.tg_id.label("tg_id"),
Payment.amount.label("amount"),
Payment.created_at.label("dt"),
)
.join(User, Payment.tg_id == User.tg_id)
.where(
(User.source_code == code)
& (Payment.status == "success")
& not_(Payment.payment_system.in_(EXCLUDED_PAYMENT_MARKERS))
& (Payment.created_at >= created_at)
)
.subquery()
)
first_pay = (
select(
payments_base.c.tg_id.label("tg_id"),
func.min(payments_base.c.dt).label("first_dt"),
)
.group_by(payments_base.c.tg_id)
.subquery()
)
month_expr_new = func.date_trunc("month", payments_base.c.dt).label("month")
new_rows = await session.execute(
select(
month_expr_new,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(
first_pay,
and_(
payments_base.c.tg_id == first_pay.c.tg_id,
payments_base.c.dt == first_pay.c.first_dt,
),
)
.group_by(month_expr_new)
.order_by(month_expr_new)
)
new_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in new_rows.all()}
month_expr_rep = func.date_trunc("month", payments_base.c.dt).label("month")
repeat_rows = await session.execute(
select(
month_expr_rep,
func.count().label("cnt"),
func.coalesce(func.sum(payments_base.c.amount), 0.0).label("amt"),
)
.join(first_pay, payments_base.c.tg_id == first_pay.c.tg_id)
.where(payments_base.c.dt > first_pay.c.first_dt)
.group_by(month_expr_rep)
.order_by(month_expr_rep)
)
repeat_by_month = {r.month: (int(r.cnt), float(r.amt)) for r in repeat_rows.all()}
month_expr_regs = func.date_trunc("month", User.created_at).label("month")
regs_rows = await session.execute(
select(
month_expr_regs,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.created_at >= created_at))
.group_by(month_expr_regs)
.order_by(month_expr_regs)
)
regs_by_month = {r.month: int(r.cnt) for r in regs_rows.all()}
month_expr_trials = func.date_trunc("month", User.created_at).label("month")
trials_rows = await session.execute(
select(
month_expr_trials,
func.count(func.distinct(User.tg_id)).label("cnt"),
)
.where((User.source_code == code) & (User.trial == 1) & (User.created_at >= created_at))
.group_by(month_expr_trials)
.order_by(month_expr_trials)
)
trials_by_month = {r.month: int(r.cnt) for r in trials_rows.all()}
months = set()
months.update(regs_by_month.keys())
months.update(trials_by_month.keys())
months.update(new_by_month.keys())
months.update(repeat_by_month.keys())
monthly = []
for m in sorted(months):
regs = regs_by_month.get(m, 0)
trls = trials_by_month.get(m, 0)
new_cnt, new_amt = new_by_month.get(m, (0, 0.0))
rep_cnt, rep_amt = repeat_by_month.get(m, (0, 0.0))
monthly.append({
"month": _month_key(m),
"registrations": regs,
"trials": trls,
"new_purchases_count": new_cnt,
"new_purchases_amount": new_amt,
"repeat_purchases_count": rep_cnt,
"repeat_purchases_amount": rep_amt,
})
return {
"name": row.name,
"code": row.code,
"created_at": row.created_at,
"registrations": row.registrations or 0,
"trials": row.trials or 0,
"payments": row.payments or 0,
"total_amount": float(row.total_amount or 0),
"monthly": monthly,
}
+237
View File
@@ -0,0 +1,237 @@
from datetime import datetime
from sqlalchemy import delete, exists, func, or_, select, update
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from database.keys import delete_key
from database.models import (
BlockedUser,
CouponUsage,
Gift,
GiftUsage,
Key,
Notification,
Payment,
Referral,
TemporaryData,
User,
)
from logger import logger
async def add_user(
session: AsyncSession,
tg_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language_code: str = None,
is_bot: bool = False,
source_code: str = None,
):
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:
res = await session.execute(
update(User)
.where(User.tg_id == tg_id)
.values(balance=func.coalesce(User.balance, 0) + amount)
.returning(User.balance)
)
new_balance = res.scalar_one_or_none()
await session.commit()
if new_balance is not None:
old_balance = new_balance - amount
logger.info(f"[DB] Баланс пользователя {tg_id} обновлён: {old_balance}{new_balance}")
else:
logger.info(f"[DB] Баланс пользователя {tg_id} не изменён: пользователь не найден")
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при обновлении баланса пользователя {tg_id}: {e}")
await session.rollback()
async def check_user_exists(session: AsyncSession, tg_id: int) -> bool:
stmt = select(exists().where(User.tg_id == tg_id))
result = await session.execute(stmt)
return result.scalar()
async def get_balance(session: AsyncSession, tg_id: int) -> float:
result = await session.execute(select(func.coalesce(User.balance, 0.0)).where(User.tg_id == tg_id))
return round(float(result.scalar_one()), 1)
async def set_user_balance(session: AsyncSession, tg_id: int, balance: float) -> None:
try:
await session.execute(update(User).where(User.tg_id == tg_id).values(balance=balance))
await session.commit()
except SQLAlchemyError as e:
logger.error(f"Ошибка при установке баланса для пользователя {tg_id}: {e}")
await session.rollback()
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(func.coalesce(User.trial, 0)).where(User.tg_id == tg_id))
return int(result.scalar_one())
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(),
)
)
await session.commit()
result = await session.execute(select(User).where(User.tg_id == tg_id))
return dict(result.scalar_one().__dict__)
else:
res = await session.execute(
insert(User)
.values(
tg_id=tg_id,
username=username,
first_name=first_name,
last_name=last_name,
language_code=language_code,
is_bot=is_bot,
created_at=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(),
},
)
.returning(User)
)
obj = res.scalar_one()
await session.commit()
d = obj.__dict__.copy()
d.pop("_sa_instance_state", None)
return d
except SQLAlchemyError as e:
logger.error(f"[DB] Ошибка при UPSERT пользователя {tg_id}: {e}")
await session.rollback()
raise
async def delete_user_data(session: AsyncSession, tg_id: int):
try:
await session.execute(delete(Notification).where(Notification.tg_id == tg_id))
await session.execute(
delete(GiftUsage).where(GiftUsage.gift_id.in_(select(Gift.gift_id).where(Gift.sender_tg_id == tg_id)))
)
await session.execute(delete(Gift).where(Gift.sender_tg_id == tg_id))
await session.execute(update(Gift).where(Gift.recipient_tg_id == tg_id).values(recipient_tg_id=None))
await session.execute(delete(Payment).where(Payment.tg_id == tg_id))
await session.execute(
delete(Referral).where(or_(Referral.referrer_tg_id == tg_id, Referral.referred_tg_id == tg_id))
)
await session.execute(delete(CouponUsage).where(CouponUsage.user_id == tg_id))
await delete_key(session, tg_id)
await session.execute(delete(TemporaryData).where(TemporaryData.tg_id == tg_id))
await session.execute(delete(BlockedUser).where(BlockedUser.tg_id == tg_id))
await session.execute(delete(User).where(User.tg_id == tg_id))
await session.commit()
logger.info(f"[DB] Данные пользователя {tg_id} полностью удалены")
except SQLAlchemyError as e:
await session.rollback()
logger.error(f"[DB] Ошибка при удалении данных пользователя {tg_id}: {e}")
raise
async def mark_trial_extended(tg_id: int, session: AsyncSession):
await session.execute(update(User).where(User.tg_id == tg_id).values(trial=-1))
await session.commit()
async def get_user_snapshot(session: AsyncSession, tg_id: int) -> tuple[int, int] | None:
res = await session.execute(
select(func.coalesce(User.trial, 0), func.count(Key.client_id))
.select_from(User)
.join(Key, Key.tg_id == User.tg_id, isouter=True)
.where(User.tg_id == tg_id)
.group_by(User.tg_id, User.trial)
)
row = res.first()
if row is None:
return None
return int(row[0]), int(row[1])
async def upsert_source_if_empty(session: AsyncSession, tg_id: int, source_code: str) -> None:
if not source_code:
return
stmt = (
insert(User)
.values(tg_id=tg_id, source_code=source_code)
.on_conflict_do_update(
index_elements=[User.tg_id],
set_={"source_code": insert(User).excluded.source_code},
where=(User.source_code.is_(None)),
)
)
await session.execute(stmt)
await session.commit()
+9 -8
View File
@@ -1,16 +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:
admin_ids: int | list[int] = ADMIN_ID
if isinstance(admin_ids, list):
return message.from_user.id in admin_ids
return message.from_user.id == admin_ids
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
+8
View File
@@ -0,0 +1,8 @@
from aiogram.enums import ChatType
from aiogram.filters import BaseFilter
from aiogram.types import Chat, TelegramObject
class IsPrivateFilter(BaseFilter):
async def __call__(self, event: TelegramObject, event_chat: Chat) -> bool:
return event_chat.type == ChatType.PRIVATE
+5 -3
View File
@@ -3,28 +3,30 @@ __all__ = ("router",)
from aiogram import Router
from .admin import router as admin_router
from .captcha import router as captcha_router
from .coupons import router as coupons_router
from .donate import router as donate_router
from .instructions import router as instructions_router
from .keys import router as keys_router
from .notifications import router as notifications_router
from .pay import router as pay_router
from .payments import router as payments_router
from .profile import router as profile_router
from .refferal import router as refferal_router
from .start import router as start_router
router = Router(name="handlers_main_router")
router.include_routers(
start_router,
captcha_router,
profile_router,
pay_router,
donate_router,
coupons_router,
notifications_router,
payments_router,
keys_router,
instructions_router,
admin_router,
refferal_router,
)
+28 -5
View File
@@ -2,16 +2,39 @@ __all__ = ("router",)
from aiogram import Router
from .admin_coupons import router as coupons_router
from .admin_panel import router as panel_router
from .admin_servers import router as servers_router
from .admin_user_editor import router as user_editor_router
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 .module import router as modules_router
from .panel import router as panel_router
from .restart import router as restart_router
from .sender import router as sender_router
from .servers import router as servers_router
from .stats import router as stats_router
from .tariffs import router as tariffs_router
from .users import router as users_router
router = Router(name="admins_main_router")
router.include_routers(
modules_router,
panel_router,
management_router,
servers_router,
clusters_router,
users_router,
stats_router,
backups_router,
sender_router,
coupons_router,
user_editor_router,
restart_router,
bans_router,
ads_router,
tariffs_router,
gifts_router,
)
-164
View File
@@ -1,164 +0,0 @@
from typing import Any
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from database import create_coupon, delete_coupon_from_db, get_all_coupons
from filters.admin import IsAdminFilter
from logger import logger
class AdminCouponsState(StatesGroup):
waiting_for_coupon_data = State()
router = Router()
@router.callback_query(F.data == "coupons_editor", IsAdminFilter())
async def show_coupon_management_menu(
callback_query: types.CallbackQuery, state: FSMContext
):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=" Создать купон", callback_data="create_coupon")
)
builder.row(InlineKeyboardButton(text="Купоны", callback_data="coupons"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"🛠 Меню управления купонами:", reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "coupons", IsAdminFilter())
async def show_coupon_list(callback_query: types.CallbackQuery, session: Any):
try:
coupons = await get_all_coupons(session)
if not coupons:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
"❌ На данный момент нет доступных купонов. 🚫\nВы можете вернуться в меню управления. 🔙",
reply_markup=builder.as_markup(),
)
return
coupon_list = "📜 Список всех купонов:\n\n"
builder = InlineKeyboardBuilder()
for coupon in coupons:
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"💰 <b>Сумма:</b> {coupon['amount']} рублей\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n\n"
)
builder.row(
InlineKeyboardButton(
text=f"❌ Удалить {coupon['code']}",
callback_data=f"delete_coupon_{coupon['code']}",
)
)
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await callback_query.message.answer(
coupon_list, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
@router.callback_query(F.data.startswith("delete_coupon_"), IsAdminFilter())
async def handle_delete_coupon(callback_query: types.CallbackQuery, session: Any):
coupon_code = callback_query.data[len("delete_coupon_") :]
try:
result = await delete_coupon_from_db(coupon_code, session)
if result:
await show_coupon_list(callback_query, session)
else:
await callback_query.message.answer(
f"❌ Купон с кодом <b>{coupon_code}</b> не найден.",
)
await show_coupon_list(callback_query, session)
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
@router.callback_query(F.data == "create_coupon", IsAdminFilter())
async def handle_create_coupon(callback_query: types.CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
await callback_query.message.answer(
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n",
reply_markup=builder.as_markup(),
)
await state.set_state(AdminCouponsState.waiting_for_coupon_data)
@router.message(AdminCouponsState.waiting_for_coupon_data, IsAdminFilter())
async def process_coupon_data(message: types.Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor"))
if len(parts) != 3:
await message.answer(
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈",
reply_markup=builder.as_markup(),
)
return
try:
coupon_code = parts[0]
coupon_amount = float(parts[1])
usage_limit = int(parts[2])
except ValueError:
await message.answer(
"⚠️ <b>Проверьте правильность введенных данных!</b>\n"
"💱 Сумма должна быть числом, 🔢 а лимит — целым числом.",
reply_markup=builder.as_markup(),
)
return
try:
await create_coupon(coupon_code, coupon_amount, usage_limit, session)
result_message = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан! 🎉\n"
f"Сумма: <b>{coupon_amount} рублей</b> 💰\n"
f"Лимит использования: <b>{usage_limit} раз</b> 🔢."
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="coupons_editor")
)
await message.answer(result_message, reply_markup=builder.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
-362
View File
@@ -1,362 +0,0 @@
import subprocess
from datetime import datetime
from io import BytesIO
from typing import Any
from aiogram import F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from backup import backup_database
from bot import bot
from filters.admin import IsAdminFilter
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
displaying_user_info = State()
waiting_for_restart_confirmation = State()
waiting_for_message = State()
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext):
await handle_admin_message(callback_query.message, state)
@router.message(Command("admin"), F.data == "admin", IsAdminFilter())
async def handle_admin_message(message: types.Message, state: FSMContext):
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📊 Статистика пользователей", callback_data="user_stats"
)
)
builder.row(
InlineKeyboardButton(
text="👥 Управление пользователями", callback_data="user_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🖥️ Управление серверами", callback_data="servers_editor"
)
)
builder.row(
InlineKeyboardButton(
text="🎟️ Управление купонами", callback_data="coupons_editor"
)
)
builder.row(
InlineKeyboardButton(text="📢 Массовая рассылка", callback_data="send_to_alls")
)
builder.row(
InlineKeyboardButton(text="💾 Создать резервную копию", callback_data="backups")
)
builder.row(
InlineKeyboardButton(text="🔄 Перезагрузить бота", callback_data="restart_bot")
)
builder.row(InlineKeyboardButton(text="👤 Личный кабинет", callback_data="profile"))
await message.answer("🤖 Панель администратора", reply_markup=builder.as_markup())
@router.callback_query(F.data == "user_stats", IsAdminFilter())
async def user_stats_menu(callback_query: CallbackQuery, session: Any):
try:
total_users = await session.fetchval("SELECT COUNT(*) FROM connections")
total_keys = await session.fetchval("SELECT COUNT(*) FROM keys")
total_referrals = await session.fetchval("SELECT COUNT(*) FROM referrals")
total_payments_today = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= CURRENT_DATE"
)
total_payments_week = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments WHERE created_at >= date_trunc('week', CURRENT_DATE)"
)
total_payments_all_time = await session.fetchval(
"SELECT COALESCE(SUM(amount), 0) FROM payments"
)
active_keys = await session.fetchval(
"SELECT COUNT(*) FROM keys WHERE expiry_time > $1",
int(datetime.utcnow().timestamp() * 1000),
)
expired_keys = total_keys - active_keys
stats_message = (
f"📊 <b>Подробная статистика проекта:</b>\n\n"
f"👥 Пользователи:\n"
f" 🌐 Зарегистрировано: <b>{total_users}</b>\n"
f" 🤝 Привлеченных рефералов: <b>{total_referrals}</b>\n\n"
f"🔑 Ключи:\n"
f" 🌈 Всего сгенерировано: <b>{total_keys}</b>\n"
f" ✅ Действующих: <b>{active_keys}</b>\n"
f" ❌ Просроченных: <b>{expired_keys}</b>\n\n"
f"💰 Финансовая статистика:\n"
f" 📅 За день: <b>{total_payments_today} ₽</b>\n"
f" 📆 За неделю: <b>{total_payments_week} ₽</b>\n"
f" 🏦 За все время: <b>{total_payments_all_time} ₽</b>\n"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔄 Обновить", callback_data="user_stats")
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить пользователей в CSV",
callback_data="export_users_csv",
)
)
builder.row(
InlineKeyboardButton(
text="📥 Выгрузить оплаты в CSV", callback_data="export_payments_csv"
)
)
builder.row(
InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin")
)
await callback_query.message.answer(
stats_message, reply_markup=builder.as_markup()
)
except Exception as e:
logger.error(f"Error in user_stats_menu: {e}")
@router.callback_query(F.data == "export_users_csv", IsAdminFilter())
async def export_users_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
users = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
u.language_code,
u.is_bot,
c.balance,
c.trial
FROM users u
LEFT JOIN connections c ON u.tg_id = c.tg_id
"""
)
if not users:
await callback_query.message.answer(
"📭 Нет пользователей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,language_code,is_bot,balance,trial\n" # Заголовки CSV
for user in users:
csv_data += f"{user['tg_id']},{user['username']},{user['first_name']},{user['last_name']},{user['language_code']},{user['is_bot']},{user['balance']},{user['trial']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="users_export.csv")
await callback_query.message.answer_document(
file,
caption="📥 Экспорт пользователей в CSV",
reply_markup=builder.as_markup(),
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте пользователей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте пользователей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "export_payments_csv", IsAdminFilter())
async def export_payments_csv(callback_query: CallbackQuery, session: Any):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_stats"))
try:
payments = await session.fetch(
"""
SELECT
u.tg_id,
u.username,
u.first_name,
u.last_name,
p.amount,
p.payment_system,
p.status,
p.created_at
FROM users u
JOIN payments p ON u.tg_id = p.tg_id
"""
)
if not payments:
await callback_query.message.answer(
"📭 Нет платежей для экспорта.", reply_markup=builder.as_markup()
)
return
csv_data = "tg_id,username,first_name,last_name,amount,payment_system,status,created_at\n" # Заголовки CSV
for payment in payments:
csv_data += f"{payment['tg_id']},{payment['username']},{payment['first_name']},{payment['last_name']},{payment['amount']},{payment['payment_system']},{payment['status']},{payment['created_at']}\n"
file_name = BytesIO(csv_data.encode("utf-8-sig"))
file_name.seek(0)
file = BufferedInputFile(file_name.getvalue(), filename="payments_export.csv")
await callback_query.message.answer_document(
file, caption="📥 Экспорт платежей в CSV", reply_markup=builder.as_markup()
)
file_name.close()
except Exception as e:
logger.error(f"Ошибка при экспорте платежей в CSV: {e}")
await callback_query.message.answer(
"❗ Произошла ошибка при экспорте платежей.",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "send_to_alls", IsAdminFilter())
async def handle_send_to_all(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
"✍️ Введите текст сообщения, который вы хотите отправить всем клиентам 📢🌐:",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_message)
@router.message(UserEditorState.waiting_for_message, IsAdminFilter())
async def process_message_to_all(
message: types.Message, state: FSMContext, session: Any
):
text_message = message.text
try:
tg_ids = await session.fetch("SELECT tg_id FROM connections")
total_users = len(tg_ids)
success_count = 0
error_count = 0
for record in tg_ids:
tg_id = record["tg_id"]
try:
await bot.send_message(chat_id=tg_id, text=text_message)
success_count += 1
except Exception as e:
error_count += 1
logger.error(
f"❌ Ошибка при отправке сообщения пользователю {tg_id}: {e}"
)
await message.answer(
f"📤 Рассылка завершена:\n"
f"👥 Всего пользователей: {total_users}\n"
f"✅ Успешно отправлено: {success_count}\n"
f"❌ Не доставлено: {error_count}"
)
except Exception as e:
logger.error(f"❗ Ошибка при подключении к базе данных: {e}")
await state.clear()
@router.callback_query(F.data == "backups", IsAdminFilter())
async def handle_backup(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"💾 Инициализация резервного копирования базы данных..."
)
await backup_database()
await callback_query.message.answer(
"✅ Резервная копия успешно создана и отправлена администратору."
)
@router.callback_query(F.data == "restart_bot", IsAdminFilter())
async def handle_restart(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_restart_confirmation)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да, перезапустить", callback_data="confirm_restart"
),
InlineKeyboardButton(text="❌ Нет, отмена", callback_data="admin"),
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
await callback_query.message.answer(
"🤔 Вы уверены, что хотите перезапустить бота?",
reply_markup=builder.as_markup(),
)
@router.callback_query(
F.data == "confirm_restart",
UserEditorState.waiting_for_restart_confirmation,
IsAdminFilter(),
)
async def confirm_restart_bot(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Вернуться в меню", callback_data="admin"))
try:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
capture_output=True,
text=True,
)
await state.clear()
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except subprocess.CalledProcessError:
await callback_query.message.answer(
"🔄 Бот успешно перезапущен.", reply_markup=builder.as_markup()
)
except Exception as e:
await callback_query.message.answer(
f"⚠️ Ошибка при перезагрузке бота: {e.stderr}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "user_editor", IsAdminFilter())
async def user_editor_menu(callback_query: CallbackQuery):
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔍 Поиск по названию ключа",
callback_data="search_by_key_name",
)
)
builder.row(
InlineKeyboardButton(
text="🆔 Поиск по Telegram ID", callback_data="search_by_tg_id"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Поиск по Username", callback_data="search_by_username"
)
)
builder.row(InlineKeyboardButton(text="🔙 Вернуться назад", callback_data="admin"))
await callback_query.message.answer(
"👇 Выберите способ поиска пользователя:", reply_markup=builder.as_markup()
)
-531
View File
@@ -1,531 +0,0 @@
import asyncpg
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi
from backup import create_backup_and_send_to_admins
from config import ADMIN_PASSWORD, ADMIN_USERNAME, DATABASE_URL
from database import check_unique_server_name, get_servers_from_db
from filters.admin import IsAdminFilter
router = Router()
class UserEditorState(StatesGroup):
waiting_for_cluster_name = State()
waiting_for_api_url = State()
waiting_for_inbound_id = State()
waiting_for_server_name = State()
waiting_for_subscription_url = State()
@router.callback_query(F.data == "servers_editor", IsAdminFilter())
async def handle_servers_editor(callback_query: types.CallbackQuery):
servers = await get_servers_from_db()
builder = InlineKeyboardBuilder()
for cluster_name, cluster_servers in servers.items():
builder.row(
InlineKeyboardButton(
text=f"⚙️ {cluster_name}", callback_data=f"manage_cluster|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(text=" Добавить кластер", callback_data="add_cluster")
)
builder.row(InlineKeyboardButton(text="🔙 Назад в админку", callback_data="admin"))
await callback_query.message.answer(
"<b>🔧 Управление кластерами</b>\n\n"
"<i>📌 Здесь вы можете добавить новый кластер.</i>\n\n"
"<i>🌐 <b>Кластеры</b> — это пространство серверов, в пределах которого создается подписка.</i>\n"
"💡 Если вы хотите выдавать по 1 серверу, то добавьте всего 1 сервер в кластер.\n\n"
"<i>⚠️ <b>Важно:</b> Кластеры удаляются автоматически, если удалить все серверы внутри них.</i>\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data == "add_cluster", IsAdminFilter())
async def handle_add_cluster(callback_query: types.CallbackQuery, state: FSMContext):
await callback_query.message.answer(
"🔧 <b>Введите имя нового кластера:</b>\n\n"
"<b>Имя кластера должно быть уникальным и на английском языке.</b>\n"
"<i>Пример:</i> <code>cluster2</code> или <code>us_east_1</code>",
parse_mode="HTML",
)
await state.set_state(UserEditorState.waiting_for_cluster_name)
@router.message(UserEditorState.waiting_for_cluster_name, IsAdminFilter())
async def handle_cluster_name_input(message: types.Message, state: FSMContext):
cluster_name = message.text.strip()
if cluster_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not cluster_name:
await message.answer("❌ Имя кластера не может быть пустым. Попробуйте снова.")
return
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.message(UserEditorState.waiting_for_server_name, IsAdminFilter())
async def handle_server_name_input(message: types.Message, state: FSMContext):
server_name = message.text.strip()
if server_name == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not server_name:
await message.answer("❌ Имя сервера не может быть пустым. Попробуйте снова.")
return
server_unique = await check_unique_server_name(server_name)
if not server_unique:
await message.answer(
"❌ Сервер с таким именем уже существует. Пожалуйста, выберите другое имя."
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
await state.update_data(server_name=server_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите API URL для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"API URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port/panel_path</code>\n\n"
"URL должен быть без слэша на конце!\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_api_url)
@router.message(UserEditorState.waiting_for_api_url, IsAdminFilter())
async def handle_api_url_input(message: types.Message, state: FSMContext):
api_url = message.text.strip()
if api_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not api_url.startswith("https://"):
await message.answer(
"❌ API URL должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
api_url = api_url.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(api_url=api_url)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите subscription_url для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Subscription URL должен быть в следующем формате:\n\n"
"<code>https://your_domain:port_sub/sub_path</code>\n\n"
"URL должен быть без слэша и имени клиента на конце!\n"
"Его можно увидеть в панели 3x-ui в информации о клиенте.",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_subscription_url)
@router.message(UserEditorState.waiting_for_subscription_url, IsAdminFilter())
async def handle_subscription_url_input(message: types.Message, state: FSMContext):
subscription_url = message.text.strip()
if subscription_url == "❌ Отменить":
await state.clear()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔧 Управление кластерами", callback_data="servers_editor"
)
)
await message.answer(
"Процесс создания кластера был отменен. Вы вернулись в меню управления серверами.",
reply_markup=builder.as_markup(),
)
return
if not subscription_url.startswith("https://"):
await message.answer(
"❌ subscription_url должен начинаться с <code>https://</code>. Попробуйте снова.",
parse_mode="HTML",
)
return
subscription_url = subscription_url.rstrip("/")
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
await state.update_data(subscription_url=subscription_url)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await message.answer(
f"<b>Введите inbound_id для сервера {server_name} в кластере {cluster_name}:</b>\n\n"
"Это номер подключения vless в вашей панели 3x-ui. Обычно это <b>1</b> при чистой настройке по гайду.\n\n",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_inbound_id)
@router.message(UserEditorState.waiting_for_inbound_id, IsAdminFilter())
async def handle_inbound_id_input(message: types.Message, state: FSMContext):
inbound_id = message.text.strip()
if not inbound_id.isdigit():
await message.answer(
"❌ inbound_id должен быть числовым значением. Попробуйте снова."
)
return
user_data = await state.get_data()
cluster_name = user_data.get("cluster_name")
server_name = user_data.get("server_name")
api_url = user_data.get("api_url")
subscription_url = user_data.get("subscription_url")
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
INSERT INTO servers (cluster_name, server_name, api_url, subscription_url, inbound_id)
VALUES ($1, $2, $3, $4, $5)
""",
cluster_name,
server_name,
api_url,
subscription_url,
inbound_id,
)
await conn.close()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад к кластерам", callback_data="servers_editor"
)
)
await message.answer(
f"✅ Кластер {cluster_name} и сервер {server_name} успешно добавлены!",
reply_markup=builder.as_markup(),
)
await state.clear()
@router.callback_query(F.data.startswith("manage_cluster|"), IsAdminFilter())
async def handle_manage_cluster(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=f"manage_server|{server['server_name']}",
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер", callback_data=f"add_server|{cluster_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность серверов",
callback_data=f"server_availability|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап кластера",
callback_data=f"backup_cluster|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🔧 Управление серверами для кластера {cluster_name}",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("server_availability|"), IsAdminFilter())
async def handle_check_server_availability(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
if not cluster_servers:
await callback_query.answer(f"Кластер '{cluster_name}' не содержит серверов.")
return
in_progress_message = await callback_query.message.answer(
f"🖥️ Проверка доступности серверов для кластера {cluster_name}.\n\n"
"Это может занять до 1 минуты, пожалуйста, подождите..."
)
availability_message = (
f"🖥️ Проверка доступности серверов для кластера {cluster_name} завершена:\n\n"
)
for server in cluster_servers:
xui = AsyncApi(
server["api_url"], username=ADMIN_USERNAME, password=ADMIN_PASSWORD
)
try:
await xui.login()
online_users = len(await xui.client.online())
availability_message += (
f"🌍 {server['server_name']}: {online_users} активных пользователей.\n"
)
except Exception as e:
availability_message += f"{server['server_name']}: Не удалось получить информацию. Ошибка: {e}\n"
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await in_progress_message.edit_text(
availability_message, reply_markup=builder.as_markup()
)
await callback_query.answer()
@router.callback_query(F.data.startswith("manage_server|"), IsAdminFilter())
async def handle_manage_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
server = None
cluster_name = None
for cluster, cluster_servers in servers.items():
server = next(
(s for s in cluster_servers if s["server_name"] == server_name), None
)
if server:
cluster_name = cluster
break
if server:
api_url = server["api_url"]
subscription_url = server["subscription_url"]
inbound_id = server["inbound_id"]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🗑️ Удалить", callback_data=f"delete_server|{server_name}"
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад", callback_data=f"manage_cluster|{cluster_name}"
)
)
await callback_query.message.answer(
f"<b>🔧 Информация о сервере {server_name}:</b>\n\n"
f"<b>📡 API URL:</b> {api_url}\n"
f"<b>🌐 Subscription URL:</b> {subscription_url}\n"
f"<b>🔑 Inbound ID:</b> {inbound_id}",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
else:
await callback_query.message.answer("❌ Сервер не найден.")
@router.callback_query(F.data.startswith("delete_server|"), IsAdminFilter())
async def handle_delete_server(callback_query: types.CallbackQuery, state: FSMContext):
server_name = callback_query.data.split("|")[1]
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="✅ Да", callback_data=f"confirm_delete_server|{server_name}"
),
InlineKeyboardButton(
text="❌ Нет", callback_data=f"manage_server|{server_name}"
),
)
await callback_query.message.answer(
f"🗑️ Вы уверены, что хотите удалить сервер {server_name}?",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_server|"), IsAdminFilter())
async def handle_confirm_delete_server(
callback_query: types.CallbackQuery, state: FSMContext
):
server_name = callback_query.data.split("|")[1]
conn = await asyncpg.connect(DATABASE_URL)
await conn.execute(
"""
DELETE FROM servers WHERE server_name = $1
""",
server_name,
)
await conn.close()
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в управление кластерами", callback_data="servers_editor"
)
)
await callback_query.message.answer(
f"🗑️ Сервер {server_name} успешно удален.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("add_server|"), IsAdminFilter())
async def handle_add_server(callback_query: types.CallbackQuery, state: FSMContext):
cluster_name = callback_query.data.split("|")[1]
await state.update_data(cluster_name=cluster_name)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="❌ Отменить", callback_data="servers_editor")
)
await callback_query.message.answer(
f"<b>Введите имя сервера для кластера {cluster_name}:</b>\n\n"
"Рекомендуется указать локацию сервера в имени.\n\n"
"<i>Пример:</i> <code>server-asia</code>, <code>server-europe</code>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await state.set_state(UserEditorState.waiting_for_server_name)
@router.callback_query(F.data.startswith("backup_cluster|"), IsAdminFilter())
async def handle_backup_cluster(callback_query: types.CallbackQuery):
cluster_name = callback_query.data.split("|")[1]
servers = await get_servers_from_db()
cluster_servers = servers.get(cluster_name, [])
for server in cluster_servers:
xui = AsyncApi(
server["api_url"],
username=ADMIN_USERNAME,
password=ADMIN_PASSWORD,
)
await create_backup_and_send_to_admins(xui)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="servers_editor"))
await callback_query.message.answer(
f"<b>Бэкап для кластера {cluster_name} был успешно создан и отправлен администраторам!</b>\n\n"
f"🔔 <i>Бэкапы отправлены в боты панелей.</i>",
parse_mode="HTML",
reply_markup=builder.as_markup(),
)
await callback_query.answer()
-669
View File
@@ -1,669 +0,0 @@
import asyncio
from datetime import datetime
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, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import TOTAL_GB
from database import delete_user_data, get_client_id_by_email, get_servers_from_db, restore_trial, update_key_expiry
from filters.admin import IsAdminFilter
from handlers.keys.key_utils import (
delete_key_from_cluster,
delete_key_from_db,
renew_key_in_cluster,
)
from handlers.utils import sanitize_key_name
from logger import logger
router = Router()
class UserEditorState(StatesGroup):
waiting_for_tg_id = State()
waiting_for_username = State()
displaying_user_info = State()
waiting_for_new_balance = State()
waiting_for_key_name = State()
waiting_for_expiry_time = State()
@router.callback_query(F.data == "search_by_tg_id", IsAdminFilter())
async def prompt_tg_id(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Telegram ID клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_tg_id)
@router.callback_query(F.data == "search_by_username", IsAdminFilter())
async def prompt_username(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Введите Username клиента:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_username)
@router.message(UserEditorState.waiting_for_username, IsAdminFilter())
async def handle_username_input(
message: types.Message, state: FSMContext, session: Any
):
username = message.text.strip().lstrip("@")
user_record = await session.fetchrow(
"SELECT tg_id FROM users WHERE username = $1", username
)
if not user_record:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🔍 Пользователь с указанным username не найден. 🚫",
reply_markup=builder.as_markup(),
)
await state.clear()
return
tg_id = user_record["tg_id"]
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.message(UserEditorState.waiting_for_tg_id, F.text.isdigit(), IsAdminFilter())
async def handle_tg_id_input(message: types.Message, state: FSMContext, session: Any):
tg_id = int(message.text)
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
if balance is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пользователь с указанным tg_id не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(
InlineKeyboardButton(
text="📝 Изменить баланс",
callback_data=f"change_balance_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(
InlineKeyboardButton(
text="🔄 Восстановить пробник",
callback_data=f"restore_trial_{tg_id}",
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("restore_trial_"), IsAdminFilter())
async def handle_restore_trial(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
await restore_trial(tg_id, session)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора", callback_data="admin"
)
)
await callback_query.message.answer(
"✅ Триал успешно восстановлен.", reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("change_balance_"), IsAdminFilter())
async def process_balance_change(callback_query: CallbackQuery, state: FSMContext):
tg_id = int(callback_query.data.split("_")[2])
await state.update_data(tg_id=tg_id)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"💸 Введите новую сумму баланса:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_new_balance)
@router.message(UserEditorState.waiting_for_new_balance, IsAdminFilter())
async def handle_new_balance_input(
message: types.Message, state: FSMContext, session: Any
):
if not message.text.isdigit() or int(message.text) < 0:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, введите корректную сумму для изменения баланса.",
reply_markup=builder.as_markup(),
)
return
new_balance = int(message.text)
user_data = await state.get_data()
tg_id = user_data.get("tg_id")
await session.execute(
"UPDATE connections SET balance = $1 WHERE tg_id = $2",
new_balance,
tg_id,
)
response_message = f"✅ Баланс успешно изменен на <b>{new_balance}</b>."
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🔙 Назад в меню администратора",
callback_data="admin",
)
)
await message.answer(response_message, reply_markup=builder.as_markup())
await state.clear()
async def get_key_details(email, session):
record = await session.fetchrow(
"""
SELECT k.key, k.expiry_time, k.server_id, c.tg_id, c.balance
FROM keys k
JOIN connections c ON k.tg_id = c.tg_id
WHERE k.email = $1
""",
email,
)
if not record:
return None
servers = await get_servers_from_db()
cluster_name = "Неизвестный кластер"
for cluster_name, cluster_servers in servers.items():
if any(
server["inbound_id"] == record["server_id"] for server in cluster_servers
):
cluster_name = cluster_name
break
expiry_date = datetime.utcfromtimestamp(record["expiry_time"] / 1000)
current_date = datetime.utcnow()
time_left = expiry_date - current_date
if time_left.total_seconds() <= 0:
days_left_message = "<b>Ключ истек.</b>"
elif time_left.days > 0:
days_left_message = f"Осталось дней: <b>{time_left.days}</b>"
else:
hours_left = time_left.seconds // 3600
days_left_message = f"Осталось часов: <b>{hours_left}</b>"
return {
"key": record["key"],
"expiry_date": expiry_date.strftime("%d %B %Y года"),
"days_left_message": days_left_message,
"server_name": cluster_name,
"balance": record["balance"],
"tg_id": record["tg_id"],
}
@router.callback_query(F.data.startswith("edit_key_"), IsAdminFilter())
async def process_key_edit(callback_query: CallbackQuery, session: Any):
email = callback_query.data.split("_", 2)[2]
key_details = await get_key_details(email, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 <b>Информация о ключе не найдена.</b> 🚫",
reply_markup=builder.as_markup(),
)
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Кластер: <b>{key_details['server_name']}</b>"
)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{email}",
)
)
builder.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{email}",
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data == "search_by_key_name", IsAdminFilter())
async def prompt_key_name(callback_query: CallbackQuery, state: FSMContext):
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔑 Введите имя ключа:", reply_markup=builder.as_markup()
)
await state.set_state(UserEditorState.waiting_for_key_name)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(
message: types.Message, state: FSMContext, session: Any
):
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(key_name, session)
if not key_details:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"🚫 Пользователь с указанным именем ключа не найден.",
reply_markup=builder.as_markup(),
)
await state.clear()
return
response_message = (
f"🔑 Ключ: <code>{key_details['key']}</code>\n"
f"⏰ Дата истечения: <b>{key_details['expiry_date']}</b>\n"
f"💰 Баланс пользователя: <b>{key_details['balance']}</b>\n"
f"🌐 Сервер: <b>{key_details['server_name']}</b>"
)
key_buttons = InlineKeyboardBuilder()
key_buttons.row(
InlineKeyboardButton(
text="ℹ️ Получить информацию о юзере",
callback_data=f"user_info|{key_details['tg_id']}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="⏳ Изменить время истечения",
callback_data=f"change_expiry|{key_name}",
)
)
key_buttons.row(
InlineKeyboardButton(
text="❌ Удалить ключ",
callback_data=f"delete_key_admin|{key_name}",
)
)
key_buttons.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(response_message, reply_markup=key_buttons.as_markup())
await state.clear()
@router.callback_query(F.data.startswith("change_expiry|"), IsAdminFilter())
async def prompt_expiry_change(callback_query: CallbackQuery, state: FSMContext):
email = callback_query.data.split("|")[1]
await callback_query.message.answer(
f"⏳ Введите новое время истечения для ключа <b>{email}</b> в формате <code>YYYY-MM-DD HH:MM:SS</code>:"
)
await state.update_data(email=email)
await state.set_state(UserEditorState.waiting_for_expiry_time)
@router.message(UserEditorState.waiting_for_expiry_time, IsAdminFilter())
async def handle_expiry_time_input(
message: types.Message, state: FSMContext, session: Any
):
user_data = await state.get_data()
email = user_data.get("email")
if not email:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"📧 Email не найден в состоянии. 🚫", reply_markup=builder.as_markup()
)
await state.clear()
return
try:
expiry_time_str = message.text
expiry_time = int(
datetime.strptime(expiry_time_str, "%Y-%m-%d %H:%M:%S").timestamp() * 1000
)
client_id = await get_client_id_by_email(email)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
f"🚫 Клиент с email {email} не найден. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
record = await session.fetchrow(
"SELECT server_id FROM keys WHERE client_id = $1", client_id
)
if not record:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
)
await message.answer(
"🚫 Клиент не найден в базе данных. 🔍",
reply_markup=builder.as_markup(),
)
await state.clear()
return
clusters = await get_servers_from_db()
async def update_key_on_all_servers():
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
asyncio.create_task(
renew_key_in_cluster(
cluster_name,
email,
client_id,
expiry_time,
total_gb=TOTAL_GB,
)
)
)
await asyncio.gather(*tasks)
await update_key_on_all_servers()
await update_key_expiry(client_id, expiry_time)
response_message = f"✅ Время истечения ключа для клиента {client_id} ({email}) успешно обновлено на всех серверах."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="admin"))
await message.answer(response_message, reply_markup=builder.as_markup())
except ValueError:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await message.answer(
"❌ Пожалуйста, используйте формат: YYYY-MM-DD HH:MM:SS.",
reply_markup=builder.as_markup(),
)
except Exception as e:
logger.error(e)
await state.clear()
@router.callback_query(F.data.startswith("delete_key_admin|"), IsAdminFilter())
async def process_callback_delete_key(
callback_query: types.CallbackQuery, session: Any
):
email = callback_query.data.split("|")[1]
client_id = await session.fetchval(
"SELECT client_id FROM keys WHERE email = $1", email
)
if client_id is None:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
await callback_query.message.answer(
"🔍 Ключ не найден. 🚫", reply_markup=builder.as_markup()
)
return
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="✅ Да, удалить",
callback_data=f"confirm_delete_admin|{client_id}",
)
)
builder.row(
types.InlineKeyboardButton(text="❌ Нет, отменить", callback_data="user_editor")
)
await callback_query.message.answer(
"<b>❓ Вы уверены, что хотите удалить ключ?</b>",
reply_markup=builder.as_markup(),
)
@router.callback_query(F.data.startswith("confirm_delete_admin|"), IsAdminFilter())
async def process_callback_confirm_delete(
callback_query: types.CallbackQuery, session: Any
):
client_id = callback_query.data.split("|")[1]
record = await session.fetchrow(
"SELECT email FROM keys WHERE client_id = $1", client_id
)
if record:
email = record["email"]
response_message = "✅ Ключ успешно удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
clusters = await get_servers_from_db()
async def delete_key_from_servers(email, client_id):
tasks = []
for cluster_name, cluster_servers in clusters.items():
for server in cluster_servers:
tasks.append(
delete_key_from_cluster(cluster_name, email, client_id)
)
await asyncio.gather(*tasks)
await delete_key_from_servers(email, client_id)
await delete_key_from_db(client_id, session)
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
else:
response_message = "🚫 Ключ не найден или уже удален."
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="⬅️ Назад", callback_data="view_keys"))
await callback_query.message.answer(
response_message, reply_markup=builder.as_markup()
)
@router.callback_query(F.data.startswith("user_info|"), IsAdminFilter())
async def handle_user_info(
callback_query: types.CallbackQuery, state: FSMContext, session: Any
):
tg_id = int(callback_query.data.split("|")[1])
username = await session.fetchval(
"SELECT username FROM users WHERE tg_id = $1", tg_id
)
balance = await session.fetchval(
"SELECT balance FROM connections WHERE tg_id = $1", tg_id
)
key_records = await session.fetch("SELECT email FROM keys WHERE tg_id = $1", tg_id)
referral_count = await session.fetchval(
"SELECT COUNT(*) FROM referrals WHERE referrer_tg_id = $1", tg_id
)
builder = InlineKeyboardBuilder()
for (email,) in key_records:
builder.row(
InlineKeyboardButton(text=f"🔑 {email}", callback_data=f"edit_key_{email}")
)
builder.row(InlineKeyboardButton(text="📝 Изменить баланс", callback_data=f"change_balance_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔄 Восстановить пробник", callback_data=f"restore_trial_{tg_id}"))
builder.row(InlineKeyboardButton(text="❌ Удалить клиента", callback_data=f"confirm_delete_user_{tg_id}"))
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor"))
user_info = (
f"📊 Информация о пользователе:\n\n"
f"🆔 ID пользователя: <b>{tg_id}</b>\n"
f"👤 Логин пользователя: <b>@{username}</b>\n"
f"💰 Баланс: <b>{balance}</b>\n"
f"👥 Количество рефералов: <b>{referral_count}</b>\n"
f"🔑 Ключи (для редактирования нажмите на ключ):"
)
await callback_query.message.answer(user_info, reply_markup=builder.as_markup())
await state.set_state(UserEditorState.displaying_user_info)
@router.callback_query(F.data.startswith("confirm_delete_user_"), IsAdminFilter())
async def confirm_delete_user(callback_query: types.CallbackQuery, state: FSMContext, session: Any):
tg_id = int(callback_query.data.split("_")[3])
confirmation_markup = InlineKeyboardMarkup(
row_width=2,
inline_keyboard=[
[InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"delete_user_{tg_id}")],
[InlineKeyboardButton(text="❌ Отменить", callback_data="user_editor")],
],
)
await callback_query.message.answer(
f"Вы уверены, что хотите удалить пользователя с ID {tg_id}?", reply_markup=confirmation_markup
)
@router.callback_query(F.data.startswith("delete_user_"), IsAdminFilter())
async def delete_user(callback_query: types.CallbackQuery, session: Any):
tg_id = int(callback_query.data.split("_")[2])
key_records = await session.fetch("SELECT email, client_id FROM keys WHERE tg_id = $1", tg_id)
async def delete_keys_from_servers():
try:
tasks = []
for email, client_id in key_records:
servers = await get_servers_from_db()
for cluster_id, cluster in servers.items():
tasks.append(delete_key_from_cluster(cluster_id, email, client_id))
await asyncio.gather(*tasks)
except Exception as e:
logger.error(f"Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
await delete_keys_from_servers()
try:
await delete_user_data(session, tg_id)
back_button = InlineKeyboardButton(text="🔙 Назад", callback_data="user_editor")
keyboard = InlineKeyboardMarkup(inline_keyboard=[[back_button]])
await callback_query.message.answer(f"🗑️ Пользователь с ID {tg_id} был удален.", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка при удалении данных из базы данных для пользователя {tg_id}: {e}")
await callback_query.message.answer(
f"❌ Произошла ошибка при удалении пользователя с ID {tg_id}. Попробуйте снова."
)
+3
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .backups_handler import router
+36
View File
@@ -0,0 +1,36 @@
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from utils.backup import backup_database
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "backups"),
IsAdminFilter(),
)
async def handle_backups(callback_query: CallbackQuery):
kb = build_admin_back_kb("management")
await callback_query.message.edit_text(
text="💾 Инициализация резервного копирования базы данных...",
reply_markup=kb,
)
try:
exception = await backup_database()
if exception:
text = f"❌ Ошибка при создании резервной копии:\n<code>{exception}</code>"
else:
text = "✅ Резервная копия успешно создана и отправлена администраторам."
except Exception as e:
text = f"❌ Непредвиденная ошибка: <code>{e}</code>"
await callback_query.message.edit_text(text=text, reply_markup=kb)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .bans_handler import router
+201
View File
@@ -0,0 +1,201 @@
import csv
import io
from datetime import datetime, timezone
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import BufferedInputFile, CallbackQuery, Message
from sqlalchemy import delete, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from database import delete_user_data
from database.models import ManualBan
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import build_bans_kb
router = Router()
class PreemptiveBanStates(StatesGroup):
waiting_for_preemptive_ids = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "bans"), IsAdminFilter())
async def handle_bans(callback_query: CallbackQuery):
text_ = (
"🚫 <b>Управление банами</b>\n\n"
"📛 <b>Забанившие бота</b> — пользователи, которые заблокировали бота вручную.\n"
"🔒 <b>Ручной бан</b> — пользователи, которых вы забанили через админку.\n\n"
"⬇ Выберите нужный раздел:"
)
await callback_query.message.edit_text(text=text_, reply_markup=build_bans_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_export"), IsAdminFilter())
async def handle_bans_export(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("management")
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
banned_users = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id"])
for user in banned_users:
writer.writerow([user.tg_id])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="banned_users.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт пользователей, заблокировавших бота (CSV)",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при экспорте: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_banned"), IsAdminFilter())
async def handle_bans_delete_banned(callback_query: CallbackQuery, session: AsyncSession):
kb = build_admin_back_kb("bans")
try:
result = await session.execute(text("SELECT tg_id FROM blocked_users"))
blocked_users = result.all()
blocked_ids = [user.tg_id for user in blocked_users]
if not blocked_ids:
await callback_query.message.answer(
text="📂 Нет заблокировавших пользователей для удаления.",
reply_markup=kb,
)
return
for tg_id in blocked_ids:
await delete_user_data(session, tg_id)
await session.execute(
text("DELETE FROM blocked_users WHERE tg_id = ANY(:blocked_ids)"),
{"blocked_ids": blocked_ids},
)
await session.commit()
await callback_query.message.answer(
text=f"🗑️ Удалены данные о {len(blocked_ids)} пользователях и связанных записях.",
reply_markup=kb,
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Произошла ошибка при удалении записей: {e}",
reply_markup=kb,
)
@router.callback_query(AdminPanelCallback.filter(F.action == "manual_bans_export"), IsAdminFilter())
async def handle_manual_bans_export(callback_query: CallbackQuery, session: AsyncSession):
build_admin_back_kb("bans")
try:
result = await session.execute(text("SELECT tg_id, banned_at, reason, until FROM manual_bans"))
rows = result.all()
csv_output = io.StringIO()
writer = csv.writer(csv_output)
writer.writerow(["tg_id", "banned_at", "reason", "until"])
for user in rows:
writer.writerow([user.tg_id, user.banned_at, user.reason, user.until])
csv_output.seek(0)
document = BufferedInputFile(file=csv_output.getvalue().encode("utf-8"), filename="manual_bans.csv")
await callback_query.message.answer_document(
document=document,
caption="📥 Экспорт вручную забаненных пользователей",
)
except Exception as e:
await callback_query.message.answer(
text=f"❗ Ошибка при экспорте: {e}",
reply_markup=build_admin_back_kb("bans"),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_delete_manual"), IsAdminFilter())
async def handle_delete_manual_banned(callback_query: CallbackQuery, session: AsyncSession):
try:
await session.execute(delete(ManualBan))
await session.commit()
await callback_query.message.edit_text(
"🗑️ Вручную забаненные пользователи удалены.",
reply_markup=build_bans_kb(),
)
logger.info("[BANS] Очищены записи из manual_bans")
except Exception as e:
logger.error(f"[BANS] Ошибка при очистке manual_bans: {e}")
await callback_query.message.edit_text("❌ Ошибка при удалении вручную забаненных пользователей.")
@router.callback_query(AdminPanelCallback.filter(F.action == "bans_preemptive"), IsAdminFilter())
async def handle_preemptive_ban_start(callback: CallbackQuery, state: FSMContext):
await state.set_state(PreemptiveBanStates.waiting_for_preemptive_ids)
await callback.message.edit_text(
"📥 Отправьте список Telegram ID (один на строке), которых нужно заранее забанить (теневой бан).\n\n"
"Пример:\n<code>123456789\n987654321</code>",
reply_markup=build_admin_back_kb("bans"),
)
@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()
+35
View File
@@ -0,0 +1,35 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback
def build_bans_kb():
builder = InlineKeyboardBuilder()
builder.button(
text="📛 Забанившие бота",
callback_data=AdminPanelCallback(action="bans_export").pack(),
)
builder.button(
text="📛 Забаненные вручную",
callback_data=AdminPanelCallback(action="manual_bans_export").pack(),
)
builder.button(
text="Забанить заранее",
callback_data=AdminPanelCallback(action="bans_preemptive").pack(),
)
builder.button(
text="🗑️ Удалить забанивших",
callback_data=AdminPanelCallback(action="bans_delete_banned").pack(),
)
builder.button(
text="🗑️ Очистить забаненных",
callback_data=AdminPanelCallback(action="bans_delete_manual").pack(),
)
builder.button(
text="🔙 Назад",
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.adjust(1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .clusters_handler import router
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
from ..servers.keyboard import AdminServerCallback
class AdminClusterCallback(CallbackData, prefix="admin_cluster"):
action: str
data: str | None = None
def build_clusters_editor_kb(servers: dict) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
cluster_names = list(servers.keys())
for i in range(0, len(cluster_names), 2):
row_buttons = []
for name in cluster_names[i : i + 2]:
servers_in_cluster = servers[name]
all_disabled = all(not s["enabled"] for s in servers_in_cluster)
label = f"{name} (отключен)" if all_disabled else f"⚙️ {name}"
row_buttons.append(
InlineKeyboardButton(
text=label,
callback_data=AdminClusterCallback(action="manage", data=name).pack(),
)
)
builder.row(*row_buttons)
builder.row(
InlineKeyboardButton(
text=" Добавить кластер",
callback_data=AdminClusterCallback(action="add").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_manage_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🌍 {server['server_name']}",
callback_data=AdminServerCallback(action="manage", data=server["server_name"]).pack(),
)
)
builder.row(
InlineKeyboardButton(
text=" Добавить сервер",
callback_data=AdminServerCallback(action="add", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="attach_tariff_menu", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_attach_tariff_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="🗂 Спецгруппы",
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_groups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🗂 Подгруппы",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
),
InlineKeyboardButton(
text="🧹 Сбросить",
callback_data=AdminClusterCallback(action="reset_cluster_subgroups", data=cluster_name).pack(),
),
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_select_subgroup_servers_kb(
cluster_name: str, cluster_servers: list, selected: set[str]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_subgroup", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📚 Выбрать подгруппу",
callback_data=AdminClusterCallback(action="choose_subgroup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_subgroup_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_subgroup_selection_kb(cluster_name: str, subgroups: list[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for i, title in enumerate(subgroups):
builder.button(
text=title,
callback_data=AdminClusterCallback(action="apply_tariff_subgroup", data=f"{cluster_name}|{i}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад к выбору серверов",
callback_data=AdminClusterCallback(action="set_subgroup", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_cluster_management_kb(cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="📡 Серверы",
callback_data=f"cluster_servers|{cluster_name}",
)
)
builder.row(
InlineKeyboardButton(
text="🌐 Доступность",
callback_data=AdminClusterCallback(action="availability", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔄 Синхронизация",
callback_data=AdminClusterCallback(action="sync", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💾 Создать бэкап",
callback_data=AdminClusterCallback(action="backup", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="⏳ Добавить время",
callback_data=AdminClusterCallback(action="add_time", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="✏️ Сменить название",
callback_data=AdminClusterCallback(action="rename", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="💸 Тариф(Установить/изменить)",
callback_data=AdminClusterCallback(action="set_tariff", data=cluster_name).pack(),
)
)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="clusters").pack()))
return builder.as_markup()
def build_sync_cluster_kb(cluster_servers: list, cluster_name: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for server in cluster_servers:
builder.row(
InlineKeyboardButton(
text=f"🔄 Синхронизировать {server['server_name']}",
callback_data=AdminClusterCallback(action="sync-server", data=server["server_name"]).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📍 Синхронизировать кластер",
callback_data=AdminClusterCallback(action="sync-cluster", data=cluster_name).pack(),
)
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_panel_type_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌐 3X-UI", callback_data=AdminClusterCallback(action="panel_3xui").pack())
builder.button(
text="🌀 Remnawave",
callback_data=AdminClusterCallback(action="panel_remnawave").pack(),
)
builder.row(build_admin_back_btn("clusters"))
return builder.as_markup()
def build_tariff_group_selection_kb(cluster_name: str, groups: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(action="apply_tariff_group", data=f"{cluster_name}|{group_id}").pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
def build_select_group_servers_kb(cluster_name: str, cluster_servers: list, selected: set[str]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
names = []
for s in cluster_servers:
if isinstance(s, str):
names.append(s)
elif isinstance(s, dict):
names.append(s.get("server_name") or s.get("name") or str(s))
else:
names.append(getattr(s, "server_name", None) or getattr(s, "name", None) or str(s))
for i, name in enumerate(names):
mark = "" if name in selected else "⬜️"
builder.row(
InlineKeyboardButton(
text=f"{mark} {name}",
callback_data=AdminClusterCallback(action="toggle_server_group", data=f"{cluster_name}|{i}").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📚 Выбрать спецгруппу",
callback_data=AdminClusterCallback(action="choose_group", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="♻️ Сбросить выбор",
callback_data=AdminClusterCallback(action="reset_group_selection", data=cluster_name).pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔙 Назад",
callback_data=AdminClusterCallback(action="manage", data=cluster_name).pack(),
)
)
return builder.as_markup()
def build_tariff_group_selection_for_servers_kb(
cluster_name: str, groups: list[tuple[int, str]]
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for group_id, group_code in groups:
builder.button(
text=group_code,
callback_data=AdminClusterCallback(
action="apply_group_to_servers", data=f"{cluster_name}|{group_id}"
).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminClusterCallback(action="set_group", data=cluster_name).pack(),
)
)
builder.adjust(2, 1)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .coupons_handler import router
+326
View File
@@ -0,0 +1,326 @@
from typing import Any
from aiogram import F, Router
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import (
CallbackQuery,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
Message,
)
from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import INLINE_MODE, USERNAME_BOT
from database import create_coupon, delete_coupon, get_all_coupons
from filters.admin import IsAdminFilter
from handlers.buttons import BACK
from handlers.utils import format_days
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import (
AdminCouponDeleteCallback,
build_coupons_kb,
build_coupons_list_kb,
format_coupons_list,
)
router = Router()
class AdminCouponsState(StatesGroup):
waiting_for_coupon_type = State()
waiting_for_balance_data = State()
waiting_for_days_data = State()
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons"),
IsAdminFilter(),
)
async def handle_coupons(callback_query: CallbackQuery):
await callback_query.message.edit_text(text="🛠 Меню управления купонами:", reply_markup=build_coupons_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_create"),
IsAdminFilter(),
)
async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext):
text = "🎫 <b>Выберите тип купона:</b>"
kb = InlineKeyboardBuilder()
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
kb.button(text="⏳ Время", callback_data="coupon_type_days")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_coupon_type)
@router.callback_query(F.data == "coupon_type_balance")
async def handle_balance_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_balance_data)
@router.callback_query(F.data == "coupon_type_days")
async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
text = (
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
)
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
await state.set_state(AdminCouponsState.waiting_for_days_data)
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
async def handle_balance_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
"Пример: <b>'COUPON1 50 5'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
coupon_amount = int(parts[1])
usage_limit = int(parts[2])
if coupon_amount <= 0:
raise ValueError("Сумма должна быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, coupon_amount, usage_limit, days=None)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.message(AdminCouponsState.waiting_for_days_data, IsAdminFilter())
async def handle_days_coupon_input(message: Message, state: FSMContext, session: Any):
text = message.text.strip()
parts = text.split()
kb = InlineKeyboardBuilder()
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
if len(parts) != 3:
text = (
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
"Пример: <b>'DAYS10 10 50'</b> 👈"
)
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
coupon_code = parts[0]
days = int(parts[1])
usage_limit = int(parts[2])
if days <= 0:
raise ValueError("Количество дней должно быть больше 0")
except ValueError:
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
await message.answer(text=text, reply_markup=kb.as_markup())
return
try:
await create_coupon(session, coupon_code, 0, usage_limit, days=days)
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
text = (
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
f"⏳ <b>{format_days(days)}</b>\n"
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
)
kb = InlineKeyboardBuilder()
if INLINE_MODE:
kb.button(text="📤 Поделиться", switch_inline_query=f"coupon_{coupon_code}")
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
kb.adjust(1)
await message.answer(text=text, reply_markup=kb.as_markup())
await state.clear()
except Exception as e:
logger.error(f"Ошибка при создании купона: {e}")
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
@router.callback_query(
AdminPanelCallback.filter(F.action == "coupons_list"),
IsAdminFilter(),
)
async def handle_coupons_list(callback_query: CallbackQuery, session: Any):
try:
data = AdminPanelCallback.unpack(callback_query.data)
page = data.page if data.page is not None else 1
await update_coupons_list(callback_query.message, session, page)
except Exception as e:
logger.error(f"Ошибка при получении списка купонов: {e}")
await callback_query.message.edit_text("Произошла ошибка при получении списка купонов.")
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_(None)), IsAdminFilter())
async def handle_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
kb = InlineKeyboardBuilder()
kb.button(
text="✅ Да, удалить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=True).pack(),
)
kb.button(
text="❌ Нет, отменить",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code, confirm=False).pack(),
)
kb.adjust(1)
await callback_query.message.edit_text(
f"Вы уверены, что хотите удалить купон <b>{coupon_code}</b>?",
reply_markup=kb.as_markup(),
)
@router.callback_query(AdminCouponDeleteCallback.filter(F.confirm.is_not(None)), IsAdminFilter())
async def confirm_coupon_delete(
callback_query: CallbackQuery,
callback_data: AdminCouponDeleteCallback,
session: Any,
):
coupon_code = callback_data.coupon_code
confirm = callback_data.confirm
if confirm:
try:
result = await delete_coupon(session, coupon_code)
if not result:
await callback_query.message.edit_text(
f"❌ Купон с кодом {coupon_code} не найден.",
reply_markup=build_admin_back_kb("coupons"),
)
return
except Exception as e:
logger.error(f"Ошибка при удалении купона: {e}")
await callback_query.message.edit_text(
"Произошла ошибка при удалении купона.",
reply_markup=build_admin_back_kb("coupons"),
)
return
await update_coupons_list(callback_query.message, session)
async def update_coupons_list(message, session: Any, page: int = 1):
per_page = 10
result = await get_all_coupons(session, page, per_page)
coupons = result["coupons"]
if not coupons:
await message.edit_text(
text="❌ На данный момент нет доступных купонов!",
reply_markup=build_admin_back_kb("coupons"),
)
return
kb = build_coupons_list_kb(coupons, result["current_page"], result["pages"])
text = format_coupons_list(coupons, USERNAME_BOT)
await message.edit_text(text=text, reply_markup=kb)
@router.inline_query(F.query.startswith("coupon_"))
async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
if not INLINE_MODE:
return
coupon_code = inline_query.query.split("coupon_")[1]
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
coupons = await get_all_coupons(session, page=1, per_page=10)
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
if not coupon:
await inline_query.answer(
results=[],
switch_pm_text="Купон не найден",
switch_pm_parameter="coupons",
cache_time=1,
)
return
title = f"Купон {coupon['code']}"
description = (
f"Получи {coupon['amount']} рублей!"
if coupon["amount"] > 0
else f"Продли подписку на {format_days(coupon['days'])}!"
)
message_text = (
f"🎫 <b>Купон:</b> {coupon['code']}\n"
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
f"👇 Нажми, чтобы активировать!"
)
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
result = InlineQueryResultArticle(
id=coupon_code,
title=title,
description=description,
input_message_content=InputTextMessageContent(message_text=message_text, parse_mode=ParseMode.HTML),
reply_markup=builder.as_markup(),
)
await inline_query.answer(results=[result], cache_time=86400, is_personal=True)
+84
View File
@@ -0,0 +1,84 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK
from handlers.utils import format_days
from ..panel.keyboard import AdminPanelCallback, build_admin_back_btn
class AdminCouponDeleteCallback(CallbackData, prefix="admin_coupon_delete"):
coupon_code: str
confirm: bool | None = None
def build_coupons_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=" Создать купон",
callback_data=AdminPanelCallback(action="coupons_create").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="Купоны",
callback_data=AdminPanelCallback(action="coupons_list").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for coupon in coupons:
coupon_code = coupon["code"]
builder.button(
text=f"{coupon_code}",
callback_data=AdminCouponDeleteCallback(coupon_code=coupon_code).pack(),
)
pagination_buttons = []
if current_page > 1:
pagination_buttons.append(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="coupons_list", page=current_page - 1).pack(),
)
)
if current_page < total_pages:
pagination_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="coupons_list", page=current_page + 1).pack(),
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
builder.row(build_admin_back_btn("coupons"))
builder.adjust(2)
return builder.as_markup()
def format_coupons_list(coupons: list, username_bot: str) -> str:
coupon_list = "📜 Список всех купонов:\n\n"
for coupon in coupons:
value_text = (
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
if coupon["amount"] > 0
else f"⏳ <b>{format_days(coupon['days'])}</b>"
)
coupon_list += (
f"🏷️ <b>Код:</b> {coupon['code']}\n"
f"{value_text}\n"
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
)
return coupon_list
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .gifts_handler import router
+273
View File
@@ -0,0 +1,273 @@
from collections import defaultdict
from aiogram import Bot, F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import create_subgroup_hash, find_subgroup_by_hash, get_tariffs
from handlers.utils import edit_or_send_message, format_days, format_months
from logger import logger
from ..panel.keyboard import AdminPanelCallback
from .keyboard import build_admin_gifts_kb, build_gifts_list_kb
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):
tariffs_data = await get_tariffs(session, group_code="gifts", with_subgroup_weights=True)
tariffs = [t for t in tariffs_data["tariffs"] if t.get("is_active")]
subgroup_weights = tariffs_data["subgroup_weights"]
if not tariffs:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
await callback.message.edit_text("❌ Нет активных тарифов в группе 'gifts'.", reply_markup=builder.as_markup())
return
grouped_tariffs = defaultdict(list)
for t in tariffs:
grouped_tariffs[t.get("subgroup_title")].append(t)
builder = InlineKeyboardBuilder()
for t in grouped_tariffs.get(None, []):
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.get("duration_days"))
builder.row(
types.InlineKeyboardButton(
text=f"{t.get('name')} {duration_text}", callback_data=f"admin_gift_select|{t.get('id')}"
)
)
sorted_subgroups = sorted([k for k in grouped_tariffs if k], key=lambda x: (subgroup_weights.get(x, 999999), x))
for subgroup in sorted_subgroups:
subgroup_hash = create_subgroup_hash(subgroup, "gifts")
builder.row(
types.InlineKeyboardButton(
text=subgroup,
callback_data=f"admin_gift_subgroup|{subgroup_hash}",
)
)
builder.row(types.InlineKeyboardButton(text="🔙 Назад", 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
tariffs = await get_tariffs(session, group_code="gifts")
filtered = [t for t in tariffs if t.get("subgroup_title") == subgroup and t.get("is_active")]
if not filtered:
await callback.message.edit_text("❌ В этой подгруппе пока нет тарифов.")
return
builder = InlineKeyboardBuilder()
for t in filtered:
if t.get("duration_days") % 30 == 0:
duration_text = format_months(t.get("duration_days") // 30)
else:
duration_text = format_days(t.get("duration_days"))
builder.row(
types.InlineKeyboardButton(
text=f"{t.get('name')} {duration_text}",
callback_data=f"admin_gift_select|{t.get('id')}",
)
)
builder.row(types.InlineKeyboardButton(text="⬅️ Назад", 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"<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
from ..panel.keyboard import AdminPanelCallback
def build_admin_gifts_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="🎁 Создать подарок", callback_data="admin_gift_create"))
builder.row(
InlineKeyboardButton(text="📦 Все подарки", callback_data="admin_gifts_all"),
)
builder.row(InlineKeyboardButton(text=BACK, callback_data="admin"))
return builder.as_markup()
def build_gift_tariffs_kb(tariffs: list[Tariff]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for tariff in tariffs:
builder.button(
text=f"{tariff.name}{tariff.duration_days // 30} мес.",
callback_data=f"admin_gift_confirm|{tariff.id}",
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack())
builder.adjust(1)
return builder.as_markup()
def build_gifts_list_kb(gifts: list[Gift], page: int, total: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row = []
for i, gift in enumerate(gifts):
if gift.selected_months > 0:
duration_text = format_months(gift.selected_months)
else:
days = (gift.expiry_time.date() - gift.created_at.date()).days
duration_text = format_days(days)
button_text = f"{gift.gift_id[:6]}... — {duration_text}"
row.append(
InlineKeyboardButton(
text=button_text,
callback_data=f"gift_view|{gift.gift_id}",
)
)
if len(row) == 2 or i == len(gifts) - 1:
builder.row(*row)
row = []
nav = []
if page > 1:
nav.append(InlineKeyboardButton(text="⬅️ Назад", callback_data=f"gifts_page|{page - 1}"))
if len(gifts) == 10:
nav.append(InlineKeyboardButton(text="➡️ Далее", callback_data=f"gifts_page|{page + 1}"))
if nav:
builder.row(*nav)
builder.row(InlineKeyboardButton(text="🔙 Назад", callback_data=AdminPanelCallback(action="gifts").pack()))
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .management_handler import router
+161
View File
@@ -0,0 +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(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
if admin_role == "superadmin":
builder.button(
text="👑 Управление админами",
callback_data=AdminPanelCallback(action="admins").pack(),
)
builder.button(
text="🗄 Управление БД",
callback_data=AdminPanelCallback(action="database").pack(),
)
builder.button(
text="📛 Управление банами",
callback_data=AdminPanelCallback(action="bans").pack(),
)
builder.button(
text="🔄 Перезагрузить бота",
callback_data=AdminPanelCallback(action="restart").pack(),
)
builder.button(
text="🌐 Сменить домен подписок",
callback_data=AdminPanelCallback(action="change_domain").pack(),
)
builder.button(
text="🔑 Восстановить пробники",
callback_data=AdminPanelCallback(action="restore_trials").pack(),
)
builder.button(
text="📤 Загрузить файл",
callback_data=AdminPanelCallback(action="upload_file").pack(),
)
maintenance_text = "🛠️ Выключить тех. работы" if maintenance.maintenance_mode else "🛠️ Включить тех. работы"
builder.button(
text=maintenance_text,
callback_data=AdminPanelCallback(action="toggle_maintenance").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_database_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="💾 Создать резервную копию",
callback_data=AdminPanelCallback(action="backups").pack(),
)
builder.button(
text="♻️ Восстановить БД из бэкапа",
callback_data=AdminPanelCallback(action="restore_db").pack(),
)
builder.button(
text="📤 Получить данные БД из панели",
callback_data=AdminPanelCallback(action="export_db").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="⬅️ Назад", callback_data=AdminPanelCallback(action="database").pack())
return builder.as_markup()
def build_export_db_sources_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🌀 Remnawave", callback_data=AdminPanelCallback(action="export_remnawave").pack())
builder.button(text="🧩 3x-ui", callback_data=AdminPanelCallback(action="request_3xui_file").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
def build_admins_kb(admins: list[tuple[int, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for tg_id, role in admins:
builder.button(
text=f"🧑 {tg_id} ({role})", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack()
)
builder.button(text=" Добавить админа", callback_data=AdminPanelCallback(action="add_admin").pack())
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_single_admin_menu(tg_id: int, role: str = "moderator") -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="✏ Изменить роль", callback_data=AdminPanelCallback(action=f"edit_role|{tg_id}").pack())
builder.button(text="🗑 Удалить админа", callback_data=AdminPanelCallback(action=f"delete_admin|{tg_id}").pack())
if role == "superadmin":
builder.button(
text="🎟 Выпустить токен", callback_data=AdminPanelCallback(action=f"generate_token|{tg_id}").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_role_selection_kb(tg_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="👑 superadmin", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|superadmin").pack())
builder.button(text="🛡 moderator", callback_data=AdminPanelCallback(action=f"set_role|{tg_id}|moderator").pack())
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action=f"admin_menu|{tg_id}").pack())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb_to_admins() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_token_result_kb(token: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="📋 Скопировать токен", switch_inline_query_current_chat=token)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="admins").pack())
builder.adjust(1)
return builder.as_markup()
def build_back_to_db_menu() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
return builder.as_markup()
def build_post_import_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="🔁 Перевыпустить подписки", callback_data=AdminPanelCallback(action="resync_after_import").pack()
)
builder.button(text="🔙 Назад", callback_data=AdminPanelCallback(action="back_to_db_menu").pack())
builder.adjust(1)
return builder.as_markup()
@@ -0,0 +1,687 @@
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import traceback
from asyncio import sleep
from datetime import datetime
from tempfile import NamedTemporaryFile
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from dateutil import parser
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from config import DB_NAME, DB_PASSWORD, DB_USER, PG_HOST, PG_PORT, REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD
from database.models import Admin, Key, Server, User
from filters.admin import IsAdminFilter
from handlers.keys.operations import update_subscription
from logger import logger
from middlewares import maintenance
from panels.remnawave import RemnawaveAPI
from ..panel.keyboard import build_admin_back_kb
from .keyboard import (
AdminPanelCallback,
build_admin_back_kb_to_admins,
build_admins_kb,
build_back_to_db_menu,
build_database_kb,
build_export_db_sources_kb,
build_management_kb,
build_post_import_kb,
build_role_selection_kb,
build_single_admin_menu,
build_token_result_kb,
)
router = Router()
class AdminManagementStates(StatesGroup):
waiting_for_new_domain = State()
class Import3xuiStates(StatesGroup):
waiting_for_file = State()
class FileUploadState(StatesGroup):
waiting_for_file = State()
class DatabaseState(StatesGroup):
waiting_for_backup_file = State()
class AdminState(StatesGroup):
waiting_for_tg_id = State()
@router.callback_query(AdminPanelCallback.filter(F.action == "management"), IsAdminFilter())
async def handle_management(callback_query: CallbackQuery, session: AsyncSession):
tg_id = callback_query.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback_query.message.edit_text("❌ Вы не зарегистрированы как администратор.")
return
await callback_query.message.edit_text(
text="🤖 Управление ботом",
reply_markup=build_management_kb(admin.role),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "change_domain"), IsAdminFilter())
async def request_new_domain(callback_query: CallbackQuery, state: FSMContext):
"""Запрашивает у администратора новый домен."""
await state.set_state(AdminManagementStates.waiting_for_new_domain)
await callback_query.message.edit_text(
text="🌐 Введите новый домен (без https://):\nПример: solobotdomen.ru",
)
@router.message(AdminManagementStates.waiting_for_new_domain)
async def process_new_domain(message: Message, state: FSMContext, session: AsyncSession):
"""Обновляет домен в таблице keys."""
new_domain = message.text.strip()
if not re.fullmatch(r"[a-zA-Z0-9.-]+", new_domain) or " " in new_domain:
logger.warning("[DomainChange] Некорректный домен")
await message.answer(
"🚫 Некорректный домен! Введите домен без http:// и без пробелов.",
reply_markup=build_admin_back_kb("admin"),
)
return
new_domain_url = f"https://{new_domain}"
try:
stmt = (
update(Key)
.values(
key=func.regexp_replace(Key.key, r"^https://[^/]+", new_domain_url),
remnawave_link=func.regexp_replace(Key.remnawave_link, r"^https://[^/]+", new_domain_url),
)
.where(
(Key.key.startswith("https://") & ~Key.key.startswith(new_domain_url))
| (Key.remnawave_link.startswith("https://") & ~Key.remnawave_link.startswith(new_domain_url))
)
)
await session.execute(stmt)
await session.commit()
logger.info("[DomainChange] Запрос на обновление домена выполнен успешно.")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выполнении запроса: {e}")
await message.answer(
f"❌ Ошибка при обновлении домена: {e}",
reply_markup=build_admin_back_kb("admin"),
)
return
try:
sample = await session.execute(select(Key.key, Key.remnawave_link).limit(1))
example = sample.fetchone()
logger.info(f"[DomainChange] Пример обновленной записи: {example}")
except Exception as e:
logger.error(f"[DomainChange] Ошибка при выборке обновленной записи: {e}")
await message.answer(
f"✅ Домен успешно изменен на {new_domain}!",
reply_markup=build_admin_back_kb("admin"),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery, session: AsyncSession):
tg_id = callback.from_user.id
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.answer("❌ Админ не найден.", show_alert=True)
return
maintenance.maintenance_mode = not maintenance.maintenance_mode
new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb(admin.role))
@router.callback_query(AdminPanelCallback.filter(F.action == "admins"))
async def show_admins(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Admin.tg_id, Admin.role))
admins = result.all()
await callback.message.edit_text("👑 <b>Список админов</b>", reply_markup=build_admins_kb(admins))
@router.callback_query(AdminPanelCallback.filter(F.action == "add_admin"))
async def prompt_new_admin(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"Введите <code>tg_id</code> нового админа:", reply_markup=build_admin_back_kb_to_admins()
)
await state.set_state(AdminState.waiting_for_tg_id)
@router.message(AdminState.waiting_for_tg_id)
async def save_new_admin(message: Message, session: AsyncSession, state: FSMContext):
try:
tg_id = int(message.text.strip())
except ValueError:
await message.answer("❌ Неверный формат. Введите числовой <code>tg_id</code>.")
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
if result.scalar_one_or_none():
await message.answer("⚠️ Такой админ уже существует.")
else:
session.add(Admin(tg_id=tg_id, role="moderator", description="Добавлен вручную"))
await session.commit()
await message.answer(f"✅ Админ <code>{tg_id}</code> добавлен.", reply_markup=build_admin_back_kb_to_admins())
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("admin_menu|")))
async def open_admin_menu(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin.role).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
role = admin or "moderator"
await callback.message.edit_text(
f"👤 <b>Управление админом</b> <code>{tg_id}</code>", reply_markup=build_single_admin_menu(tg_id, role)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("generate_token|")))
async def generate_token(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
token = Admin.generate_token()
token_hash = hashlib.sha256(token.encode()).hexdigest()
admin.token = token_hash
await session.commit()
msg = await callback.message.edit_text(
f"🎟 <b>Новый токен для</b> <code>{tg_id}</code>:\n\n"
f"<code>{token}</code>\n\n"
f"⚠️ Это сообщение исчезнет через 5 минут.",
reply_markup=build_token_result_kb(token),
)
await sleep(300)
try:
await msg.delete()
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("edit_role|")))
async def edit_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback):
tg_id = int(callback_data.action.split("|")[1])
await callback.message.edit_text(
f"✏ <b>Выберите новую роль для</b> <code>{tg_id}</code>:", reply_markup=build_role_selection_kb(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("set_role|")))
async def set_admin_role(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
try:
_, tg_id_str, role = callback_data.action.split("|")
tg_id = int(tg_id_str)
if role not in ("superadmin", "moderator"):
raise ValueError
except Exception:
await callback.message.edit_text("❌ Неверный формат.")
return
if tg_id == callback.from_user.id:
await callback.message.edit_text(
"🚫 <b>Нельзя изменить свою собственную роль!</b>", reply_markup=build_single_admin_menu(tg_id)
)
return
result = await session.execute(select(Admin).where(Admin.tg_id == tg_id))
admin = result.scalar_one_or_none()
if not admin:
await callback.message.edit_text("❌ Админ не найден.")
return
admin.role = role
await session.commit()
await callback.message.edit_text(
f"✅ Роль админа <code>{tg_id}</code> изменена на <b>{role}</b>.", reply_markup=build_single_admin_menu(tg_id)
)
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("delete_admin|")))
async def delete_admin(callback: CallbackQuery, callback_data: AdminPanelCallback, session: AsyncSession):
tg_id = int(callback_data.action.split("|")[1])
await session.execute(delete(Admin).where(Admin.tg_id == tg_id))
await session.commit()
await callback.message.edit_text(
f"🗑 Админ <code>{tg_id}</code> удалён.", reply_markup=build_admin_back_kb_to_admins()
)
@router.callback_query(AdminPanelCallback.filter(F.action == "database"))
async def handle_database_menu(callback: CallbackQuery):
await callback.message.edit_text(
text="🗄 <b>Управление базой данных</b>",
reply_markup=build_database_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "restore_db"))
async def prompt_restore_db(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Отправьте файл резервной копии (.sql), чтобы восстановить базу данных.\n"
"⚠️ Все текущие данные будут перезаписаны.",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(DatabaseState.waiting_for_backup_file)
@router.message(DatabaseState.waiting_for_backup_file)
async def restore_database(message: Message, state: FSMContext, bot: Bot):
document = message.document
if not document or not document.file_name.endswith(".sql"):
await message.answer("❌ Пожалуйста, отправьте файл с расширением .sql.")
return
try:
with NamedTemporaryFile(delete=False, suffix=".sql") as tmp_file:
tmp_path = tmp_file.name
await bot.download(document, destination=tmp_path)
logger.info(f"[Restore] Файл получен и сохранён: {tmp_path}")
is_custom_dump = False
with open(tmp_path, "rb") as f:
signature = f.read(5)
if signature == b"PGDMP":
is_custom_dump = True
subprocess.run(
[
"sudo",
"-u",
"postgres",
"psql",
"-d",
"postgres",
"-c",
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{DB_NAME}' AND pid <> pg_backend_pid();",
],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"DROP DATABASE IF EXISTS {DB_NAME};"],
check=True,
)
subprocess.run(
["sudo", "-u", "postgres", "psql", "-d", "postgres", "-c", f"CREATE DATABASE {DB_NAME} OWNER {DB_USER};"],
check=True,
)
logger.info("[Restore] База данных пересоздана")
os.environ["PGPASSWORD"] = DB_PASSWORD
if is_custom_dump:
result = subprocess.run(
[
"pg_restore",
f"--dbname={DB_NAME}",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"--no-owner",
"--exit-on-error",
tmp_path,
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[
"psql",
"-U",
DB_USER,
"-h",
PG_HOST,
"-p",
PG_PORT,
"-d",
DB_NAME,
"-f",
tmp_path,
],
capture_output=True,
text=True,
)
del os.environ["PGPASSWORD"]
if result.returncode != 0:
logger.error(f"[Restore] Ошибка восстановления: {result.stderr}")
await message.answer(
f"❌ Ошибка при восстановлении базы данных:\n<pre>{result.stderr}</pre>",
)
return
await message.answer(
"✅ База данных восстановлена.",
reply_markup=build_back_to_db_menu(),
)
logger.info("[Restore] Успешно восстановлено. Завершаем процесс для перезапуска.")
await state.clear()
sys.exit(0)
except Exception as e:
logger.exception(f"[Restore] Непредвиденная ошибка: {e}")
await message.answer(
f"❌ Произошла ошибка:\n<pre>{traceback.format_exc()}</pre>",
)
finally:
try:
os.remove(tmp_path)
except Exception:
pass
@router.callback_query(AdminPanelCallback.filter(F.action == "export_db"))
async def handle_export_db(callback: CallbackQuery):
await callback.message.edit_text(
"📤 Выберите панель, с которой требуется получить данные:\n\n"
"<i>Подтянутся подписки с панели и будут сохранены в базу данных бота.</i>",
reply_markup=build_export_db_sources_kb(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "back_to_db_menu"))
async def back_to_database_menu(callback: CallbackQuery):
await callback.message.edit_text("📦 Управление базой данных:", reply_markup=build_database_kb())
@router.callback_query(AdminPanelCallback.filter(F.action == "export_remnawave"))
async def show_remnawave_clients(callback: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server).where(Server.panel_type == "remnawave", Server.enabled.is_(True)))
servers = result.scalars().all()
if not servers:
await callback.message.edit_text(
"❌ Нет доступных Remnawave-серверов.",
reply_markup=build_back_to_db_menu(),
)
return
server = servers[0]
api = RemnawaveAPI(base_url=server.api_url)
users = await api.get_all_users_time(
username=REMNAWAVE_LOGIN,
password=REMNAWAVE_PASSWORD,
)
if not users:
await callback.message.edit_text(
"📭 На панели нет клиентов.",
reply_markup=build_back_to_db_menu(),
)
return
logger.warning(f"[Remnawave Export] Пример ответа:\n{json.dumps(users[:3], indent=2, ensure_ascii=False)}")
added_users = await import_remnawave_users(session, users)
server_id = server.cluster_name or server.server_name
added_keys = await import_remnawave_keys(session, users, server_id=server_id)
preview = ""
for i, user in enumerate(users[:3], 1):
email = user.get("email") or user.get("username") or "-"
expire = user.get("expireAt", "")[:10]
preview += f"{i}. {email} — до {expire}\n"
await callback.message.edit_text(
f"📄 Найдено клиентов: <b>{len(users)}</b>\n"
f"👤 Импортировано пользователей: <b>{added_users}</b>\n"
f"🔐 Импортировано ключей: <b>{added_keys}</b>\n\n"
f"<b>Первые 3:</b>\n{preview}",
reply_markup=build_back_to_db_menu(),
)
async def import_remnawave_users(session: AsyncSession, users: list[dict]) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
if not tg_id:
continue
exists = await session.execute(select(User).where(User.tg_id == tg_id))
if exists.scalar():
continue
try:
new_user = User(
tg_id=tg_id,
username=None,
first_name=None,
last_name=None,
language_code=None,
is_bot=False,
balance=0.0,
trial=1,
source_code=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
session.add(new_user)
added += 1
except SQLAlchemyError as e:
logger.error(f"[Remnawave Import] Ошибка при добавлении пользователя {tg_id}: {e}")
continue
await session.commit()
return added
async def import_remnawave_keys(session: AsyncSession, users: list[dict], server_id: str) -> int:
added = 0
for user in users:
tg_id = user.get("telegramId")
client_id = user.get("uuid")
email = user.get("email") or user.get("username")
remnawave_link = user.get("subscriptionUrl")
expire_at = user.get("expireAt")
created_at = user.get("createdAt")
if not tg_id or not client_id:
logger.warning(f"[SKIP] Пропущен клиент: tg_id={tg_id}, client_id={client_id}")
continue
exists_stmt = await session.execute(select(Key).where(Key.client_id == client_id))
if exists_stmt.scalar():
logger.info(f"[SKIP] Ключ уже существует: {client_id}")
continue
try:
created_ts = int(parser.isoparse(created_at).timestamp() * 1000) if created_at else int(time.time() * 1000)
expire_ts = int(parser.isoparse(expire_at).timestamp() * 1000) if expire_at else int(time.time() * 1000)
new_key = Key(
tg_id=tg_id,
client_id=client_id,
email=email,
created_at=created_ts,
expiry_time=expire_ts,
key="",
server_id=server_id,
remnawave_link=remnawave_link,
tariff_id=None,
is_frozen=False,
alias=None,
notified=False,
notified_24h=False,
)
session.add(new_key)
added += 1
logger.info(f"[ADD] Ключ добавлен: {client_id}, до {expire_at}, email={email}, server_id={server_id}")
except Exception as e:
logger.error(f"[ERROR] Ошибка при добавлении ключа {client_id}: {e}")
await session.commit()
logger.info(f"[IMPORT] Всего добавлено ключей: {added}")
return added
@router.callback_query(AdminPanelCallback.filter(F.action == "request_3xui_file"))
async def prompt_for_3xui_file(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📂 Пришлите файл базы данных <code>x-ui.db</code> для восстановления подписок и клиентов.\n\n"
"Формат: SQLite-файл с таблицей <code>inbounds</code>.\n\n"
"<b>⚠️ Важно!</b> Убедитесь, что у всех подписок в панели прописан <code>telegram_id</code>.\n"
"После восстановления обязательно выполните <b>синхронизацию</b> с текущими серверами!",
reply_markup=build_back_to_db_menu(),
)
await state.set_state(Import3xuiStates.waiting_for_file)
@router.message(Import3xuiStates.waiting_for_file, F.document)
async def handle_3xui_db_upload(message: Message, state: FSMContext, session: AsyncSession):
file = message.document
if not file.file_name.endswith(".db"):
await message.reply("❌ Пожалуйста, пришли файл с расширением .db")
return
file_path = f"/tmp/{file.file_name}"
await message.bot.download(file, destination=file_path)
processing_message = await message.reply("📥 Файл получен. Начинаю восстановление...")
try:
from database.importer import import_keys_from_3xui_db
imported, skipped = await import_keys_from_3xui_db(file_path, session)
await processing_message.edit_text(
f"✅ Восстановление завершено:\n"
f"🔐 Импортировано подписок: <b>{imported}</b>\n"
f"⏭ Пропущено (уже есть): <b>{skipped}</b>",
reply_markup=build_post_import_kb(),
)
except Exception as e:
logger.error(f"[Import 3x-ui] Ошибка: {e}")
await processing_message.edit_text(
"❌ Произошла ошибка при импорте. Убедись, что это валидный файл <code>x-ui.db</code>",
reply_markup=build_back_to_db_menu(),
)
await state.clear()
@router.callback_query(AdminPanelCallback.filter(F.action == "resync_after_import"))
async def handle_resync_after_import(callback: CallbackQuery, session: AsyncSession):
await callback.answer("🔁 Начинаю перевыпуск подписок...")
result = await session.execute(select(Key.tg_id, Key.email))
keys = result.all()
success = 0
failed = 0
for tg_id, email in keys:
try:
await update_subscription(tg_id=tg_id, email=email, session=session)
success += 1
except Exception as e:
logger.error(f"[Resync] Ошибка при перевыпуске {email}: {e}")
failed += 1
await callback.message.edit_text(
f"🔁 Перевыпуск завершён:\n✅ Успешно: <b>{success}</b>\n❌ Ошибки: <b>{failed}</b>",
reply_markup=build_back_to_db_menu(),
)
@router.callback_query(AdminPanelCallback.filter(F.action == "upload_file"))
async def prompt_for_file_upload(callback: CallbackQuery, state: FSMContext):
await callback.message.edit_text(
"📤 <b>Загрузка файла</b>\n\n"
"Вы можете заменить файл в корневой директории бота.\n\n"
"📁 <b>Отправьте файл с таким же именем и расширением</b>, "
"как у уже существующего файла. Он будет автоматически заменён.",
reply_markup=build_admin_back_kb("management"),
)
await state.set_state(FileUploadState.waiting_for_file)
@router.message(FileUploadState.waiting_for_file, F.document)
async def handle_admin_file_upload(message: Message, state: FSMContext):
document = message.document
file_name = document.file_name
if not file_name or "." not in file_name:
await message.answer("❌ У файла должно быть имя с расширением.")
return
dest_path = os.path.abspath(f"./{file_name}")
try:
await message.bot.download(document, destination=dest_path)
await message.answer(
f"✅ Файл <code>{file_name}</code> успешно загружен и заменён.\n\n"
"🔄 <b>Перезагрузите бота, чтобы изменения вступили в силу.</b>",
reply_markup=build_admin_back_kb("management"),
)
except Exception as e:
logger.error(f"[Upload File] Ошибка при загрузке файла {file_name}: {e}")
await message.answer(
f"❌ Не удалось сохранить файл: {e}",
reply_markup=build_admin_back_kb("management"),
)
await state.clear()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .module_handler import router
+93
View File
@@ -0,0 +1,93 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.admin.panel.keyboard import AdminPanelCallback
from handlers.buttons import BACK
from utils.modules_manager import manager
def build_modules_kb(page: int, total_pages: int, items: list[tuple[str, str | None]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
row_buf = []
for name, _ in items:
label = name if manager.is_enabled(name) else f"{name} (off)"
row_buf.append(
InlineKeyboardButton(
text=label,
callback_data=AdminPanelCallback(action=f"module__{name}", page=page).pack(),
)
)
if len(row_buf) == 2:
builder.row(*row_buf)
row_buf = []
if row_buf:
builder.row(*row_buf)
if total_pages > 1:
nav = []
if page > 1:
nav.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=AdminPanelCallback(action="modules", page=page - 1).pack(),
)
)
nav.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
if page < total_pages:
nav.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=AdminPanelCallback(action="modules", page=page + 1).pack(),
)
)
builder.row(*nav)
builder.row(
InlineKeyboardButton(
text=BACK,
callback_data=AdminPanelCallback(action="admin", page=1).pack(),
)
)
return builder.as_markup()
def build_module_menu_kb(name: str, page: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
enabled = manager.is_enabled(name)
if enabled:
builder.button(
text="🔁 Перезапустить",
callback_data=AdminPanelCallback(action=f"module_restart__{name}", page=page).pack(),
)
builder.button(
text="🛑 Остановить",
callback_data=AdminPanelCallback(action=f"module_stop__{name}", page=page).pack(),
)
else:
builder.button(
text="▶️ Запустить",
callback_data=AdminPanelCallback(action=f"module_start__{name}", page=page).pack(),
)
builder.button(
text="🔄 Обновить",
callback_data=AdminPanelCallback(action=f"module_update__{name}", page=page).pack(),
)
builder.row(
InlineKeyboardButton(
text="⬅️ К списку",
callback_data=AdminPanelCallback(action="modules", page=page).pack(),
)
)
builder.adjust(1)
return builder.as_markup()
+171
View File
@@ -0,0 +1,171 @@
import os
from math import ceil
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from filters.admin import IsAdminFilter
from handlers.admin.panel.keyboard import AdminPanelCallback
from utils.modules_manager import manager
from .keyboard import build_module_menu_kb, build_modules_kb
router = Router()
def list_installed_modules() -> list[tuple[str, str | None]]:
base = "modules"
if not os.path.isdir(base):
return []
items: list[tuple[str, str | None]] = []
for name in sorted(os.listdir(base)):
path = os.path.join(base, name)
if os.path.isdir(path) and not name.startswith("."):
ver = None
vp = os.path.join(path, "VERSION")
if os.path.isfile(vp):
try:
with open(vp, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
ver = line
break
except Exception:
ver = None
items.append((name, ver))
return items
@router.callback_query(AdminPanelCallback.filter(F.action == "modules"), IsAdminFilter())
async def handle_modules(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
page = max(1, packed.page or 1)
all_items = list_installed_modules()
items = [(n, v) for n, v in all_items if n != "web_admin_panel"]
per_page = 12
total_pages = max(1, (len(items) + per_page - 1) // per_page)
page = min(page, total_pages)
start = (page - 1) * per_page
chunk = items[start : start + per_page]
if chunk:
def fmt(n, v):
return f"{n} v{v}" if v else n
lines = "\n".join(f"{fmt(n, v)}" for n, v in chunk)
text = f"🧩 Мои модули\n\nНайдено: {len(items)}\n<blockquote>{lines}</blockquote>"
else:
text = "🧩 Мои модули\n\nМодулей не найдено."
markup = build_modules_kb(page, total_pages, chunk)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
try:
await callback_query.message.edit_reply_markup(reply_markup=None)
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest:
pass
else:
raise
finally:
await callback_query.answer()
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_restart__")), IsAdminFilter())
async def handle_module_restart(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_restart__", 1)[-1]
try:
await manager.restart(name)
result = "✅ Модуль перезапущен."
except Exception as e:
result = f"❌ Ошибка перезапуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_stop__")), IsAdminFilter())
async def handle_module_stop(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_stop__", 1)[-1]
try:
await manager.stop(name)
result = "🛑 Модуль остановлен."
except Exception as e:
result = f"❌ Ошибка остановки: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
@router.callback_query(AdminPanelCallback.filter(F.action.startswith("module_start__")), IsAdminFilter())
async def handle_module_start(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await state.clear()
packed = AdminPanelCallback.unpack(callback_query.data)
action = packed.action
page = packed.page or 1
name = action.split("module_start__", 1)[-1]
try:
await manager.start(name)
result = "▶️ Модуль запущен."
except Exception as e:
result = f"❌ Ошибка запуска: {e}"
items = dict(list_installed_modules())
ver = items.get(name)
title = f"{name} v{ver}" if ver else name
text = f"🧩 Модуль: <b>{title}</b>\n\n{result}"
markup = build_module_menu_kb(name, page)
try:
await callback_query.message.edit_text(text=text, reply_markup=markup, disable_web_page_preview=True)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return
raise
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .panel_handler import router
+108
View File
@@ -0,0 +1,108 @@
from typing import Any
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from handlers.buttons import BACK, MAIN_MENU
from hooks.hook_buttons import insert_hook_buttons
from hooks.hooks import run_hooks
class AdminPanelCallback(CallbackData, prefix="admin_panel"):
action: str
page: int
def __init__(self, /, **data: Any) -> None:
if "page" not in data or data["page"] is None:
data["page"] = 1
super().__init__(**data)
async def build_panel_kb(admin_role: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="👤 Поиск пользователя", callback_data=AdminPanelCallback(action="search_user").pack()
),
InlineKeyboardButton(text="🔑 Поиск подписок", callback_data=AdminPanelCallback(action="search_key").pack()),
)
if admin_role == "superadmin":
builder.button(
text="🖥️ Управление серверами",
callback_data=AdminPanelCallback(action="clusters").pack(),
)
builder.button(
text="💸Управление тарифами",
callback_data=AdminPanelCallback(action="tariffs").pack(),
)
builder.button(
text="🤖 Управление ботом",
callback_data=AdminPanelCallback(action="management").pack(),
)
builder.row(
InlineKeyboardButton(text="📢 Рассылка", callback_data=AdminPanelCallback(action="sender").pack()),
InlineKeyboardButton(text="🎟️ Купоны", callback_data=AdminPanelCallback(action="coupons").pack()),
)
if admin_role == "superadmin":
builder.row(
InlineKeyboardButton(text="🎁 Подарки", callback_data=AdminPanelCallback(action="gifts").pack()),
InlineKeyboardButton(text="🧩 Мои модули", callback_data=AdminPanelCallback(action="modules").pack()),
)
builder.row(
InlineKeyboardButton(text="📊 Статистика", callback_data=AdminPanelCallback(action="stats").pack()),
InlineKeyboardButton(text="📈 Аналитика", callback_data=AdminPanelCallback(action="ads").pack()),
)
else:
builder.button(
text="🎁 Подарки",
callback_data=AdminPanelCallback(action="gifts").pack(),
)
module_buttons = await run_hooks("admin_panel", admin_role=admin_role)
builder = insert_hook_buttons(builder, module_buttons)
builder.button(
text=MAIN_MENU,
callback_data="profile",
)
if admin_role == "superadmin":
builder.adjust(2, 1, 1, 1, 2, 2, 2, 1)
else:
builder.adjust(2, 2, 1, 1)
return builder.as_markup()
def build_restart_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Да, перезагрузить",
callback_data=AdminPanelCallback(action="restart_confirm").pack(),
)
builder.row(build_admin_back_btn())
builder.adjust(1)
return builder.as_markup()
def build_admin_back_kb(action: str = "admin") -> InlineKeyboardMarkup:
return build_admin_singleton_kb(BACK, action)
def build_admin_singleton_kb(text: str, action: str) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(build_admin_btn(text, action))
return builder.as_markup()
def build_admin_back_btn(action: str = "admin") -> InlineKeyboardButton:
return build_admin_btn(BACK, action)
def build_admin_btn(text: str, action: str) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=AdminPanelCallback(action=action).pack())
+74
View File
@@ -0,0 +1,74 @@
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.models import Admin
from filters.admin import IsAdminFilter
from logger import logger
from utils.versioning import get_version
from .keyboard import AdminPanelCallback, build_panel_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "admin"), IsAdminFilter())
async def handle_admin_callback_query(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
result = await session.execute(select(Admin.role).where(Admin.tg_id == callback_query.from_user.id))
role = result.scalar_one_or_none() or "admin"
markup = await build_panel_kb(admin_role=role)
if callback_query.message.text:
try:
await callback_query.message.edit_text(
text=text,
reply_markup=markup,
disable_web_page_preview=True,
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.warning("🔄 Попытка редактировать сообщение без изменений — пропущено.")
else:
raise
else:
try:
await callback_query.message.delete()
except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}")
await callback_query.message.answer(
text=text,
reply_markup=markup,
disable_web_page_preview=True,
)
@router.callback_query(F.data == "admin", IsAdminFilter())
async def handle_admin_callback_query_simple(callback_query: CallbackQuery, state: FSMContext, session: AsyncSession):
await handle_admin_callback_query(callback_query, state, session)
@router.message(Command("admin"), IsAdminFilter())
async def handle_admin_message(message: Message, state: FSMContext, session: AsyncSession):
text = f"🤖 Панель администратора\n\nВерсия бота:\n<blockquote>{get_version()}</blockquote>"
await state.clear()
result = await session.execute(select(Admin.role).where(Admin.tg_id == message.from_user.id))
role = result.scalar_one_or_none() or "admin"
await message.answer(
text=text,
reply_markup=await build_panel_kb(admin_role=role),
disable_web_page_preview=True,
)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .restart_handler import router
+46
View File
@@ -0,0 +1,46 @@
import asyncio
import os
import subprocess
import sys
import psutil
from aiogram import F, Router
from aiogram.types import CallbackQuery
from filters.admin import IsAdminFilter
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
router = Router()
@router.callback_query(AdminPanelCallback.filter(F.action == "restart"), IsAdminFilter())
async def handle_restart_confirm(callback_query: CallbackQuery, callback_data: AdminPanelCallback):
kb = build_admin_back_kb()
await callback_query.message.edit_text("🔄 Перезапускаем бота...", reply_markup=kb)
asyncio.create_task(restart_bot())
async def restart_bot():
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
subprocess.run(
["sudo", "systemctl", "restart", "bot.service"],
check=True,
)
else:
python_exe = sys.executable
script_path = os.path.abspath(sys.argv[0])
os.execv(python_exe, [python_exe, script_path] + sys.argv[1:])
except Exception as e:
print(f"[Restart] Ошибка при перезапуске: {e}")
os._exit(1)
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .sender_handler import router
+74
View File
@@ -0,0 +1,74 @@
from aiogram.filters.callback_data import CallbackData
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from ..panel.keyboard import build_admin_back_btn
class AdminSenderCallback(CallbackData, prefix="admin_sender"):
type: str
data: str | None = None
def build_sender_kb() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text="👥 Все пользователи",
callback_data=AdminSenderCallback(type="all").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="С подпиской",
callback_data=AdminSenderCallback(type="subscribed").pack(),
),
InlineKeyboardButton(
text="❌ Без подписки",
callback_data=AdminSenderCallback(type="unsubscribed").pack(),
),
)
builder.row(
InlineKeyboardButton(
text="📍 Не использовавшие триал",
callback_data=AdminSenderCallback(type="untrial").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🧪 Триал",
callback_data=AdminSenderCallback(type="trial").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="🔥 Горячие лиды",
callback_data=AdminSenderCallback(type="hotleads").pack(),
)
)
builder.row(
InlineKeyboardButton(
text="📢 Кластер",
callback_data=AdminSenderCallback(type="cluster-select").pack(),
)
)
builder.row(build_admin_back_btn())
return builder.as_markup()
def build_clusters_kb(clusters: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for cluster in clusters:
name = cluster["cluster_name"]
builder.button(
text=f"🌐 {name}",
callback_data=AdminSenderCallback(type="cluster", data=name).pack(),
)
builder.adjust(2)
builder.row(build_admin_back_btn())
return builder.as_markup()
+377
View File
@@ -0,0 +1,377 @@
import asyncio
import json
import re
from datetime import datetime
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy import distinct, exists, func, not_, select
from sqlalchemy.ext.asyncio import AsyncSession
from database import create_blocked_user
from database.models import BlockedUser, Key, ManualBan, Payment, Server, Tariff, User
from filters.admin import IsAdminFilter
from logger import logger
from ..panel.keyboard import AdminPanelCallback, build_admin_back_kb
from .keyboard import AdminSenderCallback, build_clusters_kb, build_sender_kb
router = Router()
async def try_add_blocked_user(tg_id: int, session: AsyncSession):
if session:
try:
await create_blocked_user(session, tg_id)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception as e:
logger.warning(f"Не удалось добавить {tg_id} в blocked_users: {e}")
async def send_broadcast_batch(bot, messages, batch_size=15, session=None):
results = []
min_interval = 1.0 / 15
for msg in messages:
tg_id = msg["tg_id"]
text = msg["text"]
photo = msg.get("photo")
keyboard = msg.get("keyboard")
try:
if photo:
await bot.send_photo(chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
except TelegramRetryAfter as e:
retry_in = int(e.retry_after) + 1
logger.warning(f"⚠️ Flood control: повтор через {retry_in} сек. для пользователя {tg_id}")
await asyncio.sleep(e.retry_after)
try:
if photo:
await bot.send_photo(
chat_id=tg_id, photo=photo, caption=text, parse_mode="HTML", reply_markup=keyboard
)
else:
await bot.send_message(chat_id=tg_id, text=text, parse_mode="HTML", reply_markup=keyboard)
results.append(True)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
error_msg = str(bad_request).lower()
if "chat not found" in error_msg:
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session)
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
results.append(False)
except Exception as retry_error:
logger.error(f"❌ Ошибка повторной отправки пользователю {tg_id}: {retry_error}")
results.append(False)
except TelegramForbiddenError:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
await try_add_blocked_user(tg_id, session)
results.append(False)
except TelegramBadRequest as bad_request:
error_msg = str(bad_request).lower()
if "chat not found" in error_msg:
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
await try_add_blocked_user(tg_id, session)
else:
logger.warning(f"📩 Не удалось отправить сообщение пользователю {tg_id}: {bad_request}")
results.append(False)
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения пользователю {tg_id}: {e}")
results.append(False)
await asyncio.sleep(min_interval)
return results
class AdminSender(StatesGroup):
waiting_for_message = State()
preview = State()
async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str = None) -> tuple[list[int], int]:
now_ms = int(datetime.utcnow().timestamp() * 1000)
banned_tg_ids = select(BlockedUser.tg_id).union_all(
select(ManualBan.tg_id).where((ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()))
)
query = None
if send_to == "subscribed":
query = (
select(distinct(User.tg_id)).join(Key).where(Key.expiry_time > now_ms).where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "unsubscribed":
subquery = (
select(User.tg_id)
.outerjoin(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.count(Key.tg_id) == 0)
.union_all(
select(User.tg_id)
.join(Key, User.tg_id == Key.tg_id)
.group_by(User.tg_id)
.having(func.max(Key.expiry_time) <= now_ms)
)
)
query = select(distinct(subquery.c.tg_id)).where(~subquery.c.tg_id.in_(banned_tg_ids))
elif send_to == "untrial":
subquery = select(Key.tg_id)
query = (
select(distinct(User.tg_id))
.where(~User.tg_id.in_(subquery) & User.trial.in_([0, -1]))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "cluster":
query = (
select(distinct(User.tg_id))
.join(Key, User.tg_id == Key.tg_id)
.join(Server, Key.server_id == Server.cluster_name)
.where(Server.cluster_name == cluster_name)
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "hotleads":
subquery_active_keys = select(Key.tg_id).where(Key.expiry_time > now_ms).distinct()
query = (
select(distinct(User.tg_id))
.join(Payment, User.tg_id == Payment.tg_id)
.where(Payment.status == "success")
.where(Payment.amount > 0)
.where(Payment.payment_system.notin_(["referral", "coupon", "cashback"]))
.where(not_(exists(subquery_active_keys.where(Key.tg_id == User.tg_id))))
.where(~User.tg_id.in_(banned_tg_ids))
)
elif send_to == "trial":
trial_tariff_subquery = select(Tariff.id).where(Tariff.group_code == "trial")
query = (
select(distinct(Key.tg_id))
.where(Key.tariff_id.in_(trial_tariff_subquery))
.where(~Key.tg_id.in_(banned_tg_ids))
)
else:
query = select(distinct(User.tg_id)).where(~User.tg_id.in_(banned_tg_ids))
result = await session.execute(query)
tg_ids = [row[0] for row in result.all()]
return tg_ids, len(tg_ids)
def parse_message_buttons(text: str) -> tuple[str, InlineKeyboardMarkup | None]:
if "BUTTONS:" not in text:
return text, None
parts = text.split("BUTTONS:", 1)
clean_text = parts[0].strip()
buttons_text = parts[1].strip()
if not buttons_text:
return clean_text, None
buttons = []
button_lines = [line.strip() for line in buttons_text.split("\n") if line.strip()]
for line in button_lines:
try:
cleaned_line = re.sub(r'<tg-emoji emoji-id="[^"]*">([^<]*)</tg-emoji>', r"\1", line)
button_data = json.loads(cleaned_line)
if not isinstance(button_data, dict) or "text" not in button_data:
logger.warning(f"Неверный формат кнопки: {line}")
continue
text_btn = button_data["text"]
if "callback" in button_data:
callback_data = button_data["callback"]
if len(callback_data) > 64:
logger.warning(f"Callback слишком длинный: {callback_data}")
continue
button = InlineKeyboardButton(text=text_btn, callback_data=callback_data)
elif "url" in button_data:
url = button_data["url"]
button = InlineKeyboardButton(text=text_btn, url=url)
else:
logger.warning(f"Кнопка без действия: {line}")
continue
buttons.append([button])
except json.JSONDecodeError as e:
logger.warning(f"Ошибка парсинга JSON кнопки: {line} - {e}")
continue
except Exception as e:
logger.error(f"Ошибка создания кнопки: {line} - {e}")
continue
if not buttons:
return clean_text, None
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
return clean_text, keyboard
@router.callback_query(
AdminPanelCallback.filter(F.action == "sender"),
IsAdminFilter(),
)
async def handle_sender(callback_query: CallbackQuery):
try:
await callback_query.message.edit_text(
text="✍️ Выберите группу пользователей для рассылки:",
reply_markup=build_sender_kb(),
)
except TelegramBadRequest as e:
if "message is not modified" in str(e):
logger.debug("[Sender] Сообщение не изменено, Telegram отклонил редактирование")
else:
raise
@router.callback_query(
AdminSenderCallback.filter(F.type != "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback_text(
callback_query: CallbackQuery, callback_data: AdminSenderCallback, state: FSMContext
):
await callback_query.message.edit_text(
text=(
"✍️ Введите текст сообщения для рассылки\n\n"
"Поддерживается только Telegram-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только <b>текст</b>\n"
"• Только <b>картинку</b>\n"
"• <b>Текст + картинку</b>\n"
"• <b>Сообщение + кнопки</b> (см. формат ниже)\n\n"
"<b>📋 Пример формата кнопок:</b>\n"
"<code>Ваше сообщение</code>\n\n"
"<code>BUTTONS:</code>\n"
'<code>{"text": "👤 Личный кабинет", "callback": "profile"}</code>\n'
'<code>{"text": " Купить подписку", "callback": "buy"}</code>\n'
'<code>{"text": "🎁 Забрать купон", "url": "https://t.me/cupons"}</code>\n'
'<code>{"text": "📢 Канал", "url": "https://t.me/channel"}</code>'
),
reply_markup=build_admin_back_kb("sender"),
)
await state.update_data(type=callback_data.type, cluster_name=callback_data.data)
await state.set_state(AdminSender.waiting_for_message)
@router.callback_query(
AdminSenderCallback.filter(F.type == "cluster-select"),
IsAdminFilter(),
)
async def handle_sender_callback(callback_query: CallbackQuery, session: AsyncSession):
result = await session.execute(select(Server.cluster_name).distinct())
clusters = result.mappings().all()
await callback_query.message.answer(
"✍️ Выберите кластер для рассылки сообщений:",
reply_markup=build_clusters_kb(clusters),
)
@router.message(AdminSender.waiting_for_message, IsAdminFilter())
async def handle_message_input(message: Message, state: FSMContext, session: AsyncSession):
original_text = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
clean_text, keyboard = parse_message_buttons(original_text)
max_len = 1024 if photo else 4096
if len(clean_text) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\nМаксимум: <b>{max_len}</b> символов, сейчас: <b>{len(clean_text)}</b>.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
return
data = await state.get_data()
send_to = data.get("type", "all")
cluster_name = data.get("cluster_name")
_, user_count = await get_recipients(session, send_to, cluster_name)
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(
f"👀 Это предпросмотр рассылки.\n👥 Количество получателей: <b>{user_count}</b>\n\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")
keyboard = None
if keyboard_data:
try:
keyboard = InlineKeyboardMarkup.model_validate(keyboard_data)
except Exception as e:
logger.error(f"Ошибка восстановления клавиатуры: {e}")
tg_ids, total_users = await get_recipients(session, send_to, cluster_name)
await callback_query.message.edit_text(f"📤 <b>Рассылка начата!</b>\n👥 Количество получателей: {total_users}")
messages = []
for tg_id in tg_ids:
message_data = {"tg_id": tg_id, "text": text_message, "photo": photo, "keyboard": keyboard}
messages.append(message_data)
results = await send_broadcast_batch(bot=callback_query.bot, messages=messages, batch_size=15, session=session)
success_count = sum(1 for result in results if result)
await callback_query.message.answer(
text=(
f"📤 <b>Рассылка завершена!</b>\n\n"
f"👥 <b>Количество получателей:</b> {total_users}\n"
f"✅ <b>Доставлено:</b> {success_count}\n"
f"❌ <b>Не доставлено:</b> {total_users - success_count}"
),
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
@router.callback_query(F.data == "cancel_message", IsAdminFilter())
async def handle_send_cancel(callback_query: CallbackQuery, state: FSMContext):
await callback_query.message.edit_text(
"🚫 Рассылка отменена.",
reply_markup=build_admin_back_kb("sender"),
)
await state.clear()
+3
View File
@@ -0,0 +1,3 @@
__all__ = ("router",)
from .servers_handler import router

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