1749 Commits

Author SHA1 Message Date
Capybara-z c5c8c68825 fix delete users / fix save web img 2026-05-10 00:54:48 +00:00
Capybara-z 166077b14e fix WATA 2026-05-08 18:37:41 +00:00
Vladless ef8874d323 Backend: identities attach/detach/password ops were silently dropped due to refresh-before-flush, now flush first. 2026-05-07 20:58:09 +00:00
Vladless fb478dff38 Backend: pdf shows external payment_id only, dash if missing (no db id leak). Frontend: enforce 16px on mobile inputs to block ios zoom. 2026-05-07 19:07:06 +00:00
Vladless 8bc9f3ba0e Backend: invoice uses external payment_id (not db id) / api exposes payment_id. Frontend: fullwidth qr+hint in cyber-mono partner card / render full partner conditions (mode/levels/min payout/methods). 2026-05-07 18:41:02 +00:00
Vladless 075ad515af Backend: cli safe_prompt strips non-ascii instead of rejecting whole input. Frontend: fetch pdf as blob in tg webapp. 2026-05-07 18:17:26 +00:00
Vladless c09470fe49 Backend: broadcast channel select (bot/site/both) / scheduled_broadcasts.channel migration v29 / API v1+v2 channel field. Frontend: global haptic on button click / navigator.vibrate fallback for non-tg. 2026-05-07 17:09:48 +00:00
Vladless 6ebaff08e8 stats: show linked email + tg_id counts 2026-05-07 15:15:27 +00:00
Vladless bf09fbac26 tg webapp: bypass login flash via webapp=1 flag 2026-05-07 04:01:01 +00:00
Vladless 50de5d7af6 mobile: stream renderer 2026-05-06 22:00:56 +00:00
Capybara-z 06eabd8f26 middleware session rollback / WATA pending payment 2026-05-06 20:47:53 +00:00
Vladless aa086a57d2 scale cabinet-mono pack architecture 2026-05-03 10:36:03 +00:00
Vladless 3a7bf7430d ParityPay SBP provider, unified ChromeEditModal, responsive container queries, perf audit 2026-04-30 17:21:53 +00:00
Capybara-z 55bebbd924 device limit panel 2026-04-29 21:16:22 +00:00
Capybara-z 0893b8cc7a fix cupon 2026-04-29 16:15:12 +00:00
Capybara-z f9edc64b23 WATA runtime config check 2026-04-29 00:20:24 +00:00
Capybara-z 15c0f12c31 add WATA provider / broadcast retry / cluster sync concurrency / gift fix 2026-04-28 23:57:27 +00:00
Vladless 9c9dafb459 up version 2026-04-27 15:42:38 +00:00
Vladless c0edb9fe9b layout, autofit, dedup, hydration fix 2026-04-26 19:54:49 +00:00
Capybara-z 4da12d850a Fix webhook key-created notify / extract key view 2026-04-24 14:22:42 +00:00
Capybara-z 954326b48f Fix notification spam / Redis publish reuse / Systemd NOFILE 2026-04-23 22:48:00 +00:00
Vladless 92b74af0ac push permission cta / gift referral guard 2026-04-23 16:42:40 +00:00
Vladless db14ba6dac up version 2026-04-23 00:30:12 +00:00
Vladless d46a1a4e3a Align response verify with request fields 2026-04-22 03:20:29 +00:00
Vladless c6e421bcce Use client hwid for response signature verify 2026-04-22 03:00:17 +00:00
Vladless 4dd2f5f245 Bust CDN cache / restart on rpc update 2026-04-22 02:45:01 +00:00
Vladless f1f8df4715 VAPID config snippet / rpc sync logging 2026-04-22 02:30:45 +00:00
Capybara-z b1f51dbf21 Tune broadcast rate / progress throttle 2026-04-22 01:17:10 +00:00
Vladless 5c144229b2 Web notify template / CLI rpc sync 2026-04-22 01:10:49 +00:00
Vladless c6d316f4c8 CLI UX overhaul / Uninstall site 2026-04-22 00:47:09 +00:00
Vladless 6151e25974 CLI fresh-install welcome / version from versioning.py 2026-04-21 23:39:53 +00:00
Vladless b50738209e Granular admin permissions / Launcher auto-bootstrap 2026-04-21 23:15:55 +00:00
Vladless ee69075195 Viewport zoom + sidebar fill 2026-04-21 22:04:22 +00:00
Vladless 11f122a13a WEB flow customization 2026-04-21 01:46:20 +00:00
Capybara-z 88e947beb9 Redis pool limit / Systemd killmode / Devices plural 2026-04-20 22:35:46 +00:00
Vladless 68ea6db6c7 WEB-Site install update 2026-04-20 18:53:13 +00:00
Capybara-z 4a60fb18c0 CLI web license check standalone 2026-04-20 01:10:35 +00:00
Capybara-z 884b5558fc Version bump 2026-04-20 00:07:39 +00:00
Capybara-z 789acdf9a2 CLI web standalone 2026-04-19 23:30:20 +00:00
Capybara-z 31d008efbb Sender emoji / Gift visibility 2026-04-19 23:06:38 +00:00
Vladless bfd628b24e backend optimizations + redundant commits cleanup 2026-04-19 22:53:14 +00:00
Vladless ed30a21842 auth sessions + flow schema fixes 2026-04-19 20:59:15 +00:00
Vladless 469f8c8050 Webapp perf + UX / ETag + GZip / SSR AppInfo / shared chrome SWR / dynamic modals / scoped fonts / unified Spinner / OutlineWrap loop fix / mobile edit outline / checkout theme flash / TariffInfo layout 2026-04-18 21:45:45 +00:00
Vladless 0b17346cb6 Webapp hardening + perf / CSP nonce-only / plugin-builder API guard + token autogen / cookie Secure / XSS fixes (QR, postMessage) / auth-me SWR dedup / SSR fetch cache / entry-capture redesign (gift/partner/referral) / payment webhooks (yoomoney sign, tribute trb_user_id) / email-only partners+referrals / INSTALL guide 2026-04-18 18:12:05 +00:00
Vladless cc2c9fbdba Fix update-check default GHCR image 2026-04-18 11:50:33 +00:00
Vladless 425a10423f Mobile layout + content revisioning 2026-04-18 11:30:07 +00:00
Capybara-z 57c26ff344 Fix sender rate limit 2026-04-18 11:27:51 +00:00
Capybara-z 6fd335d71c Fix init_db duplicate / circular import / redundant panel query / InaccessibleMessage 2026-04-18 01:36:37 +00:00
Vladless 3b913956e9 Disable all caching layers for instant content updates 2026-04-17 22:59:01 +00:00
Vladless eabbdeee8f Fix flow start + Query import + debug logging 2026-04-17 19:36:14 +00:00
Vladless 752ee206f2 Update check by channel + desktop/mobile block delete fix 2026-04-17 18:10:43 +00:00
Vladless 5b4437a115 Adaptive tariff cards scaling (text/padding/banner shrink with container) 2026-04-17 17:51:54 +00:00
Vladless 7dc17b821d Uniform pricing cards + flow actions + auto-fit + god-components split 2026-04-17 15:14:09 +00:00
Vladless 4bd6ce4970 Telegram OIDC + BLOCK_REGISTRY refactor + bugfixes 2026-04-17 13:06:05 +00:00
Capybara-z 0821b69879 Merge branch 'dev' of https://github.com/Vladless/Solo_src into dev # Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit. 2026-04-17 11:14:00 +00:00
Vladless 4cb84ceacc Admin onboarding + BrickGrid + workflow author fix 2026-04-17 10:45:30 +00:00
Vladless 11b5a8f4bd Addon reset warning on renewal / Fix daily stats report cron 2026-04-17 01:44:53 +00:00
Vladless 76cf9df266 Fix renew tariff state desync / Version bump 2026-04-16 17:39:12 +00:00
Vladless 19feb4cb80 Notification system refactor / Fix configurator renewal pricing / Fix Remnawave key deletion 2026-04-16 17:33:35 +00:00
Vladless a55c591f52 Fix renew tariff state desync / Version bump 2026-04-16 17:31:29 +00:00
Vladless 5742f1d68d Notification system refactor / Fix configurator renewal pricing / Fix Remnawave key deletion 2026-04-16 17:05:23 +00:00
Vladless 9624e3d5c4 Auto block-unblock chat member / Backup S3 2026-04-16 13:14:11 +00:00
Vladless 9f140542d9 Fix configurator price on tariff switch in renewal / Fix datetime naive vs aware across handlers / Redis connection stability 2026-04-15 17:42:12 +00:00
Vladless 995e2f8016 Notification photo cache, bulk traffic, blocked preload filter / Fix discount datetime aware 2026-04-15 11:10:57 +00:00
Vladless 674ce2d609 Clean default site on install 2026-04-14 20:42:52 +00:00
Vladless 1e1275f3c7 Auth sync is_admin on TG login 2026-04-14 18:58:02 +00:00
Vladless 083e3a59dd CLI web install 2026-04-14 17:40:47 +00:00
Vladless e9e3c6596b CLI install: split single/multi-server flow + fix pre-commit regex 2026-04-14 17:07:40 +00:00
Vladless fe27132102 CLI 0.5.7 / docker host.docker.internal host-gateway mapping 2026-04-14 16:31:04 +00:00
Vladless 46f7ad768a CLI VAPID keypair generation in web install 2026-04-14 15:21:58 +00:00
Vladless 6da28ef3e9 CLI 0.5.6 / nginx safe install (prompt + conflict detect) 2026-04-14 10:38:47 +00:00
Vladless 6f9b6f4e8e CLI 0.5.5 / web image channel (latest/dev) 2026-04-14 08:58:13 +00:00
Vladless 39dd2432cc Ruff format/ Cleanup 2026-04-14 07:19:14 +00:00
Vladless 0e8b0d04f0 Versioning/ Cabinet polish/ Modal block 2026-04-14 06:56:57 +00:00
Vladless 9521578cae Onboarding/ Rewritten admin tour with auto panel/cloud/edit mode transitions/ Separate tours for flow editor, element edit modal, custom elements page/ Portal z-[200000] + stopPropagation fixes/ Overlay placement fallback for large targets 2026-04-14 01:13:09 +00:00
Vladless 358725db1e Web-app mobile UX/ Theme cloud & admin panel pill nav/ Nav bar tokens (hide-on-scroll, opacity, border width)/ Fixed mobile grid rows on exit edit/ Handshake icon/ CLI API port from config/ AuthSync telegram-callback skip 2026-04-14 00:08:58 +00:00
Vladless ce01d48501 CLI update 2026-04-13 20:24:05 +00:00
Vladless d2b9b1edbb Accounts/ Email search + unlink/ Symmetric identity merge/ Schema v19 keys PK/ Web-app UX/ Setup wizard/ Post-payment flow 2026-04-13 18:50:08 +00:00
Vladless dfab5f9bc0 API/ Tariffs public fix/ Force recompile 2026-04-13 00:58:05 +00:00
Vladless c693c28ee7 WEB-APP/ Optimization/ Build fix/ Hotkey edit mode/ Log rotation/ Form a11y/ E2E non-blocking 2026-04-13 00:38:29 +00:00
Vladless c1b9b20ea3 fix butch audit cache 2026-03-20 18:42:22 +03:00
Vladless 3b67715ac0 fix close event loop for redis 2026-03-20 17:17:10 +03:00
Capybara-z fec4e37901 fix addon device limits on key reissue 2026-03-20 01:37:46 +03:00
Capybara-z 350ed85839 fix blocked users handling in sender / optimize cluster day extension 2026-03-20 01:05:17 +03:00
Vladless 6ced5ea4e4 fix logs in the loguru 2026-03-20 00:47:25 +03:00
Vladless 14dcb72be8 fix delete key 2026-03-20 00:43:04 +03:00
Vladless 4bb4914a59 balance in audit history 2026-03-20 00:05:06 +03:00
Vladless 19ae3d1611 CLI update 2026-03-19 23:27:50 +03:00
Vladless bffa1adece event loop for database engine 2026-03-19 21:55:12 +03:00
Vladless e036851440 tasks manager 2026-03-19 21:44:46 +03:00
Vladless bc558ea54f sheduled service 2026-03-19 18:27:04 +03:00
Vladless 9e0c4b199d add scheduled broadcasts / introduce periodic task manager / reduce sender progress flood
Made-with: Cursor
2026-03-19 02:59:05 +03:00
Vladless 352bc22be2 remove client subscription freeze / keep admin subscription toggle / update admin button labels
Made-with: Cursor
2026-03-19 01:40:23 +03:00
Vladless 3a304afc49 fix CallbackAnswerMiddleware 2026-03-18 23:03:00 +03:00
Vladless a9dad6a099 audit update/ funnel by steps 2026-03-18 22:55:58 +03:00
Vladless 3b4309e001 audit message 2026-03-18 04:13:57 +03:00
Vladless 6d341a9f57 audit of client actions/ protection of admin TG v2/ fix of cash accounting status/ update of dependencies 2026-03-18 01:17:44 +03:00
Vladless 77f927660b service name in yookassa 2026-03-17 14:49:38 +03:00
Vladless 11d11b2fee fix negative balance in gifts/ amoun in yoomoney 2026-03-17 01:37:57 +03:00
Capybara-z 31f008e9e5 fix resolve subscription link fallback logic 2026-03-13 01:35:17 +03:00
Capybara-z 2d571d4d97 fix coupon keeps configurator limits 2026-03-12 20:56:09 +03:00
Capybara-z 1acf239458 add addon purchase hook 2026-03-04 21:48:17 +03:00
Vladless 10beddfc6b fix bulk inactive notifications 2026-03-03 21:49:52 +03:00
Capybara-z 11d0e57541 change version 2026-03-02 18:20:14 +03:00
Capybara-z d0f7695097 add payment pagination/payment_id display 2026-03-02 18:12:34 +03:00
Vladless d3dc53e9d5 fix back button in stars 2026-03-02 17:48:09 +03:00
Vladless 33a6da852e fix country mode and traffic limit 2026-03-02 13:48:23 +03:00
Vladless 6882a4adfe fix bad request 2026-03-01 01:38:00 +03:00
Vladless b448b918c9 CLI update 0.4.0 2026-02-28 23:11:00 +03:00
Vladless 630bf5069c save blocked users in sender service 2026-02-28 19:18:07 +03:00
Vladless 0e4dfbf369 fixed subscription renewal 2026-02-28 14:12:30 +03:00
Vladless 18a72f9410 refactoring and optimization 2026-02-27 22:04:01 +03:00
Vladless 5709dca63c early response to the request 2026-02-27 19:40:33 +03:00
Vladless 2fdcb283eb safe callback 2026-02-27 18:44:05 +03:00
Capybara-z 220c5ce2e4 fix batch bulk notification queries 2026-02-27 13:01:08 +03:00
Capybara-z 69b05cb9a5 fix add missing key/link fields to keys cache dict 2026-02-27 02:11:06 +03:00
Vladless 6f76608b04 redis cache/ webhook abuse/ separate streams for sending and requesting panels/ increased security and more 2026-02-26 22:00:15 +03:00
Capybara-z 405c3fef1b fix datetime serialization for RedisStorage FSM 2026-02-26 01:35:55 +03:00
Vladless b8906cd3e3 yookassa ip adresses 2026-02-25 23:11:34 +03:00
Vladless b46c3b6912 fix ban message too long 2026-02-24 22:54:24 +03:00
Vladless fc0ae90f33 status bar in sender service 2026-02-24 16:01:16 +03:00
Vladless 51ad7cb0cc Processing requests that are too outdated 2026-02-23 20:09:49 +03:00
Vladless d3c1b4a71a fix FSM state 2026-02-22 21:38:54 +03:00
Vladless b17ebc32e2 CONCURRENCY_LIMIT/Trottle errors 2026-02-21 22:07:22 +03:00
Vladless 78fa0ee82e fix spam errors 2026-02-21 18:58:09 +03:00
Vladless 4b1a9a2567 PGboucer/UTM cache 2026-02-21 18:32:52 +03:00
Vladless aed63de100 change version 2026-02-21 17:35:08 +03:00
Vladless e702d21c33 multithreading/log refactoring 2026-02-21 15:17:58 +03:00
Vladless 417566a202 Redis url in config file 2026-02-21 13:52:21 +03:00
Vladless 0b9f223b91 Redis/ Query optimization/ multithreading/ and more 2026-02-21 04:22:24 +03:00
Vladless 60eb47f920 fix circular import 2026-02-20 19:13:04 +03:00
Vladless 05a19f01b1 Optimizing database queries/ Combining cash registers into a single point/ Caching key moments/ API versioning/ ruff formatting 2026-02-20 00:27:10 +03:00
Vladless 2580835f55 Handle session commit failure with rollback and do not re-raise 2026-02-14 23:44:23 +03:00
Vladless 49de94069f admins do not touch the shadow ban 2026-02-14 22:42:17 +03:00
Vladless d3500af899 admins do not touch the shadow ban 2026-02-14 22:27:52 +03:00
Vladless ec84972591 indempotence of cash registers/ fixed regime of countries 2026-02-14 22:11:45 +03:00
Vladless e193c920e6 chande modules logic 2026-02-14 17:03:19 +03:00
Vladless ada1aed98c add logs in session middleware 2026-02-14 14:06:05 +03:00
Vladless 30fd6ce55d working on asynchronous behavior 2026-02-14 13:10:31 +03:00
Vladless 9a921132c4 update api 2026-02-14 13:02:59 +03:00
Vladless 842bad1d80 commit in payments 2026-02-13 22:00:12 +03:00
Vladless a2c29b0697 fix int yokassa 2026-02-13 21:32:28 +03:00
Vladislav Lisitsyn 4df02fdefc 5.1-Release
5.1-Release
2026-02-12 21:58:58 +03:00
Vladislav Lisitsyn f8cee4752a Merge branch 'main' into dev 2026-02-12 21:51:59 +03:00
Vladless c5fab1ccec up bot version 2026-02-12 21:48:36 +03:00
Vladless f6ff238ca7 add support for composite callbacks 2026-02-12 20:58:36 +03:00
Capybara-z 8d0f18c929 fix middleware registration 2026-02-12 20:19:19 +03:00
Vladless c87de32d71 logs in session 2026-02-12 20:00:25 +03:00
Vladless 4e4a51fc72 fix Session in the Sales registers 2026-02-12 15:12:06 +03:00
Vladless a35b233928 Correction of settings 2026-02-12 01:17:18 +03:00
Vladless 7faa7e7409 yookassa receipt 2026-02-12 01:02:47 +03:00
Vladless 48e981c7c6 database optimization/ back button in the configurator/ query competition and more 2026-02-12 00:53:38 +03:00
Vladless 78cbd0e0de short logs 2026-02-11 20:44:02 +03:00
Vladless b62be683e1 fixing a lot of custom emojis 2026-02-11 19:54:17 +03:00
Vladless cc00522390 ruff format 2026-02-11 03:40:01 +03:00
Vladless 33271eeea6 partner api update 2026-02-11 00:28:29 +03:00
Vladless 40c0e1ce51 partner color disable 2026-02-10 23:17:00 +03:00
Vladless bd0aac897b custom emoji in inline buttons/ CLI 0.3.9/ updated dependencies/ other minor changes 2026-02-10 22:35:39 +03:00
Vladislav Lisitsyn aded285612 Merge pull request #258 from JustYay/dev
[Feature] WebAPP Open in browser option
2026-02-10 12:12:11 +03:00
JustYay 4fb6c5afd5 webapp open in browser option 2026-02-10 16:51:39 +10:00
Capybara-z 7943227c1c restrict moderator access 2026-02-10 01:51:37 +03:00
Vladless d8a4d3206d partner api extension 2026-02-10 01:42:25 +03:00
Vladless 349db285f6 yukassa improvements 2026-02-10 01:32:16 +03:00
Vladless d30cf0b8a3 fixing unresolved characters in the payment response 2026-02-10 01:28:59 +03:00
Vladless 2ff179d68a custom emojis/ session optimization/ colored inline buttons 2026-02-10 00:57:30 +03:00
Capybara-z 920d805789 refactor buttons.py layout 2026-02-08 23:54:05 +03:00
Capybara-z b29e71e474 fix admin key display timezone 2026-02-08 23:10:50 +03:00
Vladless 302c2033a8 API 0.5.1/ Minor changes and improvements 2026-02-08 22:30:05 +03:00
Capybara-z 18fb66c1a4 fix preserve device/traffic limits on coupon 2026-02-07 22:48:30 +03:00
Capybara-z 50fbd1841b fix improve trial bonus timing / add coupon button toggle 2026-02-07 17:38:15 +03:00
Vladislav Lisitsyn da01520d13 Merge pull request #257 from JustYay/dev
[FEAUTURE] Изменения прорядка касс через админ-панель
2026-02-07 01:52:09 +03:00
Boris Kovalskii 8b2d79e49c Providers order to admin 2026-02-07 08:36:03 +10:00
Boris Kovalskii ec29051b13 Provider order to core 2026-02-07 08:34:16 +10:00
Boris Kovalskii b3c2894618 Add providers order 2026-02-07 08:33:24 +10:00
Capybara-z 19aaedf417 fix: InaccessibleMessage middleware / bulk restore_trials / auto-renew reset addons 2026-02-06 22:17:52 +03:00
Vladless 5a25caf55f avoid 204 responses for non-telegram webhooks 2026-02-06 00:17:24 +03:00
Vladless 554083b284 all webhooks for middlewares 2026-02-05 22:04:49 +03:00
Vladless 4d2e0f59a0 webhook_guard 2026-02-05 20:49:49 +03:00
hteppl 59450667ed Optimize IsAdminFilter and prevent checking empty from_user field 2026-02-05 17:34:58 +03:00
Capybara-z 056d6f50ec fixing a critical rights vulnerability 2026-02-05 16:58:55 +03:00
Vladless 0d3fc00bc9 fixing a critical rights vulnerability 2026-02-05 16:46:45 +03:00
Capybara-z 7af380538e fix tariff-server binding 2026-02-04 23:59:18 +03:00
Vladless 16a8d88bd0 interest coupons/ picture for rates/ small improvements 2026-02-04 23:45:34 +03:00
Vladless 6b226fb0de subscriptions in order 2026-02-04 00:00:50 +03:00
Vladless 1f164a6951 correcting the display of devices in the configurator 2026-02-01 14:14:24 +03:00
Vladless da354e55b9 the launch script has been changed 2026-02-01 13:10:07 +03:00
Vladless f7fdef06e5 The coupon field has been changed 2026-02-01 12:36:25 +03:00
Vladless 73214b2cc9 Docker/ API 0.5.0: Partner Statistics/Coupons for New Customers and More 2026-02-01 09:58:58 +03:00
Vladless 13e653a43a Only SBP for Yookassa/ Pagination in the 'My Subscriptions' menu 2026-01-28 00:07:00 +03:00
Vladless 53d478b9f1 fix for invalid characters when creating a yookassa payment 2026-01-25 20:48:17 +03:00
Vladislav Lisitsyn 06bee21873 Merge pull request #256 from JustYay/dev
HOTFIX "Консистеность касс V2"
2026-01-25 17:09:24 +03:00
Boris Kovalskii 78cbe1b0af FIX "KassaI && Heleket" 2026-01-25 14:48:35 +10:00
Boris Kovalskii fcb786399b HOTFIX "Консистентность касс"
- исправление edit_or_send_message в кассах
- добавление ожидаемого платежа
- исправление heleket (мусорил в базе)
2026-01-25 14:43:40 +10:00
Vladless 17928f8fbf Optimize user activity updates and fix traffic top-up 2026-01-23 04:22:59 +03:00
Vladless 3ab49099be probe and logging changes 2026-01-22 01:24:42 +03:00
Capybara-z d3180360a1 fix addons config layout / fix selection handling 2026-01-22 00:04:43 +03:00
Vladless 0f0df91d86 Added a picture to the renewal menu/ installing module dependencies/ changing the 'my gifts' menu 2026-01-21 23:23:12 +03:00
Vladless a8b207b059 fix tribute convertation 2026-01-21 20:47:41 +03:00
Vladless 4209dcc919 fix delete key and incorrect debit of the balance 2026-01-20 22:58:40 +03:00
Capybara-z e827ad9811 Add bulk cluster extend / HWID reset on device limit downgrade 2026-01-16 01:06:39 +03:00
Vladless fa8a7c8026 fixing the external squad when reissuing a subscription 2026-01-15 14:27:23 +03:00
Capybara-z 782078313a fix: tariff server binding, VLESS links from API, zero traffic logic 2026-01-14 21:29:07 +03:00
Capybara-z be725c5ebb Add API for managing tariff configurator / Improved version 0.4.0 (Alpha) 2026-01-10 21:11:16 +03:00
Capybara-z 9d8d4d597b Fix special notifications 2026-01-10 17:05:39 +03:00
Capybara-z 562c0f8d25 Refactor special notifications / Refactor server tariff bindings 2026-01-08 23:18:33 +03:00
Capybara-z 0fa5c6b682 Fix delete delay config 2026-01-06 17:26:09 +03:00
Capybara-z c39e315662 Notifications refactor / Admin gifts management / Admin menu user improvements / Fix revoke subscription 2026-01-06 16:30:25 +03:00
Capybara-z 430c247cf5 fix use correct tariff price on key renewal / fix auto-renewal notification logic 2026-01-02 15:05:04 +03:00
Vladless 623de6085f Fix node online count 2025-12-27 04:10:56 +03:00
Vladless cc6b7ef783 import remnawave users with tg_id parsing from username 2025-12-27 03:19:48 +03:00
Capybara-z e3fe8cf5d3 add tariff group to key info 2025-12-26 22:26:32 +03:00
Vladless 9d02ad6fbc changing the tariff through the admin panel without recreating it 2025-12-26 20:23:50 +03:00
Vladless c7563096e9 Export user payments to CSV 2025-12-26 18:53:23 +03:00
Vladless 7032e0eca2 CLI 0.3.8/update yookassa SDK 2025-12-26 18:22:30 +03:00
Vladless fb1b8e4858 CLI 0.3.8/update yookassa SDK 2025-12-26 18:16:11 +03:00
Capybara-z 4e4ceb8b03 fix traffic in key view 2025-12-26 16:48:20 +03:00
Capybara-z 3e0974941f add configurable backup archive 2025-12-25 00:08:11 +03:00
Vladless 883eca14b8 fix unlim traffic 2025-12-24 20:17:00 +03:00
Vladless ccef6afb7c fixed option reset when extending 2025-12-23 21:45:50 +03:00
Vladless a081804268 fix TelegramBadRequest in traffic pricing and null in configurator. Extended description of billing in the settings 2025-12-23 14:41:05 +03:00
Vladless d48713b8ba bug fixes 2025-12-22 22:09:33 +03:00
Capybara-z 24bb2596c5 Fix subscription deletion in notifications 2025-12-22 13:44:32 +03:00
Vladless 03e1d6d496 fix: overrides in addons pack/ mine in the balance after purchase/ import remnawave 2025-12-22 00:36:08 +03:00
Capybara-z 8527ddc268 fix tariff overrides and renewal limits 2025-12-21 23:31:57 +03:00
Capybara-z 73a1ea8e1b refact sync 2025-12-21 16:36:18 +03:00
Capybara-z 39f91d0d19 Add discount tariff availability checks 2025-12-21 00:07:48 +03:00
Capybara-z f9023ef975 fix key config limits on expiry change 2025-12-18 00:16:57 +03:00
Vladless 6bcbd90980 recalculation during purchase 2025-12-15 23:46:03 +03:00
Vladless d3ad2edfcf fixed balance write-off in gifts/ added settings in api 2025-12-15 20:18:19 +03:00
Vladless d0c1daf3cc minor fixes 2025-12-14 22:56:33 +03:00
Capybara-z 7914b3502b Add revoke subs Remnawave 2025-12-14 22:42:51 +03:00
Capybara-z c098566c32 Add configurable webhook response for KassaAI 2025-12-14 21:45:04 +03:00
Capybara-z 13e002aba9 add key configuration management 2025-12-14 18:51:00 +03:00
Vladless 9a51aa19a2 add start_entry hook 2025-12-14 18:36:44 +03:00
Vladless 0cf72d5f30 users cache 2025-12-11 16:41:49 +03:00
Vladless b4005b2786 All middlewares now work through a single DB session 2025-12-11 15:49:38 +03:00
Vladless 4ccd95207b new version 2025-12-10 20:28:07 +03:00
Vladless af5657f490 Cli 0.3.7/ Warning 2025-12-10 16:32:32 +03:00
Vladless 54c8b8cee9 country_mode in database/ payment options 2025-12-10 12:44:27 +03:00
Vladless 82c664fa68 migration between external squads/ make format 2025-12-10 07:34:48 +03:00
Vladless cf8259e623 add support remnawave 2.3.x 2025-12-09 23:59:31 +03:00
Capybara-z 222de4d822 fix renew hwid configurator 2025-12-09 21:03:17 +03:00
Vladless 33d4299bd5 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-08 10:40:32 +03:00
Vladless c5e16cc402 normalize_expiry_ms 2025-12-08 10:39:14 +03:00
Capybara-z dfbf640fb3 Tariff configurator for discount tariffs 2025-12-08 01:59:35 +03:00
Vladless 3bb36c7a66 multiple fixes 2025-12-07 23:01:21 +03:00
Vladless 2e940c1407 optimization of the start command 2025-12-07 00:32:43 +03:00
Vladislav Lisitsyn 0710a3464c Merge pull request #254 from JustYay/dev
FIX "HWID Limit"
2025-12-06 03:32:19 +03:00
Boris Kovalskii 954747ff5d FIX "HWID Limit" 2025-12-06 10:27:33 +10:00
Vladless e6ead41f84 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-12-06 01:54:57 +03:00
Vladless a8754fc40e gift rate configurator 2025-12-06 01:54:05 +03:00
Capybara-z 2e30d82736 Refactor admin key display logic 2025-12-04 00:49:01 +03:00
Capybara-z d72907470c Refactor sender button parsing / Add tariff hooks 2025-12-03 02:26:17 +03:00
Vladless c661a9b2f6 fix tariffs in country mode 2025-12-01 23:13:55 +03:00
Vladless 4e3520ee22 changing the logic of the fare selection button 2025-12-01 22:06:55 +03:00
Vladislav Lisitsyn 313bdfdc62 Merge pull request #253 from JustYay/dev
FIX "Создание лимита трафика"
2025-12-01 13:21:56 +03:00
Boris Kovalskii c4c036dd88 FIX "Создание лимита трафика"
FIX "Создание лимита трафика" при генерации ключа
2025-12-01 11:44:05 +10:00
Vladless 158fb4a51e fix notification options in settings 2025-11-30 22:43:51 +03:00
Vladless 7ea0c29cdc added support for external squads 2025-11-30 18:37:18 +03:00
Capybara-z 008643c62e fix deletion of minute subscriptions 2025-11-30 00:52:01 +03:00
Vladless 22a0eced2b rate configurator/ purchase of conditions/ fixed payments/ refactoring/ major improvements and fixes 2025-11-29 19:08:01 +03:00
Capybara-z 17547cd323 fix: show payment statuses in balance operations 2025-11-24 00:06:00 +03:00
Capybara-z 570df97d33 refactor broadcast system with improved speed and flood control 2025-11-20 17:27:54 +03:00
Capybara-z 19c895e4f9 admin menu hooks 2025-11-18 16:37:25 +03:00
Capybara-z 40d2aa8e92 Fix referral bonus calculation 2025-11-18 10:50:07 +03:00
Vladless 5a0fae165a minor fixes 2025-11-17 00:47:06 +03:00
Capybara-z 132b26c9af Refactor hooks system / Fix country mode special groups / Fix location change filtering 2025-11-16 18:51:59 +03:00
Vladless 827f63428e freezing subscriptions for admins/ organizing selection in country mode 2025-11-16 05:08:48 +03:00
Vladislav Lisitsyn 5532f37370 Merge pull request #252 from JustYay/dev
FIX "Консистентный дизайн касс"
2025-11-16 05:04:32 +03:00
Boris Kovalskii d550ac78cd FIX "Консистентный дизайн касс"
- FIX "Консистентный дизайн касс" Heleket и Kassai
- Оптимизация касс по PEP8
- Оптимизация функций KassaI
2025-11-16 12:00:19 +10:00
Vladless 5d059c16fb Adding admin operations to the balance 2025-11-16 03:30:00 +03:00
Capybara-z fedc89e362 fix admin crypto links / fix sender button formatting 2025-11-16 01:15:15 +03:00
Vladless 773f05d113 runtime settings loaded from DB/ refactoring admin-panel/ minor fixes and improvements 2025-11-15 23:14:34 +03:00
Capybara-z b249f3c694 Remove break in hooks / improve ban management 2025-11-14 17:32:41 +03:00
Capybara-z eae52bb799 Add hooks extension 2025-11-13 19:10:31 +03:00
Capybara-z 9472aff2bf fix country mode sync / add cryptolink hook 2025-11-12 16:17:30 +03:00
Vladislav Lisitsyn 48f0316897 Merge pull request #251 from TrackLine/dev
Добавление поддержки прямых ссылок на команды
2025-11-12 00:52:35 +03:00
Max Shalenkov d84f06a941 fix new urls 2025-11-12 01:50:04 +04:00
Max Shalenkov 3b4754ebe8 add direct url function 2025-11-11 23:51:06 +04:00
Capybara-z d82d453ce3 Merge pull request #250 from TrackLine/dev
remove WATA payments
2025-11-10 22:46:50 +03:00
Max Shalenkov 634ca4a6ad remove WATA payments 2025-11-10 23:38:05 +04:00
Capybara-z 45229441c3 refactor stats menu / fix subscription renewal 2025-11-04 23:55:48 +03:00
Vladless a345f4416c a small fix for the back button 2025-11-01 23:53:53 +03:00
Capybara-z f5cf6a5398 Fix special tariff notifications 2025-11-01 14:54:27 +03:00
Capybara-z 726ba8dd66 Fix special tariff notifications 2025-11-01 14:53:15 +03:00
Capybara-z bef5469e40 fix sender banning issues 2025-10-25 18:33:39 +03:00
Capybara-z 57e33dd2ec fix sender banning issues 2025-10-25 18:29:12 +03:00
Vladislav Lisitsyn e018fb11d4 v4.4 → v.5 2025-10-25 17:13:19 +03:00
Vladless 754f0beafc Resolve conflicts: prefer dev over main 2025-10-25 17:12:56 +03:00
Capybara-z 6a0371fc49 fix device limit 2025-10-25 15:29:29 +03:00
Capybara-z acd56e2ef9 fix text sub 2025-10-25 02:12:31 +03:00
Capybara-z d6152bc3fd fix improve server sync filtering / tariff update logic 2025-10-25 02:10:28 +03:00
Capybara-z 04bfd537a2 Fix Remnawave sync 2025-10-25 00:57:32 +03:00
Vladislav Lisitsyn 6835d89e21 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 ce29a37990 up bot version 2025-10-24 09:47:14 +03:00
Vladless 78e1a4d63b ruff format/ minor fixes and improvements 2025-10-24 09:39:54 +03:00
Capybara-z e9ff036934 preserve UUID in Remnawave sync 2025-10-24 02:39:32 +03:00
Capybara-z 050e4ae139 Fix server validation and add key renewal hooks 2025-10-23 21:54:40 +03:00
Capybara-z a7c4e06102 fix: add subgroup/special group support to key renewals and cluster sync 2025-10-22 02:07:18 +03:00
Capybara-z 5e230e4286 Refactor admin panel menu / fix QR code caching 2025-10-21 23:39:15 +03:00
Capybara-z a027d0b1a9 Add router icon / multi-format media support 2025-10-18 00:21:14 +03:00
Vladless 08509052c0 fix change locations/ delete update_subscription_button/ refactoring the deletion function 2025-10-17 22:23:16 +03:00
Vladislav Lisitsyn 7e587e5ae0 Merge pull request #246 from JustYay/dev 2025-10-17 04:52:02 +03:00
Boris Kovalskii 2766dc9d00 FIX "Новые вебхуки" 2025-10-16 23:42:35 +00:00
Boris Kovalskii e2090b00ed Add files via upload 2025-10-16 23:41:47 +00:00
Boris Kovalskii 560fb64010 Delete web/kassai_payment.py 2025-10-17 09:41:27 +10:00
Boris Kovalskii 3cb22a5c7d Delete web/heleket_payment.py 2025-10-17 09:41:17 +10:00
Vladislav Lisitsyn 30de90851b Merge pull request #245 from JustYay/dev
FIX "Сообщение о минимальном балансе"
2025-10-14 01:06:12 +03:00
Vladless 807b32d87c optimization of database transactions/fixing middlewares/ cache of images 2025-10-14 01:05:52 +03:00
Boris Kovalskii 33d573daa1 FIX "Сообщение о минимальном балансе" 2025-10-13 21:52:01 +00:00
Capybara-z 93aaaa8f05 fix notifications 2025-10-13 19:04:52 +03:00
Vladless cc36f16e43 aggregated links for update key/ fix router button for country mode 2025-10-13 17:43:53 +03:00
Capybara-z 026922fc88 fix notification message handling 2025-10-13 10:11:26 +03:00
Vladless 5ab7e80e64 fix country_mode 2025-10-12 21:43:21 +03:00
Vladless 4ed43150f5 logging_level/ fix subgroup migrations and more 2025-10-12 20:59:18 +03:00
Vladless 97b582d02e adding servers for special groups/ ruff formatting 2025-10-11 01:27:10 +03:00
Capybara-z 7488eac897 refactor notifications with configurable timing / native crypto link handling / referral cache optimization 2025-10-10 23:56:34 +03:00
Oleg Kraev c1d0b06eb3 Merge pull request #244 from JustYay/dev
FIX "message is not modified"
2025-10-10 08:24:04 +03:00
Boris Kovalskii 284e20c839 FIX "message is not modified" 2025-10-10 05:19:35 +00:00
Vladless 8ffab98ec8 changed extension logic / fix: images when creating and back buttons 2025-10-10 03:36:19 +03:00
Vladless 2a0005248a Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-10-09 18:37:09 +03:00
Vladislav Lisitsyn f9d39aa736 Merge pull request #242 from JustYay/dev
FIX "Добавление и ответы бота чатах"
2025-10-09 18:28:44 +03:00
Capybara-z fa271581fe add auto-blocking for failed broadcasts and improve send speed 2025-10-09 17:17:21 +03:00
Boris Kovalskii d1caf97d15 FIX "Добавление и ответы бота чатах"
FIX "Добавление и ответы бота чатах" при обязательной подписке:

Теперь бот будет:
- Проверять подписку только в личных сообщениях
- Игнорировать все сообщения из групповых чатов
- Игнорировать анонимные сообщения от имени сообщества
- Игнорировать сообщения от других ботов
2025-10-09 03:16:11 +00:00
Vladislav Lisitsyn e3fd60d8d5 Merge pull request #241 from JustYay/dev
FIX "Adding a chat for a mandatory subscription"
2025-10-08 18:56:38 +03:00
Vladless 54f5a6a3e2 add pagination for modules menu 2025-10-07 23:19:16 +03:00
Vladless 6772a370bc add logic for back button in payments 2025-10-07 14:45:18 +03:00
Vladless 5d5adfd82a fix Tribute router 2025-10-07 14:07:13 +03:00
Vladless 73b6edff9f fix Tribute flow 2025-10-07 13:48:48 +03:00
Boris Kovalskii f94a26c54e FIX "Добавление чата для обязательной подписки" 2025-10-07 00:12:21 +00:00
Vladless 1084fac243 Module management/ TRIBUTE webapp/ small gift fix/ backup selection in CLI 2025-10-07 02:48:34 +03:00
Vladless bb21aabbe3 fix connection button for router tariffs 2025-10-06 17:37:33 +03:00
Vladless 1a04fa03d8 adding a fixed exchange rate for the dollar to the ruble/fixing server failures 2025-10-05 13:32:11 +03:00
Capybara-z 9feeb0e1d5 Add hook system for tariff group management 2025-10-04 23:18:27 +03:00
Capybara-z 5da236ce5d fix restore trials logic 2025-10-02 16:02:00 +03:00
Capybara-z 978abc8acb fix encode happ crypt links all versions 2025-10-02 10:45:44 +03:00
Capybara-z 4e859c90df fix encode happ crypt3 links 2025-10-02 01:08:23 +03:00
Vladless 827701a9d6 update issue template 2025-10-01 14:12:26 +03:00
Vladless 97eb7226fa fix issue template format 2025-10-01 14:12:14 +03:00
Vladless e1825603cd add issue template 2025-10-01 14:06:24 +03:00
Vladless f2c6f79592 add issue template 2025-10-01 14:05:27 +03:00
Vladless a041ef76b2 contributing guidelines / code of conduct / security policy / pr template 2025-10-01 13:57:44 +03:00
Vladless 7d68bbc273 contributing guidelines / code of conduct / security policy / issue template / pr template 2025-10-01 13:31:25 +03:00
Vladislav Lisitsyn f69bea787a Create CONTRIBUTING.md 2025-10-01 13:02:27 +03:00
Vladless a4d71682bf fix happ cryptolink for remnawave webapp 2025-10-01 12:24:43 +03:00
Vladless 5e5c3f77c7 ruff formatting 2025-09-30 19:19:05 +03:00
Vladless 608b520b72 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 8e8dcab230 fix stars peyment status/ remove literal for schemas 2025-09-24 00:55:24 +03:00
Vladless 91c0db6e3c add pending for api payments 2025-09-24 00:22:13 +03:00
Vladless 2b3c301760 minor fixes/api for payments 2025-09-23 22:41:11 +03:00
Vladislav Lisitsyn db009d52a0 Merge pull request #239 from JustYay/dev
FIX "Приведение касс к одному стилю"
2025-09-21 14:24:54 +03:00
Boris Kovalskii 6f3252c0d1 FIX "Отобжражение в handlers касс" 2025-09-21 04:17:06 +00:00
Boris Kovalskii d148d5efd5 FIX "Приведение касс к одному стилю"
- Теперь создаем клавиатуры методами из keyboards
- Язык пользователя берется из БД
2025-09-21 04:10:10 +00:00
Capybara-z 6123269404 fix extend hooks for key creation 2025-09-19 23:55:22 +03:00
Capybara-z 4840d8b10c extend hooks for key creation 2025-09-19 12:13:04 +03:00
Vladless 5dffe96f80 fix middlewares, gifts and more 2025-09-19 02:47:40 +03:00
Capybara-z eaa98c9329 add extend keyboard hooks / refactor hot leads texts / fix hot leads notification logic 2025-09-18 21:23:58 +03:00
Vladless d51a0eafe8 minor fixes for start menu 2025-09-16 23:34:48 +03:00
Capybara-z f14b8fb127 Add hook integration: cluster balancing, subscription routing, tariff menu 2025-09-16 22:09:32 +03:00
Vladislav Lisitsyn 00dcd95523 Merge pull request #238 from JustYay/dev
FIX "Выбор касс и кнопка возврата"
2025-09-16 21:23:06 +03:00
Boris Kovalskii cf280444b2 FIX "Кнопка back_to_pay"
Не работала кнопка возврата, для восстановления функцицональности сделано временное решение - возврат на balance
2025-09-16 12:43:30 +00:00
Boris Kovalskii 1118105b7c FIX "Активные платежки" 2025-09-16 12:38:02 +00:00
Vladislav Lisitsyn 77707efce8 Merge pull request #237 from JustYay/dev
Fix Currency rate and Heleket payment
2025-09-16 02:30:34 +03:00
Boris Kovalskii 9f6717bf82 FIX "Комментарии 5" 2025-09-15 23:27:23 +00:00
Boris Kovalskii ef32c71668 FIX "Modules в Providers"
Теперь мы можем использовать modules для каждого провайдера, если несколько методов в одной платежке
2025-09-15 23:25:42 +00:00
Boris Kovalskii eb1fb341fa FIX "Комментарии 3" 2025-09-15 23:04:27 +00:00
Boris Kovalskii b0eb63441e Delete handlers/payments/kassai/kassai.py 2025-09-16 08:58:50 +10:00
Boris Kovalskii fdda700a46 Delete handlers/payments/heleket/heleket.py 2025-09-16 08:58:33 +10:00
Boris Kovalskii 06b4dad525 FIX "Комментарии 2" 2025-09-15 22:57:00 +00:00
Boris Kovalskii 4cd74b058d FIX "Приведение Касс к Стандарту бота"
Heleket и KassaAI
2025-09-15 22:53:48 +00:00
Boris Kovalskii 82d07736a1 FIX "Добавление быстрого флоу для кастомных касс" 2025-09-15 22:13:57 +00:00
Boris Kovalskii 9eb56101be FIX "Вернул роутер" 2025-09-15 21:53:14 +00:00
Boris Kovalskii 7737b253e5 FIX "Комментарии" 2025-09-15 21:46:42 +00:00
Boris Kovalskii 2a8ec56fc4 FIX "Двойное предложение выбрать Heleket"
Убрал один роутер, и выход сразу на страницу пополнения баланса
2025-09-15 21:45:06 +00:00
Boris Kovalskii f4633e82b0 FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:43:10 +00:00
Boris Kovalskii 10214cae85 FIX "Наценки на платежи в валюте в процентах"
Для работы с процентами мы должны делить на 100
2025-09-15 21:39:07 +00:00
Vladless 5246e3609a currency/fast flow for everyone/optimization and cache/new payment system/and more 2025-09-15 21:55:31 +03:00
Vladless 31b615be3e Update cli for backup after update 2025-09-11 22:38:47 +03:00
Capybara-z 49aeb918dd Fix traffic display on iPhones in subscription 2025-09-10 11:33:20 +03:00
Capybara-z 3cbb6a904d Fix traffic notifications timing and remove async hooks from gifts/coupons 2025-09-05 21:59:47 +03:00
Capybara-z 1b4f1fb192 add hooks integration for coupons and gifts keyboard 2025-09-05 18:06:00 +03:00
Vladless 5a55324b2e add hooks for about_menu 2025-09-04 10:54:20 +03:00
Vladless d4a8f0921e bot version 2025-09-03 23:15:39 +03:00
Capybara-z 7b54fcf3ad auto-initialize tariff weights on bot startup 2025-09-03 00:36:11 +03:00
Vladless 9be986d627 bug fixes: silent_mode/ cash register indempotence/ antispam for key_country/ ban improvements 2025-09-02 23:38:52 +03:00
Capybara-z cc54baa4ca Fix device limit handling in subscription renewal 2025-09-01 21:38:19 +03:00
Capybara-z 93387c81d0 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 2cb66f52c8 add hook for user_info in admin-panel 2025-08-29 11:57:11 +03:00
Capybara-z bb3775be4f fix hooks 2025-08-29 10:35:12 +03:00
Capybara-z e72bcde45b fix text hot lead 2025-08-29 00:33:09 +03:00
Capybara-z 41b7423dc9 Improved hot lead logic 2025-08-29 00:06:47 +03:00
Capybara-z a54310da27 Removed tblocker / Fixed trial subscriptions counting 2025-08-26 18:26:35 +03:00
Vladless 8e55225724 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-25 02:10:03 +03:00
Vladless feae5d3162 add support for franchise/ minor fixes 2025-08-25 02:08:18 +03:00
Vladislav Lisitsyn 3b0090d1ee Merge pull request #235 from TrackLine/dev
fix hook:view_key_menu
2025-08-24 21:50:27 +03:00
Vladislav Lisitsyn 60c6cb4c5f Merge pull request #236 from myr1001/dev
fix notifications for notifications module
2025-08-24 21:50:04 +03:00
Capybara-z 934fb3b325 Expanding the work of hooks 2025-08-24 00:13:40 +03:00
Capybara-z 8e68502b2b fix instructions 2025-08-23 14:18:35 +03:00
myr1001 6df6a21446 Update kassai_payment.py
fix notofications for notifications module
2025-08-22 15:19:39 +03:00
myr1001 a52c629480 Update wata_payment.py
fix notifications for notifications module
2025-08-22 15:18:42 +03:00
Capybara-z ab8efe8529 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 d8ff76b64d fix hook:view_key_menu 2025-08-20 23:11:28 +04:00
Vladless a5a4e16990 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-08-19 01:02:24 +03:00
Vladless 09468ba5a9 fixed auto-installation of dependencies 2025-08-18 23:45:55 +03:00
Capybara-z 857aef1c4c Fix hot leads logic / Add tariff sorting functionality 2025-08-18 21:50:02 +03:00
Vladless 313cc97861 minor fixes: utm_api/donations and module payments/users info in admin 2025-08-18 15:55:45 +03:00
Vladislav Lisitsyn 4b58ddc639 Merge pull request #234 from Vladless/feat/callback-answer
Callback answer middleware
2025-08-18 15:55:24 +03:00
Capybara-z 56e5f6824e add user message hook system / fix SSL certificate validation 2025-08-15 15:44:07 +03:00
Vladislav Lisitsyn 3ac36f39d7 Merge pull request #233 from TrackLine/dev
Add hooks in start menu
2025-08-14 22:05:21 +03:00
Max Shalenkov dc5453c100 remove comment 2025-08-14 23:03:30 +04:00
Max Shalenkov ad0997a19d add hooks in start menu 2025-08-14 23:01:24 +04:00
Capybara-z 027d5cc0cf fix ssl check 2025-08-13 20:43:29 +03:00
Vladislav Lisitsyn d7edddcaf4 Merge pull request #232 from TrackLine/dev
Add new hooks for modules
2025-08-13 14:59:41 +03:00
Max Shalenkov dd8154c58b fix imports 2025-08-13 15:58:45 +04:00
Max Shalenkov b4a99c6c3f add delete hook button; add hook for periodic notifications 2025-08-13 15:52:36 +04:00
Vladislav Lisitsyn 4cad021b0b Merge pull request #231 from TrackLine/dev
add hook in key_view
2025-08-12 21:52:59 +03:00
Max Shalenkov e3546703d9 add hook in key_view 2025-08-12 22:51:26 +04:00
Vladless 9bfea3f480 Tribute/ Robokassa 2025-08-12 01:19:36 +03:00
hteppl 97aab0e265 Fix topups was None in admin panel if payments doesn't exist 2025-08-11 08:56:21 +03:00
Vladless d5048fde38 robokassa nomenclature/ minor fixes for API 2025-08-10 23:06:26 +03:00
Vladless e90e7dbe5f fix gifts country_mode tariffs 2025-08-07 17:41:36 +03:00
Vladless 9d8fa33ce8 fix gifts/custom amount for wata/change domain 2025-08-07 17:39:25 +03:00
Capybara-z 9d1760c7ca Add support for streaming modules / Add mailing for trial subscriptions 2025-08-06 23:07:27 +03:00
Capybara-z a833b11b8e Add webhook support for modules / Implement hooks for payment keyboard 2025-08-04 20:31:28 +03:00
Vladless 35ef17660c silent mode/ wata flow/ api tracking/ refactoring 2025-08-02 21:13:50 +03:00
Vladless 2d52fd1409 minor fixes for gifts_menu 2025-08-02 01:20:46 +03:00
Vladislav Lisitsyn c87f3915ca Merge pull request #230 from TrackLine/dev
fix rates for wata payments
2025-08-01 23:02:31 +03:00
Vladless 5c45aced3f fix delete modules 2025-08-01 22:41:23 +03:00
Capybara-z e696ee08a5 Location of module buttons 2025-08-01 22:37:37 +03:00
Vladless 9fa6d20a73 the idempotence fix 2025-08-01 22:22:09 +03:00
Vladless 7c16491548 module architecture/ delete tg_id in gifts 2025-08-01 22:16:26 +03:00
Max Shalenkov 618291a466 fix wata 2025-08-01 19:57:35 +04:00
Max Shalenkov 221b797dad fix rates for wata payments 2025-08-01 19:56:13 +04:00
Capybara-z 99759c3326 fix for days accrual in cluster 2025-08-01 15:19:26 +03:00
Vladless 530e63d70c module architecture/ moderator restrictions 2025-08-01 01:56:56 +03:00
Vladless 1565ec4820 module architecture/ formatting 2025-07-31 23:42:45 +03:00
Vladless de61c04f4a support for remnawave api 2.x 2025-07-30 20:02:40 +03:00
Vladless ca96ffc9d9 minor fixes 2025-07-30 09:49:56 +03:00
Vladislav Lisitsyn 33670143b7 Merge pull request #228 from JustYay/dev
minor fix KassaAI
2025-07-30 09:45:46 +03:00
Boris Kovalskii 3dbbc804c9 minor fix KassaAI 2025-07-30 16:43:55 +10:00
Vladless a644316349 minor fixes 2025-07-30 09:23:28 +03:00
Vladless 957d56b32d zero-day fixes 2025-07-30 06:34:27 +03:00
Oleg Kraev 9da72b2575 Merge pull request #226 from Vladless/feature/more-user-info
More user info
2025-07-30 00:27:16 +03:00
hteppl 7ed533413c Implement users topups amount in admin user profile 2025-07-30 00:25:00 +03:00
hteppl 1873284771 Remove uv.lock due to project currently is not supporting uv 2025-07-29 22:50:06 +03:00
Capybara-z 27140d2f95 refactor: migrate trial subscription to database-driven tariffs 2025-07-29 20:50:43 +03:00
Vladislav Lisitsyn 8dce80b3a5 Update README.md 2025-07-29 19:53:06 +03:00
Vladless cca97f697a formatting 2025-07-29 19:45:13 +03:00
Vladless e269dc64c2 up bot version 2025-07-29 19:34:49 +03:00
Vladless 39380d503a antispam for create_key 2025-07-29 17:17:15 +03:00
Vladless 978e22cf1f fix gifts tariffs and format days 2025-07-28 20:33:50 +03:00
Capybara-z 356061f7ef 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 b76da13a9b Merge pull request #223 from JustYay/dev
Heleket, Wata, KassaAI fix
2025-07-26 13:50:46 +03:00
Boris Kovalskii 9cc9ce4856 Heleket, Wata, KassaAI fix
Fix, when the payment is lower, than acceptable sum by merchant
2025-07-26 17:03:42 +10:00
Vladless 94f818ddd2 stars bot url for texts 2025-07-26 00:22:16 +03:00
Vladless cad9c50c80 support invite links/ shadow bans/ bulk preemptive bans 2025-07-25 05:40:45 +03:00
Vladless a90c607dab fix yoomoney router 2025-07-24 23:40:22 +03:00
Vladislav Lisitsyn dd93d201f0 Merge pull request #222 from JustYay/dev
Crypto-payment via Heleket
2025-07-24 23:31:46 +03:00
Boris Kovalskii 481eda1e4a Crypto payment via Heleket 2025-07-24 23:14:36 +10:00
Boris Kovalskii 8d1f2c1d63 we dont send new message 2025-07-24 20:25:01 +10:00
Boris Kovalskii ad21e7e697 Force text fix 2025-07-24 20:16:08 +10:00
Capybara-z 07d8be2cb1 Merge pull request #220 from JustYay/dev
Fix paymentid and success and fail urls
2025-07-24 13:03:43 +03:00
Boris Kovalskii 7016fe6bd4 Fix for old users 2025-07-24 20:01:55 +10:00
Boris Kovalskii 5ddeca9f27 Fix paymentid and success and fail urls 2025-07-24 19:10:14 +10:00
Vladislav Lisitsyn 2b610ca866 Merge pull request #219 from JustYay/dev
Back callback fixes
2025-07-24 07:27:56 +03:00
Boris Kovalskii 730c245163 Security bug fix
Fix duplicate webhook accept
2025-07-24 13:53:33 +10:00
Boris Kovalskii 077806eb80 Custom amount and deletion fixes 2025-07-24 13:34:07 +10:00
Boris Kovalskii 18df153ee0 Small fixes 2025-07-24 13:27:28 +10:00
Boris Kovalskii e209bb5ace Clear code 2025-07-24 13:24:21 +10:00
Boris Kovalskii b32a7ac20b 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 3647e52475 Back callback fixes
Убрано лишнее меню из касс, при возвращении, сейчас возвращает сразу на вкладку balance
2025-07-24 12:53:49 +10:00
Vladislav Lisitsyn 9b3f176179 Merge pull request #218 from JustYay/dev
Kassa AI
2025-07-24 02:58:53 +03:00
Boris Kovalskii ed8aafe33a Clear code 2025-07-24 09:42:23 +10:00
Boris Kovalskii 5b1c827413 Kassa AI 2025-07-24 03:10:15 +10:00
Vladless 688bbe04bd save alias after update_sub/db pool 2025-07-21 23:03:03 +03:00
Vladless 5828132be0 minor fixes for install dependencies 2025-07-21 19:38:59 +03:00
Capybara-z 7a677e48ff Fixed key display in admin panel / Fixed earnings calculation in statistics 2025-07-21 19:29:48 +03:00
Capybara-z 47b22623fa fix: device limit transmission in admin key creation 2025-07-20 20:17:21 +03:00
Vladless 319e34fb10 Animation of the CLI 2025-07-20 06:34:58 +03:00
Vladless 8670a21624 CLI 0.2.7/ fixed permissions 2025-07-20 03:18:51 +03:00
Vladless 142ef03813 solobot command for different copies/fixes command installation errors 2025-07-20 03:05:04 +03:00
Vladless 0e8bfd46ad importer 3x-ui/CLI update/upload file/clearing logs 2025-07-20 02:12:26 +03:00
Capybara-z f3be55e037 feat: add update time and refresh button to ads cards 2025-07-20 00:02:53 +03:00
Capybara-z 1bc3e544ed fix: tariff selection in admin panel and key operation 2025-07-19 23:16:46 +03:00
Capybara-z 842cd14e78 fix: key traffic display 2025-07-19 18:16:02 +03:00
Vladless a7f150e1b9 bot version up 2025-07-19 01:29:09 +03:00
Vladless 04450e9c83 formatting/3x-ui db import/cosmetic fixes 2025-07-19 01:14:42 +03:00
Capybara-z 81f6227754 Fix statistics admin 2025-07-19 00:52:20 +03:00
Vladless d3998fe379 fix state in middleware subs 2025-07-18 20:52:56 +03:00
Vladless f136ce322e fix sub channel 2025-07-18 20:46:02 +03:00
Vladless b16072d6e0 api 0.2.0/ minor fixes 2025-07-18 20:13:39 +03:00
Capybara-z 8354aac254 fix: broadcast callbacks and button parsing 2025-07-18 16:05:48 +03:00
Vladless 604ea7f706 stars bot link/ defolt hwid limit 0 2025-07-18 04:32:09 +03:00
Vladless b44302deb3 fix time and text/ cosmetic fixes/ description of the payment/ vat_code 2025-07-18 02:07:30 +03:00
Vladislav Lisitsyn 16cf8bb1d5 Merge pull request #217 from TrackLine/dev
Add wata.pro payments
2025-07-17 20:45:20 +03:00
Max Shalenkov a8bc620989 remove comments 2025-07-16 22:49:58 +00:00
Max Shalenkov 01e3a8f456 final fix wata.pro 2025-07-16 22:22:40 +00:00
Max Shalenkov e2ad136dd7 fix wata 2025-07-16 23:59:33 +03:00
Max Shalenkov 36e1541af1 Merge branch 'Vladless:dev' into dev 2025-07-16 23:17:46 +03:00
Vladless 54763cd18f add change domain for remnawave link 2025-07-16 22:08:20 +03:00
Max Shalenkov 6a0f4f8509 change payments 2025-07-16 19:49:49 +03:00
Capybara-z dd4a28edbb fix Improve server limits and disabled servers handling 2025-07-14 22:21:04 +03:00
Capybara-z a02a17838e Improve server limits and disabled servers handling 2025-07-13 00:52:49 +03:00
Capybara-z 7caee34147 Show all inbound nodes with country flags in Remnawave 2025-07-12 02:24:29 +03:00
Capybara-z f08d53362b Fix admin management 2025-07-11 19:27:36 +03:00
Capybara-z 537fe1b480 Improve cluster tariff error handling / Fix hot leads filtering logic 2025-07-11 14:15:04 +03:00
Capybara-z e1e85de286 fix permanent ban functionality / fix statistics timezone consistency 2025-07-10 14:13:20 +03:00
Capybara-z c3eb68f325 Add keyboard support for sending messages 2025-07-09 22:11:40 +03:00
Capybara-z b324df29bd Fix Gifts API from JustYay
Fix Gifts API
2025-07-04 17:44:36 +03:00
Capybara-z 11ab7b5fb6 Improve bot version update message 2025-07-04 17:41:11 +03:00
Capybara-z 38eeeab300 Add subscription purchase command/Add subscription renewal command/Improve bot version update message 2025-07-04 17:34:21 +03:00
Boris Kovalskii c3c5ba7f73 Fix Gifts API 2025-07-04 17:19:06 +10:00
Capybara-z 7e256ad380 update version 2025-07-03 19:57:29 +03:00
Capybara-z 9dd99c5f97 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 5d6e9b418e 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 df57d71b4c Fix SSL certificate validation / Fix tariff sorting for subscription renewals 2025-06-26 00:21:31 +03:00
Vladless 9b51324c80 expiry time for update key in api 2025-06-24 00:35:00 +03:00
Vladless 6920a3f845 add update key for api 2025-06-23 23:18:09 +03:00
Vladless 6a4fafbdf0 add verify_admin_token 2025-06-23 22:10:05 +03:00
Capybara-z fc0079770d tariff subgroups use hash callbacks / only ungrouped tariffs when creating / add subgroup management 2025-06-23 20:57:07 +03:00
Capybara-z c151d8c66d Add image for balance/payment menu 2025-06-23 00:52:31 +03:00
Vladless eb0aa3ca37 add get_all_by_field method 2025-06-22 21:10:56 +03:00
Vladless 11c9a0b61e fix router for freekassa 2025-06-22 15:12:43 +03:00
Capybara-z 2aed629daa Improve trial period display in referral offers 2025-06-22 15:09:52 +03:00
Vladless 7e129e534e Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-22 08:36:02 +03:00
Vladless 4e0f7fb2ee add freekassa_pay 2025-06-22 08:35:17 +03:00
Capybara-z b720680af0 fix: renew key flow with subgroups 2025-06-21 21:52:25 +03:00
Vladless f261d5647a fix sub/captcha for gifts 2025-06-21 19:54:39 +03:00
Capybara-z 4664e968b5 fix: butons trial profil 2025-06-20 22:38:20 +03:00
Capybara-z e047306190 fix: renew proper key selection 2025-06-20 16:54:13 +03:00
Capybara-z ca156c1271 fix: add error handling for HWID 2025-06-20 00:52:08 +03:00
Capybara-z 0c5208b7fa fix: trial menu logic / feat: admin cluster pagination 2025-06-20 00:48:06 +03:00
Vladless 4359c36444 subgroups for gifts in admin-panel/bug fix 2025-06-19 18:12:09 +03:00
Vladless 84afe54894 add requirements for setup bots 2025-06-19 06:58:21 +03:00
Vladless 30d8ef4bee Sync Remnawave panel / import users & keys / restore DB / rename buttons / UI tweaks 2025-06-19 05:09:29 +03:00
Capybara-z 0dd6dcda65 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 f81fc74f17 Fix balance buttons 2025-06-18 19:03:25 +03:00
Capybara-z 6c47e3751d Fix renewal buttons in notifications 2025-06-18 18:48:44 +03:00
Capybara-z ebc6a510b4 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 2b2ba6b647 add api routes 2025-06-16 17:20:49 +03:00
Vladless 41fdd111a7 add subgroups for gifts 2025-06-16 00:09:59 +03:00
Capybara-z 056a1ac07b 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 aa74e6fccb minor fixes 2025-06-15 22:58:44 +03:00
Vladless 1ffaf84f5f add uvicorn for FastApi 2025-06-14 22:28:21 +03:00
Vladless e06b0ef1bc API/ remnawave 1.6.12/ admins in database and more 2025-06-14 21:16:53 +03:00
Vladless cebdeb78fe minor improvements to UTM tracking accuracy 2025-06-14 07:01:42 +03:00
Vladless 40ccd8060a make get_git_commit_number work under systemd 2025-06-14 06:30:09 +03:00
Vladless 902a051dc1 CLI 0.2.0/git number for admins 2025-06-14 05:13:20 +03:00
Vladislav Lisitsyn 973436167b Update bot.py 2025-06-14 04:22:16 +03:00
Capybara-z aaea5b4dbe fix: traffic reset / coupon menu display 2025-06-14 03:45:47 +03:00
Vladless 0e43ad9694 subgroups in tariffs 2025-06-14 01:58:29 +03:00
Capybara-z 8b858fbb43 fix: tariff name display 2025-06-14 01:01:45 +03:00
Capybara-z 40982f1ca1 fix: sync delays / tariff name display 2025-06-14 00:56:22 +03:00
Capybara-z 1bdbac6f2d fix: correct version check to compare against current branch 2025-06-13 22:54:25 +03:00
Capybara-z ef6b347bf5 add subscription count display for clusters and servers 2025-06-13 21:03:47 +03:00
Vladless 33b31a411c add payment class for yookassa 2025-06-13 14:32:33 +03:00
Vladless 9a5b41911d add payment class for yookassa 2025-06-13 14:31:10 +03:00
Capybara-z 2526e7c515 fix the back button in key reissue 2025-06-13 14:06:19 +03:00
Vladless 181b141c15 fix custom amount for robokassa 2025-06-13 12:09:49 +03:00
Vladless 85d26f2be7 id sorted for renew plans and rm cashback in statistic 2025-06-13 08:58:18 +03:00
Vladless 47f8654adc fix referrals and tariff name in key view 2025-06-13 08:05:18 +03:00
Vladless 5b2e2557c4 add idempotency check to prevent duplicate payments 2025-06-13 07:55:12 +03:00
Vladless e20a94617c add idempotency check to prevent duplicate payments 2025-06-13 04:00:05 +03:00
Capybara-z 8b97da6082 add full server management with cluster transfer support 2025-06-12 23:47:18 +03:00
Vladless b76b524183 gifts for more users in admin-panel 2025-06-11 23:09:23 +03:00
Vladless e93d7e0baf CLI 0.1.9/SSL check for servers 2025-06-11 01:32:41 +03:00
Vladless d97ca64ab5 fix alembic migrations 2025-06-10 16:05:57 +03:00
Vladislav Lisitsyn 6d53bf5e4d Merge branch 'main' into dev 2025-06-09 22:45:13 +03:00
Vladislav Lisitsyn 9350cab0bf Update README.md 2025-06-09 22:43:02 +03:00
Vladless 42f858e1b4 daily reports and minor fixes 2025-06-09 21:41:30 +03:00
Vladless 5fdeee6b0f fix permissions for delete tables 2025-06-09 18:43:45 +03:00
Capybara-z 64c40ae749 fix: android tv connection for remnawave 2025-06-05 19:16:05 +03:00
Capybara-z f267103a5e Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-05 01:15:21 +03:00
Capybara-z 3eb760a972 add: android tv connection for remnawave 2025-06-05 01:14:20 +03:00
Vladless 029e7d027b Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-06-04 14:58:23 +03:00
Vladless 66489c55e8 fix custom amount input for stars pay 2025-06-04 14:58:00 +03:00
Capybara-z c19a6afda9 fix remnawave sync logic
- Исправлена логика синхронизации Remnawave, если обновлять нечего пересоздаем.
2025-06-04 00:52:22 +03:00
Vladless 3891eff224 sort tariffs by ID 2025-06-03 03:37:25 +03:00
Vladless e974ab4093 use update instead of recreate for Remnawave-only clusters 2025-06-03 02:40:56 +03:00
Vladless a6b4420708 fix UTC daily report and back button for bans 2025-06-03 00:35:20 +03:00
Vladless f62dbaa766 add cryptobot description 2025-06-02 20:41:58 +03:00
Vladless b0d6c3fe4c fix cashback in stats/back tariff/disable cluster 2025-06-02 20:11:02 +03:00
Capybara-z f64c6b401a hide device count for unlimited tariffs 2025-06-01 02:30:46 +03:00
Capybara-z b59434332d format tariff duration in messages / format delete delay message for expired subscriptions 2025-06-01 01:30:55 +03:00
Vladless 74f96f568f Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-31 20:44:16 +03:00
Vladless cbcc4b65c5 up bot version 2025-05-31 20:44:04 +03:00
Capybara-z 7cd99073d0 group tariffs and sort by duration 2025-05-31 18:08:52 +03:00
Capybara-z 8a9a48fed0 group tariffs and sort by duration 2025-05-31 16:48:40 +03:00
Vladless fd6bfcea0b delete cuopons stats for utm 2025-05-31 15:54:29 +03:00
Vladless 040f3f1cda fix remna traffic for create_key 2025-05-30 22:12:16 +03:00
Capybara-z 2e02629cbb fix: trial key display / show correct trial days / fix trial limits 2025-05-30 19:07:27 +03:00
Capybara-z 63e789d112 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 6802605331 rebase subs for country_mode 2025-05-30 16:54:44 +03:00
Capybara-z 64cadac2f6 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 1ab33e9dc8 Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-29 14:38:53 +03:00
Vladless 0f4ee2a57c Preserve images inside the img/ folder during updates 2025-05-29 14:37:22 +03:00
Capybara-z 78f292ac86 Fix auto-renewal logic to properly select tariff during renewal 2025-05-29 00:03:04 +03:00
Vladless 40758a335e restore hot leads export and stats section 2025-05-28 22:49:59 +03:00
Vladless 2d580db31f actual commit for admin-menu 2025-05-28 22:14:07 +03:00
Vladless 30bb953dcd CLI 0.1.8/update tariff menu 2025-05-28 20:48:11 +03:00
Vladless 8e88316efd fix add time in admins 2025-05-28 19:10:46 +03:00
Capybara-z 723474e150 fix: key_utils.py 2025-05-28 01:18:58 +03:00
Capybara-z ffb6d5f99e Merge branch 'dev' of fix: key management in admin menu / fixed synchronization 2025-05-28 01:08:16 +03:00
Capybara-z 03487342f7 fix: key management in admin menu / fixed synchronization 2025-05-28 00:44:26 +03:00
Capybara-z dcb101d19d tariff lookup for country model
- Определение тарифа для режима стран при продлении ключей.
2025-05-27 00:15:36 +03:00
Vladless d35cf5dd74 add commit number to bot version display 2025-05-26 23:11:04 +03:00
Vladless 942bdd8b61 add new payment flow for yoomoney/ add payment for refferals 2025-05-26 22:36:26 +03:00
Vladless b574275672 add support traffic for trial in cluster mode/country mode 2025-05-26 21:37:00 +03:00
Capybara-z 571a170c84 exclude referral and coupon payments from statistics 2025-05-26 19:07:19 +03:00
Capybara-z 4e5499a173 delete accrual statistics 2025-05-26 12:24:43 +03:00
Vladless 56155435ab Merge branch 'dev' of https://github.com/Vladless/Solo_bot into dev 2025-05-25 23:56:02 +03:00
Vladless d33153247b add trial limits for create clusters_mode 2025-05-25 23:54:43 +03:00
Capybara-z fa60336c5a add to accrual statistics
- Добавлено логирование начислений купонов.
2025-05-25 23:38:31 +03:00
Capybara-z a70a0d25b2 add to accrual statistics
- Добавлено логирование начислений реферавлов.
2025-05-25 23:35:29 +03:00
Vladislav Lisitsyn c9d424364e 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 e5d709ff64 add round for balance and less callback for tariffs 2025-05-25 20:34:16 +03:00
Capybara-z 651e1c2d32 Fix key expiration notification and auto-renewal handling
- Исправлена обработка уведомлений об истечении ключей.
- Добавлена корректная работа с задержкой удаления ключей и авто-продлением подписок.
2025-05-25 19:47:35 +03:00
Vladless 64049ce9c2 cleanup_orphans_before_migrations 2025-05-25 13:13:18 +03:00
Vladless 44a64ee557 cleanup_orphans_before_migrations 2025-05-25 12:59:42 +03:00
Vladless f4e31690c1 cleanup_orphans_before_migrations 2025-05-25 12:49:38 +03:00
Vladless 48a579f506 cleanup_orphans_before_migrations 2025-05-25 12:36:40 +03:00
Vladless d427d20eb4 fix back button in view tariff groups 2025-05-24 23:41:25 +03:00
Vladless 984fc5dc76 fix key create for user in admin 2025-05-24 23:25:49 +03:00
Vladless b0be4f396f minor fixes 2025-05-24 22:32:15 +03:00
Vladless 2ea7471fba fix gift tariff name 2025-05-24 22:01:32 +03:00
Vladless ca5a4a3b90 session for renew_key 2025-05-24 21:53:15 +03:00
Vladless 611f9bfb14 trial user in check_notification 2025-05-24 21:31:53 +03:00
Vladless a01e6ee48e session in process_callback_view_profile 2025-05-24 20:49:19 +03:00
Vladless 5571652a9a ORM update and lots of improvements 2025-05-24 19:30:23 +03:00
Vladless 8dc01f2643 key column null for table keys 2025-05-20 23:53:49 +03:00
Vladless 3af3fa2c66 sorted tariffs by id for renew key 2025-05-20 18:23:46 +03:00
Vladless 819c814c95 delete tarif_group for servers 2025-05-20 16:12:41 +03:00
Vladless f275683be1 convert balance column 2025-05-20 13:21:31 +03:00
Vladless 354ba30168 group by id 2025-05-20 13:04:37 +03:00
Vladless 9fd47594fc fix add_notification for blocked users 2025-05-20 12:35:54 +03:00
Vladless 6586940158 balance numeric in database 2025-05-20 09:06:47 +03:00
Vladless 637751f554 sudo python3 main.py 2025-05-20 08:55:18 +03:00
Vladless fe7183334e add tariff_group in get_servers 2025-05-20 08:45:17 +03:00
Vladless ab47a4f2cd fix delete tariff 2025-05-19 17:15:38 +03:00
Vladless 05a16c8497 add group_code 2025-05-19 14:17:35 +03:00
Vladless 73830f448e minor improvements 2025-05-19 11:08:51 +03:00
Vladless 5a0458a1ee discounts tariffs/notify hot leads/change success payment 2025-05-19 09:07:32 +03:00
Vladless 516d02dcc7 bot version 2025-05-19 06:03:36 +03:00
Vladless 5f792dc1f5 tariff id in callback 2025-05-19 06:03:03 +03:00
Vladless 0aed149404 statistic tariffs/database logs 2025-05-19 05:41:19 +03:00
Vladless 91df7c5466 ban middleware/ ban users 2025-05-19 03:59:26 +03:00
Vladless 84a6fb46b6 add daily project report 2025-05-18 00:44:17 +03:00
Vladless 99effd8878 tariffs view in cluster menu 2025-05-18 00:27:39 +03:00
Vladless 24f1941e45 tblocker router 2025-05-17 20:54:23 +03:00
Vladless d9e0f46b98 tariff_id for payments 2025-05-17 20:44:43 +03:00
Vladless de13bd7664 data formatted in renew 2025-05-16 20:27:48 +03:00
Vladless 129dd8ced8 fix gifts user_data 2025-05-16 18:37:41 +03:00
Vladless bf460e6a43 tariffs order by id 2025-05-16 15:46:12 +03:00
Vladless be69447178 ip limit for tariffs 2025-05-16 10:06:26 +03:00
Vladless f6a2d05b93 add tariffs stats 2025-05-16 08:51:43 +03:00
Vladless fd0340b1b2 add device_limit in tariffs 2025-05-16 07:09:18 +03:00
Vladless 3fd66d580b tariff customization/traffic for each tariff/ruff 2025-05-16 06:02:18 +03:00
Vladless b18a2f81c0 add alias for key menu in admin-panel 2025-05-13 06:45:56 +03:00
Vladless fd56e45532 add more hwid stats 2025-05-13 04:36:06 +03:00
Vladless 4931e5671a add more stats 2025-05-13 04:32:36 +03:00
Vladless 3757bb6082 hwid management in admin-panel and key menu 2025-05-12 23:16:56 +03:00
Vladislav Lisitsyn 2196bb53a0 Merge pull request #205 from Capybara-z/patch-7
Add tblocker blocking notifications
2025-05-12 23:04:28 +03:00
Capybara-z 5929dc4c8f Add tblocker blocking notifications
- Логика обработчки оповещений на вебхук от tblocker
2025-05-12 22:12:01 +03:00
Capybara-z 23234460d5 add tblocker webhook handler
- Добавлен обработчик вебхука tblocker
2025-05-12 22:05:36 +03:00
Oleg Kraev 0832a5ca72 Merge pull request #204 from Capybara-z/patch-6
add sub info
2025-05-12 02:46:05 +03:00
Vladless 197a7a836c 4.3-beta 2025-05-11 19:23:27 +03:00
Vladless bf1c3bbd6c add support HWID 2025-05-11 16:55:19 +03:00
Oleg Kraev c4a7978f65 Update README.md 2025-05-11 16:53:22 +03:00
Capybara-z 8a81efdd3a add sub info
- добавил хедер v2raytun для отображения травика и даты завершения подписки.
2025-05-11 16:14:35 +03:00
Vladless e4aa757b6c add support remnawave api 1.6.x 2025-05-11 16:12:51 +03:00
Vladless 3434f0c589 back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless 066fd40767 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 589ece52b0 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 186e961165 Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn a8379b0e6a Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z bc6325bc1d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 0a96757ccd fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 9f045d1f00 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn b3c770e94a Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 780135922c add state.clear 2025-05-09 20:35:50 +03:00
Vladless 3186367ad9 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 18fbb710dd ruff formatting 2025-05-09 01:34:11 +03:00
Vladless df33fed0b0 set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless 8bd4d2ea78 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 65f821a356 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 837acb35b4 oops 2025-05-08 17:19:00 +03:00
hteppl e2b22d131b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 2f33da4b4a Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn df4ecc7630 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn 4c742c21ac Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn 9eb450cbef Update README.md 2025-05-08 02:36:49 +03:00
Vladless 4dd7b7c551 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 4946a972c1 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn e6157cedfe Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z 8916c13c15 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless 427f9743a7 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn c78b0502da Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z d87e897a56 fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn b027d329dd Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z 811f7276c8 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z e083f2e4a9 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z b5d51d6967 Update notification 2025-05-05 21:39:20 +03:00
Capybara-z fa3307a6f6 Update notification 2025-05-05 21:38:11 +03:00
Oleg Kraev ac715622ba Bump bot version to 4.2 2025-05-02 02:39:30 +03:00
Vladless 3d85a1cf56 Merge branch 'dev' 2025-05-01 21:26:14 +03:00
Vladless c3e4c911c8 merdge 2025-05-01 21:24:20 +03:00
Vladislav Lisitsyn 9cf30b3953 Merge pull request #195 from Capybara-z/patch-6
Fix for adding banned users
2025-05-01 21:08:17 +03:00
Capybara-z 4c377a9768 Fix for adding banned users
- Вернул логику добавления забанных юзеров на уведолмения связанных  с триалом.
2025-05-01 21:05:02 +03:00
Vladislav Lisitsyn 6b17b0dc6d Merge pull request #194 from Vladless/dev
v4.2
2025-05-01 20:44:21 +03:00
Vladislav Lisitsyn ab0a128375 Merge branch 'main' into dev 2025-05-01 20:44:10 +03:00
Vladislav Lisitsyn 8cccb1db88 Merge pull request #193 from Capybara-z/patch-5
fix name month
2025-05-01 20:38:06 +03:00
Capybara-z 16d09cbe22 fix name month
- Отображение месяцев на русском.
2025-05-01 19:16:05 +03:00
Capybara-z 787f8e638f fix name month
- Отображение месяцев на русском.
2025-05-01 19:09:49 +03:00
Vladless 73b2785884 reset traffic 2025-04-30 17:10:52 +03:00
Vladless 4268bed784 update_cli_launcher for beta/release 2025-04-29 14:02:47 +03:00
Vladless 8a57b3ea2f fix show start menu once 2025-04-29 14:00:42 +03:00
Vladless d16997dff6 CLI version 0.1.7 2025-04-27 20:55:19 +03:00
Vladless 3ffac1269f update version 2025-04-26 23:30:24 +03:00
Vladless 2742cc2ce4 get gift button/api/minor fixes 2025-04-26 23:25:48 +03:00
Vladless e1f5ee0e82 add format day in csv/maintenance mode 2025-04-26 11:14:13 +03:00
Vladless 422ce15dca ruff formatting 2025-04-24 23:20:18 +03:00
Vladless 37d34e32df scalar total_gb 2025-04-24 22:42:49 +03:00
Vladislav Lisitsyn 2655ccaa93 Update README.md 2025-04-23 22:29:54 +03:00
Vladless 644d7cc812 cancel for utm/disable servers for change location/remnawave traffic 2025-04-23 22:27:53 +03:00
Vladless fd4025e51f fix gift strip in start logic 2025-04-22 19:50:07 +03:00
Vladless a37582dcb0 update user middleware 2025-04-22 19:21:15 +03:00
Vladless 46096f89d5 update table for utm 2025-04-22 00:41:34 +03:00
Vladless 9a174051c2 update version 2025-04-21 22:04:07 +03:00
Vladless 0f817920da refactoring/utm/fix minor bugs 2025-04-21 21:53:13 +03:00
Vladless bc1981ff03 CLI version 0.1.4 2025-04-21 03:37:27 +03:00
Vladless 82a0dd1c86 CLI version 0.1.4 2025-04-21 03:34:44 +03:00
Vladless aa7832fb0d hot_leads_sender 2025-04-21 02:18:41 +03:00
Vladless e10bf92101 rm old logic 2025-04-21 01:46:19 +03:00
Vladless cc3ce3a8bb refactoring/notify_optimization/least_loaded_cluster for key_limit/rm args 2025-04-21 00:22:14 +03:00
Vladislav Lisitsyn ef29a8e822 Merge pull request #192 from Capybara-z/patch-4
Fix for extension
2025-04-19 22:18:23 +03:00
Vladislav Lisitsyn bc5d466bc9 Merge pull request #191 from Capybara-z/patch-3
v2raytun agent processing
2025-04-19 22:18:09 +03:00
Capybara-z 1eb79bec6d Fix for extension 2025-04-19 21:46:04 +03:00
Capybara-z 8dcab16f5a v2raytun agent processing
- Добавлена обратотка агента v2raytun
2025-04-19 20:13:00 +03:00
Vladislav Lisitsyn 2dbf1ca508 Merge pull request #190 from Capybara-z/patch-2 2025-04-19 17:49:58 +03:00
Capybara-z 26411ef982 fix coupons/formatted day 2025-04-19 17:24:05 +03:00
Vladless 9ae5cf2660 update_button on/off 2025-04-18 15:32:07 +03:00
Vladless adb8e56303 beta version 2025-04-18 05:01:47 +03:00
Vladless 16cac60af8 fix get_keys 2025-04-18 03:51:59 +03:00
Vladless 236346da75 add column in table servers 2025-04-18 01:34:00 +03:00
Vladless dcbf121157 remove table connections/token and cookies 3x-ui/bug fixes 2025-04-18 00:10:51 +03:00
Vladless 284412ecc5 limit_key on servers 2025-04-17 16:38:39 +03:00
Vladless 45fd8fe221 on/off server in cluster 2025-04-17 15:02:54 +03:00
Vladless 6be48eb828 fix get_online in remnawave 2025-04-16 23:20:28 +03:00
Vladless e0212866b4 remnawave in country_mode/save shortid/bug fixes 2025-04-16 22:50:19 +03:00
Vladislav Lisitsyn 446b15ec08 Merge pull request #188 from Capybara-z/patch-1
Fix time declination
2025-04-16 20:09:17 +03:00
Capybara-z c794f3dcda Adding time declination
- Добавил новые склонения
2025-04-15 01:26:17 +03:00
Capybara-z 889af1df40 Fix time declination
-Склоение времени.
2025-04-15 01:25:48 +03:00
Capybara-z 86c28d0938 Fix time declination
-Склонение времени
2025-04-15 01:24:36 +03:00
Vladless 1246511af3 add create_key in country_mode/fix traffic in remnawave 2025-04-15 00:12:56 +03:00
Vladless eaf3ea0b98 pic.jpg in other menu 2025-04-14 01:08:58 +03:00
Vladislav Lisitsyn 75d50f890b Merge pull request #186 from Capybara-z/patch-13
fix trail_time
2025-04-14 01:08:40 +03:00
Capybara-z dcc64ad945 fix trail_time
- Адаптироавл вывод в инлайн моде, склонение дней.
2025-04-13 22:29:19 +03:00
Vladislav Lisitsyn 0a44c2da57 Merge pull request #185 from Capybara-z/patch-12
Fix admin button
2025-04-13 17:51:47 +03:00
Capybara-z 2c7356f0b9 Fix admin button
- Исправлено отображение кнопки админа при активации купона на деньги
2025-04-12 19:48:55 +03:00
Vladless c4895e21b4 CTRL-C and install venv in CLI 2025-04-11 22:52:13 +03:00
Vladless 5e72f9d416 gb_count/fix coupons 2025-04-11 22:40:38 +03:00
Vladless 299c4cca87 fix renew after sync 2025-04-11 17:34:47 +03:00
Vladless fe1d9bbfd7 add import renew 2025-04-10 15:02:46 +03:00
Vladless 7a27c942d8 add support for RemnaWave 2025-04-10 14:51:01 +03:00
hteppl b1f373aca3 Fix duplicated button text variables + typo (TARIFFS is unused) 2025-04-10 01:01:13 +03:00
Vladless c138011b5a Merge branch 'main' into dev 2025-04-09 23:31:23 +03:00
Vladless 0a8997d8e1 total_gb fix 2025-04-09 23:13:43 +03:00
Vladislav Lisitsyn 704381ab91 Update version 2025-04-09 20:03:46 +03:00
Vladless 2fe0de2a26 refactor: reorganize keys logic into modular files 2025-04-09 17:38:19 +03:00
Vladless 7024161591 exclude img 2025-04-09 16:08:59 +03:00
Vladless 3d398a3816 bash -c in cli-interface 2025-04-08 14:43:46 +03:00
Vladless 34210dc808 version in cli-interface 2025-04-08 14:34:16 +03:00
Vladislav Lisitsyn 706a003370 Merge pull request #184 from Capybara-z/patch-11
Fix for coupon activation
2025-04-07 02:17:43 +03:00
Capybara-z 1fbf2a8f8c Fix for coupon activation
- Корректная обработка активации купонов.
2025-04-07 02:04:01 +03:00
Capybara-z 822ada54e2 Fix for coupon activation
- Корректная передача состояний и текста в купоны.
2025-04-07 02:03:11 +03:00
Vladless 8840aa6d49 yoomoney webapp 2025-04-07 00:22:15 +03:00
Vladless 97b32699ab change callback in add_key 2025-04-06 21:44:09 +03:00
Vladless 2ce2816492 cli-interface 2025-04-06 21:19:33 +03:00
Vladless 305c3576e3 cli-interface 2025-04-06 21:15:59 +03:00
Vladislav Lisitsyn 10afee1194 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 e5c569ab13 Add server availability check and cluster-based country selection
- Добавил проверку доступности серверов перед отображением списка стран для выбора.
- Сделал пропуск удаления ключа при недоступности сервера, чтобы смена страны не останавливалась.
- Обеспечил вывод только рабочих серверов при создании ключа.
- Улучшил выбор стран, сделав его корректным согласно кластерам пользователя.
- Исправил пересоздание клиента, обновляя только server_id при смене страны.
2025-04-06 00:30:20 +03:00
Vladless 7d902900c3 add data_state 2025-04-05 20:48:13 +03:00
Vladless 0198622ddc on/off manual_instructions/fix send_photo 2025-04-05 02:16:24 +03:00
Vladislav Lisitsyn 6866e3c9e3 Merge pull request #180 from Capybara-z/patch-9
Фикс ошибок и создание ключей
2025-04-05 02:03:28 +03:00
Vladislav Lisitsyn d998d69b65 Merge pull request #179 from Capybara-z/patch-8
Исправление.
2025-04-05 02:02:43 +03:00
Capybara-z 3c3834b5c7 Перенос обратобтки состояний после создания ключа
- Перенос оплат и списания триала после создания ключа
2025-04-05 00:46:24 +03:00
Capybara-z 5cc92af46a Фикс ошибок сервера
- Исправлен сбор ошибок сервера
2025-04-05 00:44:45 +03:00
Capybara-z 195ad5fdce Исправление.
- Убрал лишнию проверку.
2025-04-04 23:38:14 +03:00
Vladislav Lisitsyn aee5bbcee4 Merge pull request #177 from Capybara-z/patch-6
Добавление активности юзера, мелкие правки
2025-04-03 22:31:54 +03:00
Capybara-z 252c072cec Добавление формулы склонения.
- Добалвение формулы склонения
- Адаптация клонение дней к новой формуле.
2025-04-03 22:17:19 +03:00
Capybara-z d4e4bbabe8 Корректировка текста
- Скорректировал текст, при смене названия синхронизация не нужна.
2025-04-03 11:32:05 +03:00
Capybara-z b836bcdc70 Функция склонения дней.
- Добавлена функция склонения дней.
2025-04-03 10:26:47 +03:00
Capybara-z ad7ed3e0b0 Склонение дней
- Склонение дней
2025-04-03 10:25:58 +03:00
Capybara-z 9dc00d815b Склонение дней
- Склонение дней
2025-04-03 10:25:39 +03:00
Capybara-z fca235ba98 Добавлено подверждение удаления
- Добавил подверждение удаления
- Поправил удаление купонов.
2025-04-03 09:58:23 +03:00
Capybara-z 44f79a52ca Правка под дни 2025-04-03 09:57:39 +03:00
Capybara-z 517e69948c Купоны по дням
- Купоны теперь можно выдать днями
- Добавил поделиться днями через инлайн мод.
2025-04-03 02:31:54 +03:00
Capybara-z ca8a1b85f1 Перенесана логика купонов
- Логика купонов перенесена в свое место.
2025-04-03 02:06:28 +03:00
Capybara-z 46aa5c46d4 Корректировка работы с купонами.
- Скорректировал работу с купонами так как ранее работа была напрямую из start
- Скорретировал работу с купонам под новый столбец days
2025-04-03 02:05:36 +03:00
Capybara-z 6d6989e3c3 Добавлено создание столбца
- Добавил создание столбца в купонах days
2025-04-03 02:04:07 +03:00
Capybara-z 137c5e0640 Добавлена возможность делиться купоном
- Добалена возможность делиться купоном при инлайн режиме.
2025-04-02 23:07:05 +03:00
Capybara-z 10601761e6 Отображение пробной подписки
- Добавлено отображение пробной подписки если доступна в профиле.
2025-04-02 21:24:49 +03:00
Capybara-z d4c623fb53 Дата регистрации/активности
- Добавлена стата даты регистрации/активности
2025-04-02 21:12:23 +03:00
Vladislav Lisitsyn 261ffea860 Merge pull request #176 from Capybara-z/patch-5
Смена версии
2025-04-02 03:01:10 +03:00
Capybara-z 0aee5000ee Смена версии
- Смена бета версии
2025-04-02 02:50:41 +03:00
Vladislav Lisitsyn 3988759040 Merge pull request #175 from Capybara-z/patch-4
Фикс активных юзеров и улучшения управления кластерами/серверами
2025-04-02 02:29:48 +03:00
Capybara-z bd332aed46 Доработка сообщений
- Добавлены напоминание синхронизации.
2025-04-02 02:08:09 +03:00
Capybara-z 006e3c50b5 Логика переноса ключей
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:43:04 +03:00
Capybara-z fb568d2eea Добавление логики переноса
- Логика переноса ключей при удалении сервера/кластера
2025-04-02 01:42:12 +03:00
Capybara-z c7e3dc65ef Корректировка сообщений
- Удалил ненужное
2025-04-02 01:41:11 +03:00
Capybara-z b6e2a1d268 Добавление QR-кода
- Добавлен QR-код для реф ссылки.
2025-04-02 00:13:13 +03:00
Capybara-z b33a87b89e Доп стата
- Стата за прошлый месяц
2025-04-01 23:55:27 +03:00
Capybara-z ae2574fa0f Добавление логики смены названий
- Добавлена логика смены названия кластера/сервера
- Сделана проверка на наличие ключей с названием кластера/сервера и смены на новое название
2025-04-01 23:40:35 +03:00
Capybara-z 10853ee28f Добавление кнопки
- Добавлена кнопка смены названия кластера
2025-04-01 23:35:19 +03:00
Capybara-z 95861462e0 добавление кнопки
- добавление кнопки смены названия сервера
2025-04-01 23:34:12 +03:00
Capybara-z 8c27e43c52 фикс активации купона
- Корректировка состояния активации купона
2025-04-01 22:10:40 +03:00
Vladislav Lisitsyn 860dbe1315 Merge pull request #174 from Capybara-z/patch-3
Фикс имени подписки и экранирование в подписках
2025-04-01 15:46:35 +03:00
Capybara-z 07abf08c02 Фикс html разметки
- Добавил экранирование
2025-04-01 15:45:37 +03:00
Capybara-z 420b20d126 Фикс имени подписки
- Поставлены ограничения на символа ввода
- Поравлен текст.
2025-04-01 15:25:07 +03:00
Vladless 4f2cdef7f9 fixing bugs 2025-04-01 02:37:52 +03:00
Vladislav Lisitsyn f2427d655e Merge pull request #173 from Capybara-z/patch-2
Добавление отдельного меню Windows/macOS и функции личного рейтинга.
2025-04-01 00:11:35 +03:00
Capybara-z fc70df9abd правка текста
-правка текста
2025-04-01 00:01:44 +03:00
Capybara-z c02b3bd438 Выбор win/macOS
- Сделан выбор win/macOS
- Сделаны текста для них отдельные.
2025-03-31 22:08:27 +03:00
Capybara-z aaf3ec6496 Часть текста перенесана
- Часть текста перенесена CHOOSE_DEVICE_TEXT
2025-03-31 22:04:33 +03:00
Capybara-z b71157514d Добавление новых кнопок
-  Добавлены кнопки скачать win/macos
- Добавлены кнопки выбора устройств win/macos
2025-03-31 22:01:59 +03:00
Capybara-z cb52ab235e Добавление личного рейтинга
-Добавление личного рейтинга вне зависимости от топа.
2025-03-31 00:57:13 +03:00
Vladless 589a841c71 add user in profile 2025-03-30 00:29:44 +03:00
Vladless a5513a5014 add new menu in country mode 2025-03-29 22:27:26 +03:00
Vladless ca2b671be9 fix QRCode, False DISCOUNTS and name profile 2025-03-29 21:57:36 +03:00
Vladless 738590a265 add back button 2025-03-29 12:03:56 +03:00
Vladless 6f5ec883f0 format text 2025-03-29 02:15:52 +03:00
Vladless 21350c43ec add pillow in requirements 2025-03-29 01:18:59 +03:00
Vladless 4b8cb77a37 add qrcode in requirements 2025-03-28 22:19:25 +03:00
Vladless b2b5a57b57 ruff, button file, castomize ping and backup 2025-03-28 20:39:06 +03:00
Vladless 1cad70a72a reorganizing the menu, reissuing the key, improving statistics, and more 2025-03-28 17:44:14 +03:00
Vladless e8d4537f08 rebase api file 2025-03-27 12:52:23 +03:00
Vladless a278ad8a56 customizing the menu, resetting traffic, and creating a key from the admin panel 2025-03-27 04:39:05 +03:00
Vladless 2cdc85ae4c fix admin and add time in cluster 2025-03-27 03:04:21 +03:00
Vladless 5bbe946d64 rename sub and reorganization menu 2025-03-27 02:01:39 +03:00
Vladless 2133ca422a notify keyboards in folder 2025-03-26 23:54:36 +03:00
Vladless 89847f6332 fix after merge 2025-03-26 16:41:43 +03:00
hteppl b21e9ea32a Fix keyboards for users_handler and make data optional for AdminClusterCallback 2025-03-26 02:49:04 +03:00
Vladislav Lisitsyn 737776c557 Merge pull request #172 from Vladless/feature/admin-optimize
Admin optimize
2025-03-26 02:37:00 +03:00
Oleg Kraev 6673bd59da Merge pull request #171 from Vladless/upstream-170
Upstream
2025-03-26 02:06:19 +03:00
hteppl 4007d14c64 Upstream 2025-03-26 02:05:24 +03:00
hteppl af19e17b62 Fix packages imports 2025-03-26 02:00:28 +03:00
hteppl 5fbfd69c5f Implement untrial type for sender 2025-03-26 01:59:52 +03:00
hteppl e4a0d7039b Implement separate sync logic 2025-03-26 01:58:59 +03:00
hteppl ed9fc56e17 Update init routing for handlers 2025-03-26 01:11:54 +03:00
hteppl ddbc37ed13 Fix back button in sender + add broadcast start text 2025-03-26 00:19:24 +03:00
hteppl 68c1c32f1a Fix some back buttons 2025-03-26 00:16:14 +03:00
Vladislav Lisitsyn fa935ae503 Merge pull request #169 from izzzzzi/main
Исправление сравнения tg_id в обработчике подписок.
2025-03-25 20:21:01 +03:00
izzzzzi 076da78981 Исправление сравнения tg_id в обработчике подписок. Приведение типов к int для корректной проверки идентификаторов. 2025-03-25 22:00:54 +05:00
hteppl 6195dfd08d Optimize and fix imports 2025-03-25 17:42:53 +03:00
Oleg Kraev 864ebfaba2 Merge pull request #168 from Vladless/upstream-168
Changes from upstream
2025-03-25 17:34:54 +03:00
Oleg Kraev 9916146097 Merge branch 'feature/admin-optimize' into upstream-168 2025-03-25 17:34:46 +03:00
hteppl b66b90b25d Make clusters 2 in a row 2025-03-25 17:27:25 +03:00
hteppl fcca168052 Do not use clusters_handler for handling management actions 2025-03-25 17:21:05 +03:00
hteppl 3fd7865197 Trying to split cluster and server manage logic 2025-03-25 17:12:47 +03:00
hteppl ffe193c004 Code optimize by ruff 2025-03-25 16:54:02 +03:00
hteppl 6e783d8876 Improve clusters and servers manage texts + fix input len checks 2025-03-25 16:52:38 +03:00
Vladislav Lisitsyn a075964417 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 aa50546bbd Update AdminPanelCallback __init__ as in super().__init__ 2025-03-25 16:32:31 +03:00
hteppl b8aec9ebe9 Use ruff formatting 2025-03-25 16:30:44 +03:00
hteppl db8001f207 Move admin keyboards to their own packages 2025-03-25 16:28:08 +03:00
hteppl 1623c9264d Rename classes + implement own packages for all handlers 2025-03-25 16:22:52 +03:00
Capybara-z 070aaf001d Вынос текста
Вынесенно:
- 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 f3809e3d2a Вынос текста
Вынесенно: 
- CREATING_CONNECTION_MSG
- SELECT_TARIFF_PLAN_MSG
- INSUFFICIENT_FUNDS_MSG
- SELECT_COUNTRY_MSG
2025-03-25 15:16:33 +03:00
Capybara-z ee29b918f6 Вынос текста
Вынесенно:
- TRIAL_INACTIVE_FIRST_MSG
- TRIAL_INACTIVE_BONUS_MSG
- ZERO_TRAFFIC_MSG
2025-03-25 15:15:25 +03:00
Capybara-z 6ce6acb155 Вынос текста
Вынесенно:
- 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 2b4b32ddd9 Вынос текста
Вынесенно:
- CAPTCHA_PROMPT_MSG
2025-03-25 15:11:10 +03:00
Capybara-z edeadba25e Вынос текст
Вынесенно:
- 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 a9111ac107 Вынос текста
Вынесенно:
- PAYMENT_METHODS_MSG
2025-03-25 15:10:23 +03:00
Capybara-z ca536838e6 Вынос текста
Вынесенно:
- BALANCE_MANAGEMENT_TEXT
- BALANCE_HISTORY_HEADER
- INVITE_TEXT_NON_INLINE
2025-03-25 15:10:04 +03:00
Capybara-z bcf93f2c95 Вынос текста
Вынесенно: 
- 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 46aae26658 add_refferal_stats 2025-03-23 19:44:11 +03:00
Vladless 585d0805e1 minor bug fixes and improvements 2025-03-21 09:26:30 +03:00
Vladless 30738a6a00 frozen_subs/restore_all_trial and more 2025-03-12 18:12:29 +03:00
Vladislav Lisitsyn 816bf046ec Merge pull request #166 from Vladless/revert-165-main 2025-03-12 10:24:09 +03:00
Vladislav Lisitsyn a2693393d3 Revert "Добавление нового middleware и рефакторинг" 2025-03-12 10:23:51 +03:00
Vladislav Lisitsyn e85b884985 Merge pull request #165 from izzzzzi/main
Добавление нового middleware и рефакторинг
2025-03-11 23:44:51 +03:00
izzzzzi 8df8c48875 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-03-08 04:59:32 +05:00
izzzzzi 9b557f22d1 Добавление нового middleware и рефакторинг обработчиков с поддержкой target_message 2025-03-08 04:59:23 +05:00
Oleg Kraev c2ddb0117d Merge pull request #163 from izzzzzi/main
Рефакторинг и оптимизация кода
2025-03-07 16:13:02 +03:00
Oleg Kraev d5793d1825 Merge pull request #164 from Vladless/fix/readme
Move preview gifs into .github folder
2025-03-07 15:46:08 +03:00
hteppl d39d9718f6 Move preview gifs into .github folder 2025-03-07 15:44:57 +03:00
izzzzzi 2d937d13d7 Рефакторинг и оптимизация кода: улучшение импортов, форматирования и обработки уведомлений 2025-03-07 06:16:24 +05:00
Vladless 20ccabb228 fix refferal system 2025-03-07 00:51:02 +03:00
Vladislav Lisitsyn 7b9fc32715 Merge pull request #162 from izzzzzi/main
Добавлена обработка исключений в асинхронных задачах
2025-03-04 16:09:25 +03:00
izzzzzi 4ac8b6ce08 Добавлена обработка таймаутов с использованием httpx в клиентских операциях 2025-03-04 01:23:54 +05:00
izzzzzi 23d675dedc Добавлена обработка исключений в асинхронных задачах с помощью return_exceptions=True 2025-03-04 00:57:41 +05:00
Vladislav Lisitsyn 79efa40ef0 Merge pull request #161 from izzzzzi/main
Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
2025-03-03 19:27:02 +03:00
Vladless 19658260aa version 4.0 2025-03-02 21:53:20 +03:00
Oleg Kraev a665c6e9a1 Update README.md 2025-03-02 16:39:09 +03:00
Vladless 93fc0f45c4 version 4.0 2025-03-02 15:31:47 +03:00
Vladless 972c8fbe0a add screening username 2025-03-02 12:33:52 +03:00
Vladislav Lisitsyn 82e1b875ad Update README.md 2025-03-02 03:23:07 +03:00
Vladless f808e44548 Added an extension check 2025-03-02 02:50:12 +03:00
Vladislav Lisitsyn f4e6796f83 Update README.md 2025-03-01 12:11:23 +03:00
Vladislav Lisitsyn df2f7535e2 Update README.md 2025-03-01 02:17:41 +03:00
Vladless cafcb499cd add preview_2 2025-03-01 02:15:57 +03:00
Vladless 5bb8b86f87 add preview 2025-03-01 00:21:15 +03:00
Vladless e81992ccb3 up bot version 2025-02-28 22:44:39 +03:00
Vladless 82e3f7d6cd fix captha and stars 2025-02-28 20:30:04 +03:00
Vladless da7b63ac46 fix Hiddify subscription 2025-02-26 12:46:33 +03:00
izzzzzi 0502ec6c6c Рефакторинг ThrottlingMiddleware с упрощением логики троттлинга
- Значительное упрощение middleware для ограничения частоты запросов
- Унифицирован механизм троттлинга с единым кэшем
- Добавлена поддержка уведомлений о превышении лимита запросов для CallbackQuery
- Удалены избыточные проверки и сложная логика
- Сокращено количество параметров инициализации
2025-02-26 02:33:26 +05:00
Vladless b486f6b186 minor bug fixes 2025-02-25 22:06:44 +03:00
Vladless cd9865caa7 minor improvements 2025-02-25 20:19:41 +03:00
Vladless ec5f96c375 up bot version 2025-02-25 20:10:26 +03:00
Vladless 5ccdc7f68b refactoring and minor improvements 2025-02-25 19:56:42 +03:00
Vladislav Lisitsyn 913d1d3a97 Merge pull request #160 from izzzzzi/main
Рефакторинг и оптимизация обработки подписок
2025-02-25 18:43:14 +03:00
izzzzzi 0657136ae5 Рефакторинг и оптимизация импортов в модулях проекта
- Упорядочивание и группировка импортов в соответствии с PEP 8
- Удаление неиспользуемых импортов
- Перемещение локальных импортов в начало списка
- Добавление пустых строк для улучшения читаемости
- Систематизация порядка импортов в различных модулях проекта
2025-02-25 04:14:58 +05:00
izzzzzi c1dc6efb4c Рефакторинг и улучшение модуля резервного копирования базы данных
- Добавлена расширенная типизация и аннотации типов
- Улучшена обработка ошибок и логирование в функциях бэкапа
- Оптимизирован процесс создания и очистки резервных копий с использованием pathlib
- Добавлены информативные docstring комментарии
- Усовершенствована отправка бэкапов администраторам с обработкой исключений
- Использованы современные подходы к работе с файловой системой и переменными окружения
2025-02-25 02:51:02 +05:00
izzzzzi 968c258029 Улучшение обработки пользователей и middleware с возвратом данных
- Модифицирована функция upsert_user для возврата полных данных пользователя
- Обновлен UserMiddleware для сохранения данных пользователя в контексте
- Расширена функциональность register_middleware с поддержкой опционального исключения middleware
- Добавлена типизация и улучшена обработка middleware
2025-02-25 02:46:59 +05:00
izzzzzi b08040f46b Оптимизация и улучшение middleware и обработки сессий базы данных
- Рефакторинг middlewares с добавлением типизации и улучшенной логики
- Оптимизация SessionMiddleware с использованием пула соединений
- Улучшение обработки пользователей и логирования в UserMiddleware
- Расширение функциональности ThrottlingMiddleware с более гибким управлением
- Добавление дополнительных проверок и логирования в AdminMiddleware
- Оптимизация функции upsert_user с поддержкой передачи существующей сессии
2025-02-25 02:39:44 +05:00
izzzzzi e3a9ff4dfd Рефакторинг и оптимизация обработки подписок
- Значительное улучшение структуры кода в subscriptions.py
- Добавлены типизация и аннотации типов
- Разделение логики на отдельные функции с четкой ответственностью
- Улучшена обработка часовых поясов с использованием pytz
- Оптимизирована логика получения и обработки подписок
- Добавлены информативные логи и комментарии
2025-02-25 01:00:41 +05:00
Vladless 6c3714b3dd refactoring and fix refferal_functions 2025-02-23 23:04:08 +03:00
Vladless 4bf1a82bf0 fix country_mode 2025-02-23 17:03:31 +03:00
Vladless 69c5a1a52f add_commands: coupon/invite 2025-02-22 19:50:53 +03:00
Vladislav Lisitsyn b36f76cd78 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 d0e3b260d0 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 95ddee8673 Merge pull request #158 from Vladless/feature/time-in-admin-stats
Время обновления в главном меню стастистики Администратора
2025-02-22 11:27:41 +03:00
hteppl 5876a69288 Add update_time in admin stats main page 2025-02-22 11:22:56 +03:00
Vladless 02dd042599 minor improvements 2025-02-21 22:36:03 +03:00
Vladislav Lisitsyn 5df29af11d Merge pull request #157 from Capybara-z/patch-9
Оптимизация subscriptions.py
2025-02-21 17:33:16 +03:00
Capybara-z c5301c4d77 Конвертатор байтов
- Добавлен конвертатор байтов.
2025-02-20 20:07:58 +03:00
Capybara-z 964708dee6 Оптимизация трафика
- Оптимизирован траифик, спасибо @hteppl
- Исправлен баг с логами в хедере хидифая.
2025-02-20 20:06:27 +03:00
Capybara-z a3b9a93c46 Апдейт
- Время подписок берется из бд
- Облегчен парсер данных
- Добавлена поддержка хидифай
- Улучшена работа отдачи и обновления подписки.
2025-02-19 22:26:01 +03:00
Vladislav Lisitsyn e7040de838 Merge pull request #156 from Capybara-z/patch-8
changing the subscription issue
2025-02-19 02:33:33 +03:00
Capybara-z a61d93ddcc фикс парсера времени
- фикс парсера времени для старых панелей
2025-02-19 01:31:15 +03:00
Capybara-z 96d738aa9a чистка кода
- Почитсил коментарии.
2025-02-18 23:57:38 +03:00
Capybara-z f87d0c9529 фикс расчета остатка
- Не посмотрел что TOTAL_GB указывается в байтах
2025-02-18 19:40:47 +03:00
Capybara-z 753c408f3e фикс подписок
- Поправлено время для SUPERNODA = False
- Добавлен подсчет использованного трафика для Happ
2025-02-18 17:35:40 +03:00
Capybara-z cc0fc74f82 фикс парсинга времени
- исправлен парсинг времени если передается без запятой
2025-02-18 14:31:43 +03:00
Capybara-z 4a3883ee71 Указал коменты 2025-02-18 13:45:59 +03:00
Capybara-z 3a459b1f61 фикс отображения
- Исправлено отображение с трафиком и без для спуперноды вкл/выкл
- Исправлен парсинг в общем для разных режимов.
2025-02-18 13:30:46 +03:00
Capybara-z 0c0c431e76 Правки для Happ
- Добавлен хедер трафика и даты завершения
2025-02-18 12:12:34 +03:00
Capybara-z 521a930911 поправлены отступы 2025-02-18 11:19:52 +03:00
Capybara-z 3e31b82d55 Фикс отображения данных
- Фикс отображения данных для Happ
2025-02-18 11:17:33 +03:00
Capybara-z 196a5204bb фикс отображения трафика
- Корректное отображения трафика
2025-02-18 10:51:51 +03:00
Capybara-z 061d7870e4 Единое время обновления подписки
- Единое время обновления подписки
2025-02-18 10:30:18 +03:00
Capybara-z 54923177ac фикс часов
- фискс отображения когда остаются только часы
2025-02-18 10:19:05 +03:00
Capybara-z 94f7f65cc3 Фикс подпсиок
- Убираем профиль
- Отказ от SUB_MESSAGE
- Перадача подпсики и времени работы вместо SUB_MESSAGE
- Отдельная итеграция для Happ по хукам.
2025-02-18 09:21:10 +03:00
Vladless 0236b08c31 add_change_domain/bug_fixes_16 2025-02-18 02:05:14 +03:00
Vladislav Lisitsyn 9c2399f4f1 Merge pull request #154 from Capybara-z/patch-7
subscriptions update
2025-02-18 00:09:00 +03:00
Capybara-z b6e7e2e5b2 Апдейт подписок.
- Добавление профиля.
- Выводит имейл и время подписки
- Страны становятся чистыми
- Профиль всегда на 1 месте
- Перемашка стран.
2025-02-17 02:50:21 +03:00
Vladislav Lisitsyn 71bbf83c09 Merge pull request #153 from JustYay/main
small fix for showing error
2025-02-17 02:47:00 +03:00
Boris Kovalskii c8d7cdba74 small fix for show error 2025-02-16 17:27:52 +10:00
Vladislav Lisitsyn 8ca4a5c15e Merge pull request #152 from JustYay/main
Optimize and fix refferals
2025-02-16 10:20:54 +03:00
Boris Kovalskii 002f8dc5ae Opimize and fix coupons and refferals 2025-02-16 17:09:16 +10:00
Boris Kovalskii 516ec94d0c optimize database 2025-02-16 17:08:38 +10:00
Vladless 469d2c9afe edit_text/lots of improvements 2025-02-16 07:17:35 +03:00
Vladislav Lisitsyn 4a14515a63 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 1b0a414ad6 Add inline referral query handler with customizable offers 2025-02-14 23:20:40 +03:00
Zakhar Izmaylov fa8c644520 Add client toggle functionality for 3x-ui servers 2025-02-14 23:06:45 +03:00
Vladislav Lisitsyn 1dc8980310 Update README.md 2025-02-14 05:17:20 +03:00
Vladislav Lisitsyn 9eaf1446ef Update README.md 2025-02-14 05:08:36 +03:00
Vladless da5962e0e2 optimization notifications and get_traffic 2025-02-14 04:36:37 +03:00
Vladless 0da8771a52 NOTIFICATIONS 2.0/bug fixes and improvments 2025-02-14 00:30:06 +03:00
Vladislav Lisitsyn e37857da31 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 659462ef93 Migrate from requirements.txt to pyproject.toml for dependency management 2025-02-09 12:08:22 +03:00
Zakhar Izmaylov ef14852457 Add logging support for inline queries in middleware 2025-02-09 10:52:02 +03:00
Zakhar Izmaylov 74494ae34b Add notification tracking to prevent duplicate notifications for key expiry and renewal 2025-02-08 11:10:11 +03:00
Zakhar Izmaylov 092e2b95cf Ignore GitHub Actions workflow directory in .gitignore 2025-02-08 11:00:00 +03:00
Zakhar Izmaylov feaaa108f0 Remove Ruff code formatting GitHub Actions workflow 2025-02-08 10:59:50 +03:00
GitHub Actions f06171b659 Auto-format code with Ruff using pyproject.toml 2025-02-08 07:46:14 +00:00
Vladless f5afec997c add traffic statistic/share refferal link and more 2025-02-07 21:38:46 +03:00
Vladless cd23569e24 Improve stability and minor fixes 2025-02-06 19:01:20 +03:00
Vladless ab91832ea8 fix_amount 2025-02-06 14:56:47 +03:00
Vladless 13325fab43 ruff formatting 2025-02-06 13:53:02 +03:00
Vladless 9d4cd6ccb5 Major bug fixes 2025-02-06 02:03:12 +03:00
Vladless aa3a8096de fixes: float_stats/delete_user/message_errors 2025-02-05 05:58:03 +03:00
Vladless d64c927101 minor_fixes 2025-02-03 00:08:55 +03:00
Vladless 4894faa37a limit_coupon_usage 2025-02-02 23:15:55 +03:00
Vladless 70b8aec368 minor fixes 2025-02-02 19:40:17 +03:00
Vladless ce20961fb2 minor fixes 2025-02-02 19:00:22 +03:00
Vladless a5ec03bafb fix_negative cashback 2025-02-02 17:49:43 +03:00
Vladless e40dae764a change_location/CASHBACK/logging/bug_fixes 2025-02-02 05:50:14 +03:00
Vladless c8503a1457 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:42:28 +03:00
Vladislav Lisitsyn 6bf4ee5a41 Merge pull request #147 from Capybara-z/patch-5
reorganizing notifications
2025-02-02 02:14:08 +03:00
Vladless 1b17f0fd71 change_location/CASHBACK/logging/bug_fixes 2025-02-02 02:11:57 +03:00
Capybara-z 8518dcba3b Корректировка уведомлений.
- Исправлена работа оповещений 10/24 часа.
- Восстановлена и переработана работа истекших подписок.
- Переработана работа уведомлений при их удалении.
2025-02-02 00:58:07 +03:00
Vladless 51b4ebb677 fix coupons error 2025-02-01 19:15:47 +03:00
Vladless e850b598eb Merge remote-tracking branch 'origin/fix/admin-actions' 2025-02-01 18:32:38 +03:00
Vladislav Lisitsyn b855e366aa Merge pull request #144 from izzzzzi/main
Enhance error handling with start menu fallback
2025-02-01 18:11:23 +03:00
Zakhar Izmaylov dcb534b9c4 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-02-01 11:34:33 +03:00
Zakhar Izmaylov 01ed329e2d 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 bf00eefd40 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 d4b4ea75e5 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 0d937d8e81 Merge pull request #141 from izzzzzi/main
Fix key
2025-01-31 14:30:13 +03:00
Zakhar Izmaylov 14ac2cca2a Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 11:01:40 +03:00
Zakhar Izmaylov d73a8eacc9 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 419e8a6597 Temporary fix for key expiry change 2025-01-31 06:46:53 +03:00
hteppl 3de20a5734 Missing doc for field in update_key_expiry 2025-01-31 06:45:56 +03:00
GitHub Actions eea9e88f0f Auto-format code with Ruff using pyproject.toml 2025-01-31 03:45:45 +00:00
hteppl d83ade8d51 Delete admin_user_editor.py 2025-01-31 06:45:24 +03:00
Zakhar Izmaylov eb03b6014d 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 f042c5fd43 Fix cluster selection 2025-01-31 06:10:37 +03:00
hteppl 70bf8b98e9 Oops legacy codestyle 2025-01-31 05:19:52 +03:00
hteppl 85f26425ed Get back cluster sync option 2025-01-31 05:14:40 +03:00
hteppl ba692f5b73 Get back cluster sender option 2025-01-31 05:03:07 +03:00
hteppl 993c79e9db Fix type mismatch for build_admin_back_btn 2025-01-31 04:21:03 +03:00
Zakhar Izmaylov 8390775b6b Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-31 03:14:14 +03:00
Zahar Izmailov 3d1791cde7 Merge branch 'Vladless:main' into main 2025-01-31 03:09:45 +03:00
Zakhar Izmaylov c8e907edd1 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 6a98f4fb43 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 db1a0d4db7 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 23c6c903ee 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 f9a3931ba0 Auto-format code with Ruff using pyproject.toml 2025-01-30 21:15:14 +00:00
Zakhar Izmaylov 2b006ecae4 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 dbb8f2db96 Merge pull request #139 from izzzzzi/main
Fix
2025-01-31 00:09:44 +03:00
Zakhar Izmaylov c9afe25c52 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 838bb0762c Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:37:28 +03:00
Zakhar Izmaylov c0251b0021 Minor text formatting in captcha generation message 2025-01-30 23:36:56 +03:00
GitHub Actions 1fad6cae3c Auto-format code with Ruff using pyproject.toml 2025-01-30 20:36:20 +00:00
Zakhar Izmaylov f0e78baa19 Remove unused is_bot_blocked function from notifications handler 2025-01-30 23:36:02 +03:00
Zakhar Izmaylov a484498d7e Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 23:35:50 +03:00
Zakhar Izmaylov cdd233a7c6 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-30 22:36:10 +03:00
Zakhar Izmaylov 83870a2022 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 4f1cad2df3 Merge pull request #138 from Capybara-z/patch-2
Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
2025-01-30 21:58:33 +03:00
Vladislav Lisitsyn c107bc2be6 Merge pull request #137 from Capybara-z/patch-1
Задержка перед удалением подписки.
2025-01-30 21:58:22 +03:00
Capybara-z 65bc868346 Расчет времени для исекших уведомлений для DELETE_KEYS_DELAY
Добавлена format_time_until_deletion для расчета времени при уведомлении когда подписка истекала.
2025-01-30 21:50:29 +03:00
Capybara-z 2122233676 Задержка перед удалением подписки.
Добавлена переменная:
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 de45f5d377 Auto-format code with Ruff using pyproject.toml 2025-01-29 04:18:09 +00:00
Zakhar Izmaylov 32ba7b0a67 Minor whitespace adjustment in database connection closing 2025-01-29 07:17:53 +03:00
Zakhar Izmaylov af2b818186 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:17:28 +03:00
Zakhar Izmaylov 7327a96730 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 6bffaab94f Auto-format code with Ruff using pyproject.toml 2025-01-29 04:15:40 +00:00
Zakhar Izmaylov 8ce7ee0ce0 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 5f2b40b2d2 Auto-format code with Ruff using pyproject.toml 2025-01-29 04:01:20 +00:00
Zakhar Izmaylov a757852598 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-29 07:01:01 +03:00
Vladislav Lisitsyn 62ad721262 add admin-panel by hteppl
reorganizing and optimizing the admin panel
2025-01-28 19:36:54 +03:00
Oleg Kraev ee155db444 Merge branch 'main' into dev 2025-01-24 23:35:19 +03:00
Zakhar Izmaylov 873b178151 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 ca2a720bfc 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 46ec8fef50 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 0890d326a4 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 39b998e6df Auto-format code with Ruff using pyproject.toml 2025-01-23 09:00:26 +00:00
Zakhar Izmaylov 5a0b157529 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 12:00:10 +03:00
Zakhar Izmaylov 5be5cd33f7 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 589851ae2c Auto-format code with Ruff using pyproject.toml 2025-01-23 08:40:44 +00:00
Zakhar Izmaylov f83a3a15a9 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 11:40:25 +03:00
Zakhar Izmaylov 73a365eeb6 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 4cfaf41980 Auto-format code with Ruff using pyproject.toml 2025-01-23 08:28:39 +00:00
Zakhar Izmaylov 7dafb47c38 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 906601f5cd 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 e42a66dde7 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 31dd3d6654 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 a9d1c0b75a 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 c850698703 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 70e9bfc2ca 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 533e1283ea Auto-format code with Ruff using pyproject.toml 2025-01-23 05:42:01 +00:00
Zakhar Izmaylov 17d519d440 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 4dcfa31331 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 2d638b0c82 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 311c48edcd 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 845a8c8671 Auto-format code with Ruff using pyproject.toml 2025-01-23 03:15:11 +00:00
Zakhar Izmaylov b78e1148d1 Update .gitignore to include logs and setup.py, and remove unnecessary entries 2025-01-23 06:14:48 +03:00
Zakhar Izmaylov cc7d15390d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-23 06:14:33 +03:00
Zakhar Izmaylov 3af4afd159 Update balance retrieval to round values to one decimal place in database.py 2025-01-23 05:49:24 +03:00
Vladless 65a7e7f9b0 minor_fixes 2025-01-22 18:46:16 +03:00
Zakhar Izmaylov c87ec273e5 Add directory creation for database backups in backup.py 2025-01-22 10:31:03 +03:00
Vladless a8f3187e84 location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
Vladless eb666ca3a4 location-selection/major_update_notifications/gifts and more fixes 2025-01-21 22:43:18 +03:00
hteppl e9671baf65 Implement .github folder for github-related data 2025-01-21 18:04:41 +03:00
Zakhar Izmaylov 8667ae3a23 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 acb73101c0 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 ffc18f4056 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 7802af7d41 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 84831a7290 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 b01e4db2b2 Update work from blocked_users 2025-01-20 03:47:16 +03:00
Vladislav Lisitsyn 775a6f855c Merge pull request #133 from hteppl/admin-redo
Обновление логики работы панели для Администраторов
2025-01-20 03:39:38 +03:00
Zakhar Izmaylov f07b46ac75 Fix errors_handler 2025-01-19 09:16:56 +03:00
Zakhar Izmaylov 82a2e63461 Fix and update Middleware 2025-01-19 09:15:42 +03:00
Zakhar Izmaylov dc9c33c1f5 Update errors_handler 2025-01-19 08:59:43 +03:00
Zakhar Izmaylov cbf53f0a50 Small fix 2025-01-19 08:49:46 +03:00
Zakhar Izmaylov 689ba3579a Update requirements.txt 2025-01-19 08:36:18 +03:00
Zakhar Izmaylov c4eec4572e Add filter and middleware 2025-01-19 08:29:10 +03:00
hteppl df64692c9d Implement expiry time printing in key names v2 2025-01-19 06:05:45 +03:00
hteppl 1067e437d4 Implement expiry time printing in key names 2025-01-19 06:05:22 +03:00
hteppl a47bb0aeae Fix some imports 2025-01-19 05:37:01 +03:00
hteppl 069f2fa983 Introduce changing key expiry date keyboard 2025-01-19 05:35:20 +03:00
hteppl f6a369c907 Remove get_key_details unused fields for now 2025-01-18 04:54:37 +03:00
hteppl 42c3dd7906 Feat https://github.com/Vladless/Solo_bot/commit/b6184d4c90deed7162f618bdebefa44f3e0ee348 2025-01-18 04:36:59 +03:00
hteppl 6d9180063a Fix bouncing messages while handle user input for changing balance 2025-01-18 04:21:40 +03:00
hteppl 347c459c7c Fix some telegram exceptions while editing the same content + add forward option to users search 2025-01-18 04:09:21 +03:00
hteppl 0ca0d47a7b Add and fix some keyboards while creating new cluster or server 2025-01-18 04:08:31 +03:00
Oleg Kraev 75d3102c79 Merge pull request #9 from hteppl/main
Changes from upstream
2025-01-17 21:51:08 +03:00
hteppl 381f3e0b67 Changes from upstream 2025-01-17 21:50:35 +03:00
hteppl 466c2dcf31 Reimage update_subscription callback to work as function 2025-01-17 21:44:54 +03:00
hteppl 327c37bfc9 Update balance changing system + fix some formatting 2025-01-17 20:42:45 +03:00
hteppl cb9db91acc Oops fix imports 2025-01-17 18:25:50 +03:00
hteppl 2d6bd5fb04 Introduce utils module + move csv exports logic 2025-01-17 18:25:09 +03:00
Zakhar Izmaylov 949de2b94e Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2025-01-17 01:54:11 +03:00
Zakhar Izmaylov 260618dcc2 Update backlog 2025-01-17 01:49:24 +03:00
Zahar Izmailov 092047e8c7 Merge branch 'Vladless:main' into main 2025-01-17 01:43:03 +03:00
hteppl 4556c01635 Reorganise backups creation 2025-01-16 11:00:38 +03:00
hteppl 0ca55040bd Rename handlers functions with actual names + delete parse_mode 2025-01-16 09:20:33 +03:00
hteppl 1abb114b6e Delete legacy admin_user_editor.py 2025-01-16 09:11:18 +03:00
hteppl 661ed8895b Implement bot version in admin main menu 2025-01-16 09:08:35 +03:00
Vladislav Lisitsyn 0354b68d1f Merge pull request #132 from JustYay/main
optimize subscriptions
2025-01-16 04:16:15 +03:00
Vladless 26ef2c4e5a backup_update 2025-01-16 04:15:17 +03:00
Vladless c85e20f814 expired_time 2025-01-16 04:03:53 +03:00
Vladless 2f44b273bc fix cluster_name in admin_panel 2025-01-16 03:03:37 +03:00
Vladless 5e5c12b310 pytz for notifications 2025-01-16 01:50:34 +03:00
Oleg Kraev 4509ae84cb Merge pull request #6 from hteppl/main
Changes from upstream
2025-01-15 22:05:55 +03:00
hteppl 1e9f178587 Admin update 2025-01-15 22:05:32 +03:00
Vladless 4135dd514a fix trial_key for SuperNode 2025-01-15 20:16:52 +03:00
Vladless c218a3d9a1 SuperNode/SyncTimeout/pytz 2025-01-15 18:46:33 +03:00
hteppl b2f793fa70 Update stats, fix some text input flows to working with delete middleware 2025-01-15 14:29:33 +03:00
hteppl 5884e0c213 Some missing logic and fixups 2025-01-15 14:07:49 +03:00
Boris Kovalskii f314fbd9cc optimize subscriptions
Get and update subscriptions faster
2025-01-15 21:05:00 +10:00
hteppl b81de05d13 Do not import bot directly in admin sender 2025-01-15 09:01:56 +03:00
hteppl d0608a46aa Remove direct key search, use common user searcher anytime 2025-01-15 09:01:10 +03:00
hteppl 7c6e316521 Update delete middleware: pass all admin callbacks and states 2025-01-15 08:59:54 +03:00
hteppl 296437ab4e Remove legacy admin_user_editor from release 2025-01-15 08:02:04 +03:00
hteppl dddd1d2af7 Optimize and fix admin handlers logic 2025-01-15 08:01:16 +03:00
hteppl 820a86d049 Optimize and fix keyboards 2025-01-15 08:00:41 +03:00
hteppl bcd7a5d59d Implement banned by users handler 2025-01-15 07:58:46 +03:00
hteppl a23d7fffdc Admin keyboards adjustments 2025-01-15 03:37:01 +03:00
Zakhar Izmaylov 4b436a3d12 Add page coupons 2025-01-15 02:39:01 +03:00
Zakhar Izmaylov 4b2e28d15d Delete parse_mode 2025-01-15 02:15:21 +03:00
Vladless a626a1d882 SuperNode/SyncTimeout/pytz 2025-01-14 23:35:19 +03:00
Vladless 7e8a36d7ad SuperNode/SyncTimeout/pytz 2025-01-14 15:53:27 +03:00
Vladless 418725ba74 SuperNode/SyncTimeout/pytz 2025-01-14 15:12:06 +03:00
Vladless 722a0f4913 SuperNode/SyncTimeout/pytz 2025-01-14 12:16:14 +03:00
Vladless 0391bd90e9 FlowPayRenew/fixNotify/pay_history/custom/statistic/minorFixes 2025-01-14 04:06:55 +03:00
Vladless c0cbc17c1e add_stats 2025-01-13 03:58:58 +03:00
Vladless 2e97cbb5ee fix_profile 2025-01-13 02:03:09 +03:00
Vladislav Lisitsyn a8002f3d04 Merge pull request #130 from izzzzzi/main
Backlog
2025-01-13 02:00:32 +03:00
Zakhar Izmaylov 92889baa93 Delete Throttle 2025-01-12 18:19:49 +03:00
hteppl bd0dea9e96 Fix some typo and quotes 2025-01-12 17:35:42 +03:00
hteppl a78df82596 Register all the missing routers 2025-01-12 17:32:11 +03:00
hteppl f41c1e6414 Implement admin sender handler 2025-01-12 17:29:48 +03:00
hteppl 36a4ee339c Implement admin servers handler 2025-01-12 17:07:57 +03:00
hteppl c81fea54e9 Rename admin users editor handler to users handler 2025-01-12 17:01:39 +03:00
hteppl dbf05cfc7c Implement admin users editor handler 2025-01-12 16:58:17 +03:00
Oleg Kraev 86acd6b822 Merge pull request #5 from hteppl/upstream1
Changes from upstream
2025-01-12 16:56:52 +03:00
Oleg Kraev 0a822265d3 Merge branch 'admin-redo' into upstream1 2025-01-12 16:56:41 +03:00
Zakhar Izmaylov 520fad20e5 Add check_users_and_update_blocked 2025-01-11 20:29:01 +03:00
Zakhar Izmaylov 3c0a94a3f8 Add timeout 2025-01-11 18:43:14 +03:00
Zakhar Izmaylov 023186bd93 Update stats 2025-01-11 18:10:43 +03:00
Zakhar Izmaylov 921cade931 Add balance_history 2025-01-11 18:03:59 +03:00
Zakhar Izmaylov d247ef81df Update config lint 2025-01-11 17:51:24 +03:00
hteppl 4371acaab4 Implement admin stats handler 2025-01-11 02:24:16 +03:00
hteppl 1e0261d809 Implement admin restart handler + also handle AdminPanelCallback for back buttons 2025-01-11 02:17:54 +03:00
hteppl 88aae8e650 Add more base functions for creating singleton admin keyboards 2025-01-11 02:17:02 +03:00
Vladislav Lisitsyn 3e5edf18b3 Merge pull request #129 from izzzzzi/main
Add stats
2025-01-11 02:01:40 +03:00
Zakhar Izmaylov a018a42f2c Add antiflood and lint 2025-01-10 14:51:13 +03:00
Zakhar Izmaylov 5c70f8c35f Add stats 2025-01-10 14:38:28 +03:00
Vladless 3ac9435e58 syncClaster/picGifts/mailingCluster/renewButtin and more 2025-01-10 00:02:13 +03:00
hteppl 84c17a9358 Implement admin backups handler 2025-01-09 15:49:23 +03:00
hteppl c57e557755 Update admin coupons handler 2025-01-09 15:48:32 +03:00
hteppl 3c58a36d19 Update admin main panel 2025-01-09 15:43:22 +03:00
hteppl b177d51f9b Introducing keyboards package 2025-01-09 15:13:36 +03:00
Vladless 94c5bfe8bf fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 06:00:50 +03:00
Vladless a45a00e55b fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 03:16:53 +03:00
Vladless 365e1f6d61 fiatCryptobot/flowPayments/Buttons/fixNotify/BanOfUsers/fixTrial and more 2025-01-09 02:40:39 +03:00
Vladislav Lisitsyn 2bddedf692 Merge pull request #128 from izzzzzi/main
Fix process_message_to_all
2025-01-06 19:20:45 +03:00
Zakhar Izmaylov 2b23e5aa42 Update version 2025-01-06 15:29:24 +03:00
Zahar Izmailov f6ac5ea8ac Merge branch 'Vladless:main' into main 2025-01-06 12:10:08 +03:00
Zakhar Izmaylov c70798f0d5 Fix process_message_to_all 2025-01-06 12:09:15 +03:00
Vladislav Lisitsyn 5073286506 Merge pull request #127 from izzzzzi/main
Backlog
2025-01-06 02:57:04 +03:00
Zakhar Izmaylov 636912070d Fix float get_usd_rate 2025-01-05 23:46:45 +03:00
Zakhar Izmaylov 812fa87f26 Add get_usd_rate 2025-01-05 23:43:04 +03:00
Zakhar Izmaylov 61e4eaf6ea Add update_all_keys 2025-01-05 23:32:18 +03:00
Zakhar Izmaylov 8128106c17 Update update_subscription user 2025-01-05 23:24:01 +03:00
Zakhar Izmaylov 9ddbb407d0 Update send_to_alls 2025-01-05 23:19:45 +03:00
Zakhar Izmaylov 1156374ba8 Add update user 2025-01-05 23:11:05 +03:00
Vladislav Lisitsyn 567ad98397 Merge pull request #126 from hteppl/fix-trial
Фикс проверки тестовых подписок
2025-01-05 18:54:58 +03:00
hteppl db8f263128 fix: typo in error message 2025-01-04 20:34:49 +03:00
hteppl 80779e161e fix: check trial key already used 2025-01-04 15:30:34 +03:00
Vladislav Lisitsyn b4bd0176e5 Merge pull request #125 from hteppl/fix-tv-preview
Удаление превью ссылок в сообщении текста для ТВ
2024-12-29 02:26:05 +03:00
hteppl fe70664a23 Fix CONNECT_TV_TEXT: add disable_web_page_preview=True 2024-12-29 02:05:47 +03:00
Vladless dbb804becb TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:46:20 +03:00
Vladless 1ee1ee3b0e TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:28:38 +03:00
Vladless d2fc1eb256 TV/Send_message/custom_refferal_level/buttons 2024-12-28 22:22:56 +03:00
Vladislav Lisitsyn 79602d593b Merge pull request #124 from hteppl/mics-updates
Небольшие изменения
2024-12-26 23:03:38 +03:00
hteppl 682543374a Fix key back button 2024-12-26 03:40:26 +03:00
hteppl 0df1c98407 Remove Telegram URL prefix when finding a user by username 2024-12-26 03:36:54 +03:00
hteppl 729e5ab87a Monthly payments in admin stats 2024-12-26 03:36:28 +03:00
Vladislav Lisitsyn 711156f34a Merge pull request #123 from hteppl/license
Лицензия и обновленный README
2024-12-24 17:40:48 +03:00
Vladless b4df8d6d86 gift_links 2024-12-24 15:02:48 +03:00
Vladless 03d494b657 buttons_pay/security_update/MacOS 2024-12-24 02:30:53 +03:00
Vladless fa374bc670 buttons_pay/security_update 2024-12-24 02:30:09 +03:00
hteppl f51df67dc4 Update README.md 2024-12-23 16:01:16 +03:00
hteppl 83dd78c3dd Add CC BY-NC 4.0 LICENSE 2024-12-23 16:00:48 +03:00
hteppl 5143f8eb09 Remove legacy installation scripts 2024-12-23 16:00:26 +03:00
Vladless 761ee488e2 minor update 2024-12-23 01:23:34 +03:00
Vladless e60797c1e0 gift_code/buttons/commands 2024-12-23 01:14:28 +03:00
Vladless c1728d111b add support for business/optimization 2024-12-20 23:29:27 +03:00
Vladless a004449dd2 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:39:07 +03:00
Vladless 97091f4875 fix_refferal/GIFTS/redirect/name_sub and more 2024-12-19 20:37:41 +03:00
Vladislav Lisitsyn 710770d39d Merge pull request #122 from hteppl/fix-referrals
WIP: фикс уявзимости, где айди приглашенного реферала, совпадает с айди пригласившего
2024-12-16 23:41:29 +03:00
hteppl 0a0f943517 Fix self-referred bug 2024-12-16 23:19:39 +03:00
Vladislav Lisitsyn c8f9ebc443 Merge pull request #120 from crell2100/main
add CHANEL_EXISTS variable
2024-12-16 23:09:42 +03:00
crell2100 8f4af59ab5 donats_enable->donations_enable (rename variable) 2024-12-16 16:40:01 +03:00
crell2100 c38a252053 Merge branch 'main' into main 2024-12-15 22:51:32 +03:00
crell2100 fdbecde455 add DONATS_ENABLE variable 2024-12-15 22:30:20 +03:00
crell2100 b1c0ecb43a add CHANEL_EXISTS variable 2024-12-15 18:07:52 +03:00
Vladless 52507b2a2b remove gifts 2024-12-15 00:23:08 +03:00
Vladless 8f5fb33fce add import 2024-12-15 00:23:08 +03:00
Vladless fa82cd30b5 add import 2024-12-15 00:23:08 +03:00
Vladislav Lisitsyn 7946e7a4f8 Update README.md 2024-12-14 21:24:42 +03:00
Vladislav Lisitsyn 35e81b2e2d Merge pull request #119 from TrackLine/main
Add Yoomoney pay service
2024-12-14 19:07:25 +03:00
TrackLine 5af2e31c66 Add Yoomoney pay service 2024-12-14 19:49:54 +04:00
Vladless 4becc056dc sub_name 2024-12-14 18:46:43 +03:00
Vladless 198cfd48f3 fix middleware 2024-12-14 17:39:39 +03:00
Vladislav Lisitsyn 51e37e3bec Merge pull request #118 from izzzzzi/main
Migrate to ruff
2024-12-14 02:02:09 +03:00
Vladislav Lisitsyn 0f0a1ab9f0 Merge branch 'main' into main 2024-12-14 02:02:00 +03:00
Vladless ef2441c1e9 vat_code 2024-12-14 00:37:20 +03:00
Vladless a0c25e8d67 sub_name/delete user 2024-12-13 23:01:58 +03:00
Zakhar Izmaylov e50b59f753 Fix requirements.txt 2024-12-13 12:13:50 +03:00
Zakhar Izmaylov 142c1c15e7 Migrate to ruff 2024-12-13 12:09:20 +03:00
Vladless ba11c8fe0e rm wrench 2024-12-11 15:41:45 +03:00
Vladislav Lisitsyn 6aa6d9132a Merge pull request #113 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 04:14:24 +02:00
Vladless dbd06691c9 pic for tariffs 2024-12-10 05:13:54 +03:00
Vladless afb037c7f5 pic for tariffs 2024-12-10 05:12:49 +03:00
Vladless f0b2c40a8a 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 bfa9c3dd5a Merge pull request #112 from Vladless/hteppl-optimize-sources
Hteppl optimize sources
2024-12-10 02:27:34 +02:00
Vladless ee92a28f8a 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 95b13dfbdc Merge pull request #108 from hteppl/optimize-routing-system-v2
Оптимизация системы рутинга 2 // LP
2024-12-10 00:54:32 +02:00
Vladislav Lisitsyn 1a2c6697b5 Merge pull request #104 from hteppl/create-sql-schema
Оптимизация создания базы данных // LP
2024-12-10 00:54:22 +02:00
hteppl f31e1bb5f6 Make utils as package + fix python unresolved imports 2024-12-08 05:32:23 +03:00
hteppl 095a225673 Move payments utils into utils folder 2024-12-08 05:13:38 +03:00
hteppl dd79c6a709 Move keys utils into utils folder 2024-12-08 05:11:31 +03:00
hteppl 9233fab758 Move utils.py to the utils folder 2024-12-08 05:02:41 +03:00
hteppl 7a11c51e1f Move util-related files into separated folder + refactor imports 2024-12-08 04:57:20 +03:00
Oleg Kraev 1a68512af5 Merge branch 'Vladless:main' into optimize-routing-system-v2 2024-12-08 04:37:31 +03:00
hteppl 9c08e8a126 Implement global router 2024-12-08 04:36:59 +03:00
hteppl 95716cdeab Include routers only if payment method enabled in the config 2024-12-08 04:23:59 +03:00
hteppl 4a16643880 Create and implement router for admin 2024-12-08 04:21:37 +03:00
hteppl 95e2ccf7b0 Create and implement router for instructions 2024-12-08 04:20:09 +03:00
hteppl a19690c17f Create and implement router for keys 2024-12-08 04:19:16 +03:00
hteppl af53834d5d Create and implement router for payments 2024-12-08 04:17:50 +03:00
hteppl 297262c749 Create basic main router for handlers 2024-12-08 04:15:52 +03:00
Vladislav Lisitsyn bd43ec0f6d Merge pull request #107 from hteppl/linux-start-scripts
Linux cкрипты для автоматической настройки зависимостей и запуска
2024-12-07 15:32:56 +02:00
Vladless 98512a288f add pay button 2024-12-07 16:32:16 +03:00
Vladislav Lisitsyn a750dfae4f Update README.md 2024-12-07 00:27:53 +03:00
Vladislav Lisitsyn b7a515271c Update README.md 2024-12-07 00:17:48 +03:00
hteppl 97f4bc8104 Add linux shell scripts to configure dependencies automatically 2024-12-06 18:39:35 +03:00
Vladless e44f7b1cd1 ping servers and notifications 2024-12-06 05:19:44 +03:00
Vladless 0fedc45eca ping servers and notifications 2024-12-06 04:48:58 +03:00
Vladless b13af49ece back button in custom pay 2024-12-06 02:25:40 +03:00
Vladless 21e858c410 back button in custom pay 2024-12-06 02:25:18 +03:00
Vladless 7d4d7a37cb preview pc_instruction disable 2024-12-06 01:42:14 +03:00
Vladless 42b8ca5eec Server availability 2024-12-06 01:14:25 +03:00
Vladless 8843ff9a7e logs folder and autoremove 2024-12-06 00:46:54 +03:00
Vladless a93cfbaa20 sales in texts 2024-12-05 23:56:06 +03:00
Vladless 3e6aa31e17 backup for 3x-ui 2024-12-05 23:31:48 +03:00
Vladless ac78a86bf1 fix refferal 2024-12-05 22:59:34 +03:00
Vladless 4899e5315d fix refferal 2024-12-05 22:45:10 +03:00
hteppl a8fa45c3f1 Create schema.sql database template + optimize init_db 2024-12-05 07:59:49 +03:00
Vladless 8dcca13405 fix trial 2024-12-04 17:25:32 +03:00
Vladless b9768ec4e0 change version 2024-12-04 03:10:15 +03:00
Vladless e98dd9a533 unique server_name/ link moderation 2024-12-04 03:02:29 +03:00
Vladless 698d6ef233 unique server_name 2024-12-04 02:46:57 +03:00
Vladless f4f1a5596c plan on create new key after trial 2024-12-04 01:28:28 +03:00
Vladless a0fd960347 admin_update 2024-12-04 00:16:23 +03:00
Vladless efdb9a11dc add button for MacOS 2024-12-04 00:06:21 +03:00
Vladless 4239f9b9cf Clusters on/off in config 2024-12-02 04:58:14 +03:00
Vladless 5fa1ce99fd profile/reorganization and news 2024-12-02 04:46:05 +03:00
Vladless 24211f6756 sale size 2024-12-02 04:20:32 +03:00
Vladless 0f638f2d21 rename server info 2024-12-02 04:08:36 +03:00
Vladless 46e0288da3 move button 2024-12-02 04:06:46 +03:00
Vladless fb7e683f12 pc button for trial_key 2024-12-02 03:54:07 +03:00
Vladless d8e499bcfb pc button for trial_key 2024-12-02 03:37:28 +03:00
Vladless 69fd2b9f8e add/delete servers in admin 2024-12-02 03:25:06 +03:00
Vladless 50bbc17b0a servers in admin_editor 2024-12-02 00:28:33 +03:00
Vladless c21906da23 servers in database 2024-12-01 23:40:37 +03:00
Vladless 319ea2b63b servers in database 2024-12-01 23:24:53 +03:00
Vladless 66d4ab22b6 autoconfig servers in database 2024-12-01 23:24:13 +03:00
Vladless 44bae2b4e6 notification inactive users 2024-12-01 22:24:00 +03:00
Vladless 2dd3d3e2a7 sudo in restart 2024-11-29 00:01:48 +03:00
Vladless 00efebc377 autoname for trial in profile 2024-11-28 20:48:34 +03:00
Vladless 2b4e3182c7 /start 2024-11-28 15:37:21 +03:00
Vladless 5eb4671cc5 link Successful payment 2024-11-27 21:18:07 +03:00
Vladless d088d4dbc2 fix stars 2024-11-27 17:30:53 +03:00
Vladless 4283470d82 update subs 2024-11-26 20:10:14 +03:00
Vladless fca313a627 fix admin 2024-11-26 20:03:21 +03:00
Vladless 909ea8901e notifications 2024-11-26 17:48:41 +03:00
Vladless d38a98cabd fix renew 2024-11-26 17:30:41 +03:00
Vladless c189e88edf random email for add device 2024-11-26 17:21:15 +03:00
Vladislav Lisitsyn 34fe87d9ac Merge pull request #102 from izzzzzi/main
Refactor
2024-11-26 03:25:33 +03:00
Zakhar Izmaylov 80f2314c9e Fix start 2024-11-26 03:21:43 +03:00
Zakhar Izmaylov af5c9215a0 Small fix 2024-11-26 02:54:32 +03:00
Zakhar Izmaylov f97faccf95 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 c1fdf21dae Merge branch 'Vladless:main' into main 2024-11-26 00:27:58 +03:00
Vladless 52d9888f8d back button 2024-11-25 01:16:06 +03:00
Vladless ec46b96906 fix session 2024-11-25 00:58:46 +03:00
Zakhar Izmaylov 1549f01699 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 c793bcad26 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 91b079bab1 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 3e18be8aba 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 ef85afbc79 Merge branch 'Vladless:main' into main 2024-11-24 23:37:58 +03:00
Vladless 54baf9d406 3.2 2024-11-24 23:10:22 +03:00
Vladless 6c29f6d164 reset traffic 2024-11-24 23:02:34 +03:00
Zakhar Izmaylov 09b763b4a3 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 22:37:23 +03:00
Zakhar Izmaylov 43fd32f7be 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 783ac6f705 ID_Inbound 2024-11-24 22:29:32 +03:00
Vladless b0b690b78f ID_Inbound 2024-11-24 22:29:05 +03:00
Vladislav Lisitsyn f629e83972 Merge pull request #100 from izzzzzi/main
Refactoring and Update
2024-11-24 21:54:53 +03:00
Vladislav Lisitsyn 9eb12ac053 Merge branch 'main' into main 2024-11-24 21:54:30 +03:00
Zakhar Izmaylov e76af1948b 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 fa17dea8fe 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 e3e409aa5c fix back cryptobot 2024-11-24 20:19:23 +03:00
Zakhar Izmaylov 13a99d5e32 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 8e3e5d66b0 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 b10da8022c Update table 2024-11-24 14:14:06 +03:00
Zakhar Izmaylov f25332f19d Fix delete msg 2024-11-24 13:51:18 +03:00
Zakhar Izmaylov 5ffb7bc71c notify_inactive_trial_users 2024-11-24 11:58:43 +03:00
Zakhar Izmaylov 2288f28475 Fix admin 2024-11-24 11:51:17 +03:00
Zakhar Izmaylov e8a0308e56 Update connection_exists 2024-11-24 11:42:29 +03:00
Zakhar Izmaylov 6b712de25c Fix srssion 2024-11-24 11:26:54 +03:00
Zakhar Izmaylov 2f799f0bad Update id 2024-11-24 11:07:52 +03:00
Zakhar Izmaylov adb509f1e1 Fix 2024-11-24 10:54:08 +03:00
Zakhar Izmaylov 7093291a2b Fix get_trial 2024-11-24 10:35:12 +03:00
Zakhar Izmaylov 44cc25718b Add logging 2024-11-24 09:15:09 +03:00
Zakhar Izmaylov 414ba695ef Fix trial_status 2024-11-24 08:27:27 +03:00
Zakhar Izmaylov 12a8fb92f9 Fix 2024-11-24 08:13:52 +03:00
Zakhar Izmaylov 1b599d5eef Update InlineKeyboardBuilder 2024-11-24 01:57:42 +03:00
Zakhar Izmaylov 48149f14cd Fix 2024-11-24 01:47:37 +03:00
Zakhar Izmaylov 57e701362f Add notify_inactive_trial_users 2024-11-24 01:34:39 +03:00
Zakhar Izmaylov d2cbfcda93 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:24:10 +03:00
Zakhar Izmaylov 25c7d5f43d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-24 01:19:06 +03:00
Zakhar Izmaylov 95192032de Fix typing 2024-11-24 01:19:00 +03:00
Zakhar Izmaylov 30efc7a1c7 Refactoring 2024-11-24 01:18:42 +03:00
Vladless 45469da513 update clusters 2024-11-23 20:21:47 +03:00
Vladless b5e1de3589 fix users 2024-11-23 17:30:39 +03:00
Vladislav Lisitsyn b2c0d75fda 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 6d106bcfb2 Fix admin 2024-11-23 07:14:58 +03:00
Zakhar Izmaylov 1bc6224f6b Add pylint 2024-11-22 21:34:56 +03:00
Zakhar Izmaylov d1b7a01b4f Update config 2024-11-22 21:24:55 +03:00
Zakhar Izmaylov 034c1246bd Fix ref 2024-11-22 17:45:38 +03:00
Zakhar Izmaylov a260e6f060 Update text admin 2024-11-22 01:14:23 +03:00
Zakhar Izmaylov 5460029b16 Update push 2024-11-22 01:12:19 +03:00
Zakhar Izmaylov fff4ef4790 Add search from username 2024-11-22 00:43:23 +03:00
Zakhar Izmaylov 54a8d380e8 Update user_stats_menu 2024-11-21 21:14:44 +03:00
Zakhar Izmaylov 4fbffd2c88 Update instructions 2024-11-21 20:49:58 +03:00
Zakhar Izmaylov 998bf7f7c6 Update text and add view_tariffs 2024-11-21 20:31:24 +03:00
Zakhar Izmaylov 512c87160d Fix send_to_alls 2024-11-20 17:30:10 +03:00
Zakhar Izmaylov 480bcf28ab Update admin 2024-11-20 17:18:28 +03:00
Zakhar Izmaylov 742cbc0bee Add new table and add stats in admin 2024-11-20 16:47:21 +03:00
Zakhar Izmaylov 936eb36acb Update logger 2024-11-20 15:46:46 +03:00
Zakhar Izmaylov 13d74ce239 Fix img and logger add button back for donate 2024-11-20 15:36:25 +03:00
Zahar Izmailov 94e1923c0d Merge branch 'Vladless:main' into main 2024-11-20 15:28:44 +03:00
Zakhar Izmaylov b7049e37b1 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-20 09:43:22 +03:00
Zakhar Izmaylov bafb176c3f Fix 2024-11-20 09:43:16 +03:00
Vladless eb6c59002f fix bugs 2024-11-20 00:18:29 +03:00
Vladless fa1f8b2ed6 clusters update 2024-11-19 19:31:31 +03:00
Vladless 232d40c6b6 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-19 14:58:25 +03:00
Vladless af5f0eef59 security update 1.0 2024-11-19 14:58:07 +03:00
Vladislav Lisitsyn f5d578d5a3 Update start.py 2024-11-19 11:52:06 +03:00
Vladless 7e1d1402e1 security update 1.0 2024-11-19 11:43:22 +03:00
Vladless a568edad63 security update 1.0 2024-11-19 11:21:34 +03:00
Vladislav Lisitsyn 909df80d7b Merge pull request #97 from izzzzzi/main
Fix key and session
2024-11-19 09:03:20 +03:00
Zakhar Izmaylov f38d169c37 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-19 08:32:33 +03:00
Zakhar Izmaylov d2eb493720 Fix key and session 2024-11-19 08:32:30 +03:00
Vladislav Lisitsyn 3b853a1edf Merge pull request #96 from izzzzzi/main
Add back button for coupons
2024-11-18 22:51:16 +03:00
Vladless 5cb8784332 renew/delete 2024-11-18 22:40:59 +03:00
Zakhar Izmaylov abf9462271 Fix admin 2024-11-18 22:19:17 +03:00
Zakhar Izmaylov 0afba0fa9c Add back button for coupons 2024-11-18 22:03:51 +03:00
Vladless 0b8564684e add time in robokassa 2024-11-18 20:03:45 +03:00
Vladless 8d1dfd909d fix update_sub 2024-11-18 16:04:29 +03:00
Vladless 28b344b9c4 Fixed unexpected behavior of the device selection button 2024-11-18 14:57:12 +03:00
Vladless e9237e5f1c Fixed unexpected behavior of the device selection button 2024-11-18 14:56:41 +03:00
Vladislav Lisitsyn 2e7d6f98c6 Merge pull request #95 from izzzzzi/main
Add admin middleware
2024-11-18 12:54:05 +03:00
Zakhar Izmaylov 39db3e2e64 Update Middleware 2024-11-17 23:42:09 +03:00
Zakhar Izmaylov 853840475c Fix 2024-11-17 16:24:12 +03:00
Zakhar Izmaylov 599cdfed38 Add button 2024-11-17 16:19:47 +03:00
Zakhar Izmaylov 1939f41a1c Formatting 2024-11-17 16:15:51 +03:00
Zakhar Izmaylov 474f611fcc Fix Middleware 2024-11-17 16:14:35 +03:00
Zakhar Izmaylov 69992725b8 Fix admin 2024-11-17 16:10:58 +03:00
Zakhar Izmaylov ca08152b03 Fix admin 2024-11-17 16:05:20 +03:00
Zakhar Izmaylov e9523059b2 Add default admin 2024-11-17 16:01:18 +03:00
Zakhar Izmaylov c81db7973e Fix start 2024-11-17 15:53:31 +03:00
Zakhar Izmaylov 31d35b7eff Fix commands 2024-11-17 15:47:31 +03:00
Zakhar Izmaylov 5a61a29368 Fix start 2024-11-17 15:44:41 +03:00
Zakhar Izmaylov b2f29ff4f2 Fix 2024-11-17 15:39:53 +03:00
Zakhar Izmaylov 75b5d2f9cc Fix admin middleware 2024-11-17 15:39:27 +03:00
Zakhar Izmaylov 4dba5736e1 Fix send backup 2024-11-17 15:29:56 +03:00
Zakhar Izmaylov fe7690ac02 Fix admin and create coupons 2024-11-17 15:26:03 +03:00
Zakhar Izmaylov e951dd9573 Fix admin 2024-11-17 15:10:28 +03:00
Zakhar Izmaylov b3d933e98d Update img 2024-11-17 14:17:51 +03:00
Zakhar Izmaylov 536511b08f Fix img 2024-11-17 14:15:27 +03:00
Zakhar Izmaylov d362e232d7 Fix 2024-11-17 14:09:05 +03:00
Zakhar Izmaylov a51807bd57 Update delete models 2024-11-17 14:08:51 +03:00
Zakhar Izmaylov ed759cdf62 Add new table and middleware 2024-11-17 14:05:31 +03:00
Zakhar Izmaylov 5ece667357 Refactoring and fix 2024-11-17 13:33:17 +03:00
Zakhar Izmaylov f585b82cd6 Add admin middleware 2024-11-17 13:11:49 +03:00
Zahar Izmailov 0c5d63c6e4 Merge branch 'Vladless:main' into main 2024-11-17 10:42:43 +03:00
Vladless 737f057cae version 2024-11-17 03:20:06 +03:00
Vladless b011639c0e add version 2024-11-17 01:32:52 +03:00
Vladless 8a0f013b5f robokassa release 2024-11-17 00:01:26 +03:00
Zakhar Izmaylov 83647655ca Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-16 23:56:54 +03:00
Zakhar Izmaylov 9e89f77ed4 #TODO Admin and bash 2024-11-16 23:56:45 +03:00
Vladislav Lisitsyn 91453a66b4 Merge pull request #94 from izzzzzi/main
Add scripts
2024-11-16 22:54:50 +03:00
Zahar Izmailov 028ef92b4c Merge branch 'Vladless:main' into main 2024-11-16 21:06:04 +03:00
Zakhar Izmaylov 4714c9e60c Delete nginx config 2024-11-16 21:04:06 +03:00
Zakhar Izmaylov 4594900b26 Update 2024-11-16 21:03:39 +03:00
Zakhar Izmaylov 789643ee3e Add bash for node 2024-11-16 19:05:09 +03:00
Vladislav Lisitsyn 0e6fecef2d Merge pull request #93 from izzzzzi/main
Add dry logging
2024-11-16 18:46:12 +03:00
Zakhar Izmaylov fb28a0881c Add dry logging 2024-11-16 18:01:28 +03:00
Vladless 69d96284c3 fix delete 2024-11-16 00:44:12 +03:00
Vladless d37b1d12c6 ip 2024-11-15 23:59:42 +03:00
Vladless 737f64763b fix back_button 2024-11-15 23:27:08 +03:00
Vladless 003063c140 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 23:15:41 +03:00
Vladless c296243032 fix logging 2024-11-15 23:15:19 +03:00
Vladislav Lisitsyn 92c6e870d9 Update start.py 2024-11-15 22:06:23 +03:00
Vladless 318efc7cf7 pc instructions and button 2024-11-15 21:49:27 +03:00
Vladless 37da9639a6 fix cluster 2024-11-15 20:30:57 +03:00
Vladless 91e1d24122 changing notifications 2024-11-15 20:15:19 +03:00
Vladless c12c1fd174 logging for add client 2024-11-15 19:24:09 +03:00
Vladless 2ec69c7f14 formatting 2024-11-15 19:19:13 +03:00
Vladless 7cccc53f55 rm DEBUG 2024-11-15 19:16:37 +03:00
Vladless ad315ed7c5 total_gb 2024-11-15 19:16:06 +03:00
Vladislav Lisitsyn df0f9b4480 Merge pull request #92 from izzzzzi/main
Fix pay stars
2024-11-15 18:03:04 +03:00
Zakhar Izmaylov 5cf01834db Fix router 2024-11-15 17:28:54 +03:00
Zakhar Izmaylov cf2ce1283d Fix pay stars 2024-11-15 17:28:06 +03:00
Zakhar Izmaylov 0855d3a5e7 Fix pay stars 2024-11-15 17:25:42 +03:00
Vladless c753a7bdf5 fix donate 2024-11-15 15:48:52 +03:00
Vladless 83c88f42db fix donate 2024-11-15 15:40:20 +03:00
Vladislav Lisitsyn 48fc9f506e Merge pull request #91 from izzzzzi/main
Add donate
2024-11-15 15:09:46 +03:00
Vladless 6bd6689d21 Merge branch 'main' of https://github.com/Vladless/Solo_bot 2024-11-15 15:09:01 +03:00
Vladless 57a840d4a3 robokassa_beta 2024-11-15 15:07:51 +03:00
Zakhar Izmaylov aaf3eda76e Add donate 2024-11-15 14:38:14 +03:00
Vladislav Lisitsyn 6ba7b6ba70 Merge pull request #90 from izzzzzi/main
Added support for the list of admins
2024-11-15 13:51:33 +03:00
Vladless b7324f256b fix message delete 2024-11-15 13:51:12 +03:00
Zahar Izmailov 928fe4a3cb Merge branch 'Vladless:main' into main 2024-11-15 13:37:57 +03:00
Zakhar Izmaylov 8bb5cf43b4 Added support for the list of admins 2024-11-15 13:37:24 +03:00
Vladislav Lisitsyn 18556e6a75 Merge pull request #89 from izzzzzi/main
Fix stars
2024-11-15 13:30:46 +03:00
Zakhar Izmaylov 0999029567 Fix stars 2024-11-15 13:25:57 +03:00
Vladislav Lisitsyn b3aa1a8296 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 dea06788cf Add multy pay 2024-11-15 11:58:48 +03:00
Zakhar Izmaylov 4336826986 Add dev mode and fix menu and fix stars and cryptobot 2024-11-15 11:40:25 +03:00
Vladless 3adc7fc59f coupons initial 2024-11-15 03:03:24 +03:00
Vladless 454b839fae fix payments 2024-11-14 15:13:52 +03:00
Vladless 2ea073db4a servers in clusters/total_gb 2024-11-14 14:17:16 +03:00
Vladless 8684496d08 servers in clusters/total_gb 2024-11-14 03:04:23 +03:00
Vladless e23676b74d servers in clusters/total_gb 2024-11-14 03:03:32 +03:00
Vladless bd051f81a6 custom trial time 2024-11-13 18:51:25 +03:00
Vladislav Lisitsyn 10e1504e6f Merge pull request #87 from izzzzzi/main
Update keyboards and restructuring for filters
2024-11-13 18:04:38 +03:00
Vladless daec9814c8 custom bonus 2024-11-13 17:27:08 +03:00
Zakhar Izmaylov bb55151e20 Add models.py #TODO 2024-11-13 17:10:25 +03:00
Zakhar Izmaylov 6a30f342db Add DatabaseMiddleware #TODO 2024-11-13 17:03:18 +03:00
Zakhar Izmaylov a1fb2bfb09 Restructuring for filters 2024-11-13 16:53:23 +03:00
Zakhar Izmaylov 47c514a55b Update keyboards, update texts.py 2024-11-13 16:48:56 +03:00
Vladless 1caaaf9ae8 add TOTAL_GB 2024-11-13 16:18:16 +03:00
Vladless 7bf22c8c2b small fix 2024-11-13 15:38:20 +03:00
Vladless 9f8a366aa3 small fix 2024-11-13 05:41:49 +03:00
Vladless 45ac41c01a redesign/bug fixes/admin trial 2024-11-13 04:05:43 +03:00
Vladless 7bc4c9ef6e redesign/bug fixes/admin trial 2024-11-13 04:04:40 +03:00
Vladless 38f02bbcb6 redesign/bug fixes/admin trial 2024-11-13 04:01:13 +03:00
Vladless c01c888f0e redesign/bug fixes/admin trial 2024-11-13 03:59:34 +03:00
Vladislav Lisitsyn 9731e87ded Merge pull request #86 from izzzzzi/main
Refactoring admin
2024-11-12 13:11:09 +03:00
Zakhar Izmaylov 4df89ea7a9 Refactoring user editor 2024-11-12 08:10:40 +03:00
Zakhar Izmaylov 3aeada3f33 Refactoring 2024-11-12 08:10:28 +03:00
Zakhar Izmaylov a4ca53eda9 Refactoring admin 2024-11-12 07:51:54 +03:00
Vladislav Lisitsyn 2e9eb4a627 Merge pull request #85 from izzzzzi/main
Fix formating and freekassa
2024-11-12 00:17:33 +03:00
Zakhar Izmaylov c5c3b4949b Fix formating and freekassa 2024-11-12 00:11:33 +03:00
Vladless e740f8658d Merge branch 'main' of https://github.com/izzzzzi/Solo_bot into izzzzzi-main 2024-11-11 23:35:49 +03:00
Zakhar Izmaylov 223636d38a Fix texts 2024-11-11 23:35:01 +03:00
Zakhar Izmaylov 15497f28fc Small refactoring admin 2024-11-11 23:17:09 +03:00
Zakhar Izmaylov 71563cac94 Fix bot.py 2024-11-11 23:11:14 +03:00
Zakhar Izmaylov f7b5ec93e6 Fix, Update menu 2024-11-11 23:07:54 +03:00
Vladislav Lisitsyn 397758ab9b Merge pull request #83 from izzzzzi/main
Add multypay
2024-11-11 23:03:26 +03:00
Zakhar Izmaylov 83eb59f663 Remove btn 2024-11-11 22:49:45 +03:00
Zakhar Izmaylov 282c7e83d5 Add multypay 2024-11-11 22:45:11 +03:00
Vladislav Lisitsyn 24351883a6 Merge pull request #82 from izzzzzi/main
Add stars pay(beta)
2024-11-11 22:38:17 +03:00
Vladless 1daa85ecc5 fix freekassa 2024-11-11 22:37:41 +03:00
Zakhar Izmaylov 77ea53fda2 Merge branch 'main' of https://github.com/izzzzzi/Solo_bot 2024-11-11 22:33:03 +03:00
Zakhar Izmaylov c2ce824c0e Fix cryptobot 2024-11-11 22:31:52 +03:00
Zahar Izmailov 4e7a3d5d28 Merge branch 'Vladless:main' into main 2024-11-11 22:28:00 +03:00
Zakhar Izmaylov c3bfa7b1c9 Add stars pay(beta) 2024-11-11 22:26:10 +03:00
Vladislav Lisitsyn 69620db5cd 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 a853e53b9f Add crypto(beta), fix import, fix requirements.txt 2024-11-11 21:51:34 +03:00
Vladislav Lisitsyn 4789246da2 Update README.md 2024-11-11 17:51:44 +03:00
Vladislav Lisitsyn 2343187621 Update README.md 2024-11-11 17:51:12 +03:00
Vladislav Lisitsyn d6412249c8 Merge pull request #80 from izzzzzi/main
Add base cryptobot
2024-11-11 17:48:31 +03:00
Vladless ca056b0add minPython 3.10/py3xui/subs/rm_artefacts 2024-11-11 17:47:26 +03:00
Zakhar Izmaylov b036727e3b Add base cryptobot 2024-11-11 14:26:00 +03:00
Vladless eddaadd1cc isort settings 2024-11-11 09:22:14 +03:00
Vladless 7092f1dd7b bug fix 2024-11-11 07:42:22 +03:00
Vladislav Lisitsyn 0f374e38ae Merge pull request #79 from izzzzzi/main
Add make and config for formatting
2024-11-10 23:12:42 +03:00
Zakhar Izmaylov e524e439b1 Add make and config for formatting 2024-11-10 11:02:15 +03:00
Vladless 6f5aa1e3f4 reset traffic 2024-11-10 03:31:19 +03:00
Vladless 0697873742 bug and fix 2024-11-10 02:25:36 +03:00
Vladislav Lisitsyn 7fb5fb9844 Merge pull request #78 from izzzzzi/main
Refactoring and fix
2024-11-09 23:59:29 +03:00
Zakhar Izmaylov a791314e1f Fix 2024-11-09 23:20:08 +03:00
Zakhar Izmaylov e49065fe7a Small fix 2024-11-09 16:23:39 +03:00
Zakhar Izmaylov 7222dc327d Refactoring admin_panel.py 2024-11-09 15:54:16 +03:00
Zakhar Izmaylov dfb576969f Refactoring notifications.py and commands.py 2024-11-09 15:49:05 +03:00
Zakhar Izmaylov 8033fdc27d Refactoring profile.py 2024-11-09 15:35:22 +03:00
Zakhar Izmaylov 05a324e4d6 Refactoring start.py 2024-11-09 14:38:30 +03:00
Zakhar Izmaylov 6ca254e705 Add middleware logging 2024-11-09 12:20:07 +03:00
Zakhar Izmaylov 3a41a522d7 Add middlewaer datatabase # TODO 2024-11-09 12:17:07 +03:00
Zakhar Izmaylov 43ba2aab01 Delete wraps 2024-11-09 12:01:24 +03:00
Zakhar Izmaylov 7b9e5914c0 isort and black 2024-11-09 11:44:15 +03:00
Zakhar Izmaylov 76dd0fd1f6 Migrate logging for loguru 2024-11-09 11:41:48 +03:00
Zakhar Izmaylov ff2c66784d Fix logging 2024-11-09 11:33:17 +03:00
Zakhar Izmaylov 7ca92c1627 Fix callback_query 2024-11-09 11:17:11 +03:00
Zakhar Izmaylov c85583d26a Fix PAYMENT_OPTIONS and RENEWAL_PLANS 2024-11-09 10:52:41 +03:00
Zakhar Izmaylov c68a5794af Refactoring and fix 2024-11-09 10:36:16 +03:00
Vladislav Lisitsyn 489a36d092 Update README.md 2024-11-08 07:13:42 +03:00
Vladislav Lisitsyn dc69c225b6 Update README.md 2024-11-08 07:08:20 +03:00
Vladislav Lisitsyn dc61a13894 Merge pull request #77 from Vladless/subscriptions-feature
preview v2.
2024-11-08 07:07:00 +03:00
Vladless 9ac1af9203 preview v2. 2024-11-08 07:06:32 +03:00
Vladislav Lisitsyn 6a1683f082 Merge pull request #76 from Vladless/subscriptions-feature
preview v3.
2024-11-08 07:05:30 +03:00
Vladless 4e8c949409 preview v3. 2024-11-08 07:04:51 +03:00
Vladislav Lisitsyn bc1ac54e9c Merge pull request #75 from Vladless/subscriptions-feature
v3. Поправлены уведомления для новой версии.
2024-11-08 06:53:12 +03:00
Vladless bb6e609a1e v3. Поправлены уведомления для новой версии. 2024-11-08 06:52:37 +03:00
Vladislav Lisitsyn 78959dc65e Merge pull request #74 from Vladless/subscriptions-feature
v3. Админка переведена на новую версию.
2024-11-08 06:43:15 +03:00
Vladless 58f9e8279c v3. Админка переведена на новую версию. 2024-11-08 06:42:12 +03:00
Vladislav Lisitsyn 9c8d7d1442 Merge pull request #73 from Vladless/subscriptions-feature
Админка
2024-11-08 06:03:34 +03:00
Vladless 52b6baeb3c v3. Админка переведена на новую версию. 2024-11-08 06:02:29 +03:00
Vladless f5ac31919b v3. Админка переведена на новую версию. 2024-11-08 06:02:07 +03:00
Vladislav Lisitsyn 06d851ce5f Update README.md 2024-11-07 07:58:58 +03:00
Vladislav Lisitsyn 7f3bf67bba Update README.md 2024-11-07 07:58:41 +03:00
Vladislav Lisitsyn f66caed616 Merge pull request #72 from Vladless/subscriptions-feature
v3. Полноценные подписки
2024-11-07 07:56:55 +03:00
Vladless 53a56e00cb v3. Полноценные подписки 2024-11-07 07:53:29 +03:00
Vladislav Lisitsyn d5b3a94ea9 Merge pull request #71 from Vladless/subscriptions-feature
Проверка ключей
2024-11-05 22:02:36 +03:00
Vladless 26040dc3f2 Проверка ключей 2024-11-05 22:00:32 +03:00
Vladislav Lisitsyn e3720e9be9 Merge pull request #70 from Vladless/subscriptions-feature
Админка. Расширение
2024-11-04 01:10:40 +03:00
Vladless 57e554bbab Админка. Расширение 2024-11-04 01:10:02 +03:00
Vladislav Lisitsyn 60ba811a35 Merge pull request #69 from Vladless/subscriptions-feature
Переработка уведомлений
2024-11-03 23:42:07 +03:00
Vladless fa5fa839cc Переработка уведомлений 2024-11-03 23:41:26 +03:00
Vladislav Lisitsyn 7cf43f064c Merge pull request #68 from Vladless/subscriptions-feature
Профиль. Логи
2024-11-03 21:54:11 +03:00
Vladless 4efdae4f9a Профиль. Логи 2024-11-03 21:52:59 +03:00
Vladislav Lisitsyn 4baf4cfbe1 Merge pull request #67 from Vladless/subscriptions-feature
Уведомления/Профиль. Логгирование
2024-11-03 21:40:41 +03:00
Vladless 8d2821a001 Профиль. Логи 2024-11-03 21:39:56 +03:00
Vladless e0f30ae22c Уведомления. Правки 2024-11-03 21:38:26 +03:00
Vladislav Lisitsyn 19b826ff74 Merge pull request #66 from Vladless/subscriptions-feature
Уведомления. Правки
2024-11-03 01:49:54 +03:00
Vladless c47de57e18 Уведомления. Правки 2024-11-03 01:49:02 +03:00
Vladislav Lisitsyn 072740523c Update README.md 2024-11-03 00:34:00 +03:00
Vladislav Lisitsyn d1df5aab77 Merge pull request #65 from Vladless/subscriptions-feature
добавление freekassa
2024-11-03 00:32:38 +03:00
Vladless e0e75f83ba добавление freekassa 2024-11-03 00:31:57 +03:00
Vladislav Lisitsyn 450b6a4173 Update README.md 2024-11-02 04:30:23 +03:00
Vladislav Lisitsyn eb1afaf43f Update README.md 2024-11-02 04:30:01 +03:00
Vladislav Lisitsyn 6c4a403c6e Update README.md 2024-11-02 04:29:12 +03:00
Vladislav Lisitsyn 09356c1766 Merge pull request #64 from Vladless/subscriptions-feature
Подписки. Обновление ключей. Кнопки
2024-11-02 04:25:49 +03:00
Vladislav Lisitsyn f5aed3c686 Merge branch 'main' into subscriptions-feature 2024-11-02 04:25:39 +03:00
Vladless f55c72f3b5 удобная смена подписки 2024-11-02 04:17:54 +03:00
Vladless b8e3096c4e перевод на v2raytun 2024-11-02 03:10:49 +03:00
Vladless 701521a3ff перевод на v2raytun 2024-11-02 01:17:04 +03:00
Vladless 1437686f55 перевод на v2raytun 2024-11-01 23:52:21 +03:00
Vladislav Lisitsyn cc677fff20 Update README.md 2024-10-31 23:59:20 +03:00
Vladislav Lisitsyn 57ec859b94 Merge pull request #63 from Vladless/subscriptions-feature
Кнопка автодобавления конфигурации в клиентское приложение
2024-10-30 20:07:12 +03:00
Vladless fe6ce1e4ae Кнопка автодобавления конфигурации в клиентское приложение 2024-10-30 20:05:38 +03:00
Vladislav Lisitsyn 821d6d0cbe Merge pull request #62 from Vladless/subscriptions-feature
Подписки. Полная версия
2024-10-30 18:03:36 +03:00
Vladless e62133cb58 Подписки. Полная версия 2024-10-30 18:03:04 +03:00
Vladless 0341acf1c4 ReadMe 2024-10-30 04:50:53 +03:00
Vladless 791824041b ReadMe 2024-10-30 03:57:27 +03:00
Vladless 033f664903 Подписки. initial commit 2024-10-30 02:35:27 +03:00
Vladislav Lisitsyn 25cd2167a5 Merge pull request #61 from Vladless/new1
правки
2024-10-30 00:02:43 +03:00
Vladislav Lisitsyn 2c8dc22770 Delete handlers/__pycache__ directory 2024-10-30 00:02:27 +03:00
Vladless ed55459e37 правки 2024-10-30 00:00:18 +03:00
Vladislav Lisitsyn e6df11b6f0 Merge pull request #60 from Vladless/new1
исправлен траффик при продлении
2024-10-29 23:46:37 +03:00
Vladless 3fba9fa9a0 исправлен траффик при продлении 2024-10-29 23:45:27 +03:00
Vladislav Lisitsyn 137ff46f20 Merge pull request #59 from Vladless/new1
Уведомления. Имя сервера вместо id
2024-10-29 23:20:22 +03:00
Vladless c7540417b0 Уведомления. Имя сервера вместо id 2024-10-29 23:19:54 +03:00
Vladislav Lisitsyn 004e611066 Merge pull request #57 from Vladless/new1
rm update_bot
2024-10-28 19:50:29 +03:00
Vladless bbe54a933d rm update_bot 2024-10-28 19:49:03 +03:00
Vladislav Lisitsyn 544b85b479 Merge pull request #56 from Vladless/new1
New1
2024-10-28 01:01:44 +03:00
Vladislav Lisitsyn f6c9f57e8a Merge branch 'main' into new1 2024-10-28 01:01:34 +03:00
Vladless 690f72df19 ReadMe 2024-10-28 01:00:20 +03:00
Vladless d5a08b1183 rm 2024-10-28 00:54:22 +03:00
Vladless 5e69e9d1d8 rm 2024-10-28 00:47:09 +03:00
Vladislav Lisitsyn 12bc7afedf Merge pull request #55 from Vladless/new1
rm
2024-10-28 00:39:58 +03:00
Vladislav Lisitsyn 975b8c7b2d rm 2024-10-28 00:35:07 +03:00
Vladislav Lisitsyn 34b8a27c36 rm 2024-10-28 00:30:27 +03:00
Vladislav Lisitsyn 8471da220b Merge pull request #54 from Vladless/new1
Уведомления. Продление по кнопке
2024-10-27 23:48:03 +03:00
Vladless eba39e4c3e Уведомления. Продление по кнопке 2024-10-27 22:58:56 +03:00
Vladislav Lisitsyn f347146be6 Merge pull request #53 from Vladless/new1
/admin. GUI интерфейс в боте
2024-10-27 22:47:09 +03:00
Vladless 49f326af61 /admin. GUI интерфейс в боте 2024-10-27 22:45:16 +03:00
Vladislav Lisitsyn b08d8c7f16 Merge pull request #52 from Vladless/new1
Админ. Установка времени ключей
2024-10-27 06:16:55 +03:00
Vladless c23f7ce091 Админ. Установка времени ключей 2024-10-27 06:15:34 +03:00
Vladislav Lisitsyn 1d879b18cb Merge pull request #51 from Vladless/new1
New1
2024-10-27 03:55:35 +03:00
Vladless e0aa14b5e9 обложка 2024-10-27 03:54:52 +03:00
Vladless b731d7dbf6 обложка 2024-10-27 03:54:19 +03:00
Vladislav Lisitsyn 0ae3d27532 Delete handlers/__pycache__ directory 2024-10-27 03:20:45 +03:00
Vladislav Lisitsyn 3e93a50ea7 Merge pull request #50 from Vladless/new1
Полная реструктуризация. Тексты все в texts.py
2024-10-27 03:17:40 +03:00
Vladless 1c19bf6205 Полная реструктуризация. Тексты все в texts.py 2024-10-27 03:16:58 +03:00
Vladislav Lisitsyn 3074a7e5cf Merge pull request #49 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:30:08 +03:00
Vladless 374965ad3c Порядок. Исправления мелких недочетов 2024-10-27 01:29:34 +03:00
Vladislav Lisitsyn 6571689c18 Merge pull request #48 from Vladless/new1
Порядок. Исправления мелких недочетов
2024-10-27 01:10:58 +03:00
Vladless edc00c6687 Порядок. Исправления мелких недочетов 2024-10-27 01:03:00 +03:00
Vladislav Lisitsyn 12b865ea0d Update trial_key.py 2024-10-26 16:05:58 +03:00
Vladislav Lisitsyn d4d4b94d45 Merge pull request #47 from Vladless/new1
админ. команда баланса
2024-10-26 03:11:00 +03:00
Vladless 864a464f7b админ. команда баланса 2024-10-26 03:10:28 +03:00
Vladislav Lisitsyn db06db6dc8 Merge pull request #46 from Vladless/new1
админка в боте
2024-10-26 02:45:43 +03:00
Vladless bd3f36e99e админка в боте 2024-10-26 02:45:19 +03:00
Vladislav Lisitsyn 46569a4320 Merge pull request #45 from Vladless/new1
админка в боте
2024-10-26 02:40:03 +03:00
Vladless cc027e1b3f админка в боте 2024-10-26 02:39:35 +03:00
Vladislav Lisitsyn bd8611f989 Merge pull request #44 from Vladless/new1
админка в боте
2024-10-26 02:34:39 +03:00
Vladless 6f14182c29 админка в боте 2024-10-26 02:34:05 +03:00
Vladislav Lisitsyn dd006dbcdc Merge pull request #43 from Vladless/new1
ReadMe
2024-10-26 02:19:28 +03:00
Vladless 4d607475a3 ReadMe 2024-10-26 02:18:48 +03:00
Vladislav Lisitsyn f9666597ac Merge pull request #42 from Vladless/new1
Docker и docker-compose
2024-10-26 02:13:46 +03:00
Vladless 536409ba73 Docker и docker-compose 2024-10-26 02:12:34 +03:00
Vladislav Lisitsyn dd3b36a7c1 Merge pull request #41 from Vladless/new1
создание ключа в 1 кнопку
2024-10-26 00:57:22 +03:00
Vladless 38be3f883e создание ключа в 1 кнопку 2024-10-26 00:55:03 +03:00
Vladislav Lisitsyn ca1376228d Merge pull request #40 from Vladless/new1
Автопродление ключа
2024-10-25 22:21:07 +03:00
Vladless f0976a2c9c Автопродление ключа 2024-10-25 22:20:12 +03:00
Vladislav Lisitsyn 601024b5bd Merge pull request #39 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:56:48 +03:00
Vladless a6ff07b9eb Логи. Уведомления. Тексты 2024-10-25 20:56:02 +03:00
Vladislav Lisitsyn a32870bc40 Merge pull request #38 from Vladless/new1
Логи. Уведомления. Тексты
2024-10-25 20:46:56 +03:00
Vladless 42395ac414 Логи. Уведомления. Тексты 2024-10-25 20:45:12 +03:00
Vladislav Lisitsyn f5a99dcd7b Merge pull request #37 from Vladless/new1
New1
2024-10-22 02:07:33 +03:00
Vladless 5c5f4bd41b основной текст в отдельном файле 2024-10-22 02:06:58 +03:00
Vladless 9ead4cfd7c основной текст в отдельном файле 2024-10-22 02:02:17 +03:00
Vladislav Lisitsyn 3571c69e24 Merge pull request #36 from Vladless/new1
основной текст в отдельном файле
2024-10-22 01:55:10 +03:00
Vladless e0b14d60a2 основной текст в отдельном файле 2024-10-22 01:53:50 +03:00
Vladless 42d5390564 поправка подсчета людей 2024-10-22 01:00:56 +03:00
Vladless 3a12586954 автоввод паролей 2024-10-22 00:30:30 +03:00
Vladless 3341b8bddf Бэкапы. команда ручного бэкапа 2024-10-22 00:25:16 +03:00
Vladless 046b60a3d7 Бэкапы. команда ручного бэкапа 2024-10-22 00:13:13 +03:00
Vladless e48cb2c714 Бэкапы. Отправка админу 2024-10-21 23:59:26 +03:00
Vladless b7ff35eaa2 requirements 2024-10-21 23:40:50 +03:00
Vladless 36efa8d98d версия бота. Релиз 2024-10-21 03:36:29 +03:00
Vladless 50ae45de46 версия бота. Релиз 2024-10-21 02:52:34 +03:00
Vladless df3d6cf730 напоминание за сутки у активного клиента 2024-10-21 02:47:59 +03:00
Vladless e57e66b7f4 ReadMe.md 2024-10-21 02:35:53 +03:00
Vladless 7612c10b3e напоминание за сутки у активного клиента 2024-10-21 02:26:15 +03:00
Vladless 2dff6cf884 ReadMe.md 2024-10-21 02:16:37 +03:00
Vladless a4af65ed07 завершщение работы без убивания процесса 2024-10-21 02:08:47 +03:00
Vladless 37f6165f10 резервные копии базы данных и уведомления 2024-10-21 01:23:27 +03:00
Vladless 1d9919f866 автопродление ключа 2024-10-13 23:11:39 +03:00
Vladless 93c69eed8b уведомление о неиспользованном триале 2024-10-13 04:02:47 +03:00
Vladless d54f05dc77 ввод своей суммы для пополнения баланса 2024-10-13 03:37:14 +03:00
Vladless a04be3da93 значительная переработка интерфейса 2024-10-13 02:51:45 +03:00
Vladless a85e703c97 значительная переработка интерфейса 2024-10-12 03:14:01 +03:00
Vladless 01cbc5f9b7 ReadMe 2024-10-09 04:01:08 +03:00
Vladislav Lisitsyn 407c0aafa0 Merge pull request #35 from Vladless/youkassa
ReadMe
2024-10-09 03:41:23 +03:00
Vladislav Lisitsyn cfe03f962a Merge branch 'main' into youkassa 2024-10-09 03:41:11 +03:00
Vladless ab3dd272b3 ReadMe 2024-10-09 03:40:31 +03:00
Vladislav Lisitsyn bc2839a3cd Merge pull request #34 from Vladless/youkassa
Уведомления
2024-10-09 02:16:07 +03:00
Vladless 213a829a08 логика, отправка уведомлений всем 2024-10-09 02:14:41 +03:00
Vladless c1db2c127a логика, отправка уведомлений всем 2024-10-09 02:13:42 +03:00
Vladless 62703bd08c логика, отправка уведомлений всем 2024-10-09 02:09:22 +03:00
Vladislav Lisitsyn b00a85faa7 Merge pull request #33 from Vladless/youkassa
aiohttp, уведомления, правки
2024-10-09 01:10:30 +03:00
Vladislav Lisitsyn 9475cdab1a Merge branch 'main' into youkassa 2024-10-09 01:10:23 +03:00
Vladless be6483db5b aiohttp, уведомления, правки 2024-10-09 00:51:22 +03:00
Vladislav Lisitsyn 11ddada01e Merge pull request #31 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 03:10:09 +03:00
Vladless 4546a4879d правка сообщения пригласить 2024-10-04 03:09:32 +03:00
Vladislav Lisitsyn b64fd4efca Merge pull request #30 from Vladless/youkassa
правка сообщения пригласить
2024-10-04 02:45:26 +03:00
Vladless a3af2cdb9e правка сообщения пригласить 2024-10-04 02:44:32 +03:00
Vladislav Lisitsyn b2f1f0d79b Merge pull request #29 from Vladless/youkassa
Youkassa
2024-10-04 02:39:12 +03:00
Vladless 4cca688510 правка сообщения пригласить 2024-10-04 02:35:48 +03:00
Vladless 98514637cb реферальная система 2024-10-04 02:31:05 +03:00
Vladislav Lisitsyn 9f2ae3b22e Merge pull request #28 from Vladless/youkassa
Youkassa
2024-10-04 02:13:12 +03:00
Vladless cd49d3cbc1 реферальная система 2024-10-04 02:06:16 +03:00
Vlad 202bb329c5 чистота кода 2024-10-03 07:20:13 +03:00
Vladislav Lisitsyn b7692a361e Merge pull request #27 from Vladless/youkassa
Youkassa
2024-10-03 07:09:08 +03:00
Vlad d339377181 чистота кода 2024-10-03 07:08:17 +03:00
Vlad 4ad82b8c60 уведомления за 10 часов и при истечении 2024-10-03 06:52:24 +03:00
Vladislav Lisitsyn 9d318aa245 Merge pull request #26 from Vladless/youkassa
Выбор сервера
2024-10-03 06:45:59 +03:00
Vlad fca465c174 поправлен тег 2024-10-03 06:45:07 +03:00
Vlad d9522db74c заполненность серверов в процентах 2024-10-03 06:42:40 +03:00
Vlad 4d2f40d36c работающий перенос ключей 2024-10-03 06:31:44 +03:00
Vlad 944aeae040 работающий перенос ключей 2024-10-03 06:20:38 +03:00
Vlad 84f1049371 выбор сервера, переработка ссылок 2024-10-03 04:43:55 +03:00
Vladislav Lisitsyn 5b8477affc Merge pull request #25 from Vladless/youkassa
смена слогана
2024-10-02 05:16:33 +03:00
Vlad 35b35b8b44 смена слогана 2024-10-02 05:12:55 +03:00
Vladislav Lisitsyn f82512c168 Merge pull request #24 from Vladless/youkassa
Youkassa
2024-10-02 05:04:01 +03:00
Vlad f36be88c01 смена слогана 2024-10-02 05:03:08 +03:00
Vlad 75289fddc5 смена слогана 2024-10-02 04:51:48 +03:00
Vlad 95fbb67489 смена слогана 2024-10-02 04:46:05 +03:00
Vladislav Lisitsyn 9e46bfee97 Merge pull request #23 from Vladless/youkassa
новостные сообщения, поправленный requirements
2024-10-02 04:16:36 +03:00
Vlad 2f304fda78 новостные сообщения, поправленный requirements 2024-10-02 04:16:04 +03:00
Vladislav Lisitsyn 914d5a5bf8 Merge pull request #22 from Vladless/youkassa
истекшие ключи автоматически удаляются
2024-10-02 02:29:44 +03:00
Vlad ba3a2c7765 истекшие ключи автоматически удаляются 2024-10-02 02:06:42 +03:00
Vlad 856bf546c5 порядок в стурктуре проекта, isort, импорты 2024-10-02 01:35:39 +03:00
Vlad a6ef7f2f68 Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:19:48 +03:00
Vladislav Lisitsyn 520988f013 Merge pull request #21 from Vladless/youkassa
Доработка платежей, уведомления и переработка интерфейса
2024-10-02 01:13:35 +03:00
Vlad 0f51f45e9a Доработка платежей, уведомления и переработка интерфейса 2024-10-02 01:13:04 +03:00
Vladislav Lisitsyn ca31d34949 Merge pull request #20 from Vladless/youkassa
Оплата и пополнение баланса через Юкасса
2024-10-01 14:27:58 +03:00
Vlad 2b2529a5d1 Оплата и пополнение баланса через Юкасса 2024-10-01 14:27:16 +03:00
Vladislav Lisitsyn 2a06cb6105 Merge pull request #19 from Vladless/new
WebHooks for YouKassa
2024-10-01 02:10:28 +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
520 changed files with 79655 additions and 6221 deletions
-6
View File
@@ -1,6 +0,0 @@
[flake8]
max-line-length = 120
ignore = E203, E266, E501, W503, F541, E704, W293, W291, E126, E121, E123, E128, E302, E131, E231, W292, E402, E261, E305, E701
max-complexity = 15
select = B, C, E, F, W, T4, B9
exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build,.txt,config.py
+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).
+17
View File
@@ -0,0 +1,17 @@
#!/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
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).
+35 -8
View File
@@ -7,6 +7,7 @@ __pycache__/
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
alembic/
# Virtual environments # Virtual environments
venv/ venv/
@@ -26,14 +27,19 @@ env/
*.db *.db
# Sensitive configuration files # Sensitive configuration files
config.py /config.py
config.ini /config.ini
.env /alembic.ini
/.env
# Backup files # Backup files
*.bak *.bak
*.swp *.swp
*~ *~
backups/
backup_bot/
Solo_backup/
.cursor/
# Specific project files # Specific project files
vpn_users.db vpn_users.db
@@ -41,7 +47,6 @@ database.db
bot_old.py bot_old.py
bot_old_2.py bot_old_2.py
backup_pg.sh backup_pg.sh
docker-compose.yml
config copy.py config copy.py
handlers/texts.py handlers/texts.py
@@ -50,7 +55,29 @@ handlers/texts.py
Thumbs.db Thumbs.db
nginx.conf nginx.conf
scripts /scripts/load_balancer.py
models.py /scripts/__pycache__
Dockerfile .csv
.csv /logs
.ruff_cache
modules/
storage/
static/web_uploads/
.license_state
Solo_backup/
# Node / Next.js artifacts (root — web-app has its own)
node_modules/
.next/
.vite/
.claude/
.cursor/hooks.json
.cursor/hooks/after-agent-response.cjs
.cursor/hooks/before-submit-prompt.cjs
.cursor/hooks/after-agent-response.js
.cursor/hooks/before-submit-prompt.js
.cursor/cursor-notifier.json
.cursor/cursor-notifier-start.json
nuitka-crash-report*
+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.
Внося изменения, вы соглашаетесь, что ваш вклад распространяется на условиях этой лицензии. Перепродажа или коммерческое распространение этого кода без явного письменного согласия правообладателя не допускаются.
---
Ваш вклад помогает проекту расти и становиться лучше.
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
COPY . .
RUN rm -rf /app/venv \
&& python -m venv /app/venv \
&& /app/venv/bin/pip install --upgrade pip \
&& /app/venv/bin/pip install -r requirements.txt
RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /app/backups \
&& chown -R appuser:appuser /app
USER appuser
CMD ["/app/venv/bin/python", "main.py"]
+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.
+19 -5
View File
@@ -1,5 +1,19 @@
formatting: format:
@echo "Running black..." && black . @echo "Running Ruff format..." && ruff format . --config pyproject.toml --exclude main.py,handlers/payments
@echo "Running isort..." && isort . @echo "Running Ruff..." && ruff check . --config pyproject.toml --exclude main.py,handlers/payments --fix
@echo "Running flake8..." && flake8 --config .flake8
# @echo "Running pylint..." && pylint . lint:
@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
test:
@echo "Running unit tests..." && cd /tmp && PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
test-sudo:
@echo "Running unit tests with sudo..." && cd /tmp && sudo env PYTHONPATH="$(CURDIR)" "$(CURDIR)/venv/bin/python" -m unittest discover -s "$(CURDIR)/tests" -q
smoke:
@echo "Running smoke checks..." && bash "$(CURDIR)/tests/smoke_runner.sh"
+201 -138
View File
@@ -1,148 +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)
Три версии — море возможностей: <h1 style="margin:0;font-weight:900;letter-spacing:.3px">SoloBot</h1>
- v1.4 — бот для продажи ключей vless: <p style="margin:6px 0 0;font-size:16px;opacity:.9"><b>Идеальный помощник для 3x-UI и Remnawave (VLESS)</b></p>
![Основные окна](preview.jpg)
- v2.3.1 — стабильная версия подписок вместо ключей с кнопками автодобавления в приложение:
![Основные окна](preview_v_2.jpg)
- v3.1 — настолько большая, что смотрите в [релизе](https://github.com/Vladless/Solo_bot/releases/tag/v3.1):
<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>
## 📋 Оглавление <hr style="height:1px;border:0;background:#222;margin:18px 0 16px">
1. [Описание](#описание) <p><b>Многофункциональный бот, кастомизируемый под ваш бренд.</b></p>
2. [Стек технологий](#стек-технологий) </div>
3. [Установка](#установка)
4. [Конфигурация](#конфигурация)
5. [Запуск](#запуск)
6. [Контакты](#контакты)
---
## 📖 Описание
SoloBot реализует множество функций, включая:
- **Выдача ключей на пробный период**. <h2 align="center">Основные возможности</h2>
- **Выдача ключей на длительные периоды** (1 месяц, 3 месяца, полгода, год).
- **Продление ключей** на указанные периоды. <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>
* через Юкасса (самозанятость и ИП)
* через freekassa (Физические Лица) <tr>
* Криптовалюта (Thanks [izzzzzi](https://github.com/izzzzzi)) <td><b>Подписки</b></td>
- Периодические **бэкапы базы данных клиентов**. <td>Произвольный срок и триал<br>Продление по тарифу<br>Happ / Hiddify / v2RayTun<br>Кастом заголовков</td>
- Уведомления: </tr>
- Произвольные сообщения через админку.
- Уведомления о неиспользованных пробниках. <tr>
- Уведомления о истекающих ключах (за сутки, за 6 часов и в момент истечения). <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>
<tr>
<td><b>Рефералы</b></td>
<td>Уникальные ссылки<br>Инлайн и обычные сообщения<br>% или фикс</td>
</tr>
<tr>
<td><b>UTM-аналитика</b></td>
<td>Трекинг переходов<br>Привязка к рефералам / купонам / триалам<br>Конверсии<br>Просмотр в админке</td>
</tr>
<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>
<tr>
<td><b>Валютность</b></td>
<td>Оплата в разных валютах</td>
</tr>
<tr>
<td><b>Надёжность</b></td>
<td>Бэкапы<br>Смена домена<br>Проверка доступности<br>Уведомления об аптайме</td>
</tr>
<tr>
<td><b>Уведомления</b></td>
<td>О скором окончании подписки<br>О непотраченном трафике</td>
</tr>
<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>
<tr>
<td><b>Админка</b></td>
<td>Поиск TG/username/ключ/email<br>Баланс / подписки / заморозки<br>Перезагрузка<br>Бан-лист · купоны · UTM · статистика<br>Синхронизация / импорт из панели</td>
</tr>
<tr>
<td><b>Модульность</b></td>
<td>Подключаемые модули<br>Вкл/выкл и перезапуск из админки<br>Обновления и версии<br>Возможность дорабатывать код не теряя обновления</td>
</tr>
<tr>
<td><b>Кастомизация</b></td>
<td>Гибкая настройка функций и логики<br>Возможность отключения отдельных функций</td>
</tr>
</table>
<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">Регулярные релизы. Обновление через 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** версии 3.8 или выше.
- **Git** для клонирования репозитория.
- **Virtualenv** для создания виртуального окружения (рекомендуется).
- **PostgreSQL** для хранения данных.
- **Nginx** для работы с вебхуками.
- **aiogram** (версия 3.13) для взаимодействия с Telegram API.
- **youkassa** для обработки платежей.
- **aiohttp** для API запросов к панелям.
Проект полностью асинхронный, что обеспечивает высокую скорость работы.
---
## ⚙️ Установка
### 1️⃣ Шаг 1: Клонирование репозитория
Клонируйте репозиторий и перейдите в его директорию:
```bash
git clone https://github.com/Vladless/Solo_bot.git
cd solo_bot
```
### 2️⃣ Шаг 2: Создание и активация виртуального окружения
```
python3 -m venv venv
source venv/bin/activate
```
### 3️⃣ Шаг 3: Установка зависимостей
```
pip install -r requirements.txt
```
### 🛠️ Конфигурация
Для правильной работы вам нужно:
* установить и запустить postgresql, создать пользователя для работы с базой данных и выдать ему права
* Настроить ваш сервер на работу с ботом, выпустить SSL сертификат для домена
* Настроить вебхуки и пути до них в NGINX
* Создать файл config.py в корневой папке проекта с вашими данными:
```
API_TOKEN = токен вашего бота телеграм
ADMIN_USERNAME = логин от вашей панели x-ray
ADMIN_PASSWORD = пароль от вашей панели x-ray
ADD_CLIENT_URL = f"{API_URL}/panel/api/inbounds/addClient"
GET_INBOUNDS_URL = f"{API_URL}/panel/api/inbounds/list/"
AUTH_URL = f"{API_URL}/login/"
DATABASE_URL = путь к вашей базе данных, имеет вид "postgresql://{user}:{password}@{Ip-adress}:{port}/{имя базы данных}"
ADMIN_ID = ID телеграм профиля администратора
CHANNEL_URL = ссылка на ваш телеграм канал
YOOKASSA_SECRET_KEY = ваш ключ юкассы
YOOKASSA_SHOP_ID = ваш шопайди
WEBHOOK_HOST = адрес вашего сервера для вебхуков
WEBHOOK_PATH = '/webhook/'
WEBHOOK_URL = f"{WEBHOOK_HOST}{WEBHOOK_PATH}"
SUPPORT_CHAT_URL = ваша ссылка на поддержку
```
**Мы высылаем детальный гайд и недостающие файлы в боте**
**Все описания в одном файле!** Удобно настроить бот под свой сервис изменив информацию и цены в одном месте
### 🚀 Запуск
введите команду из виртуального окружения
```
python main.py
```
### 🔗 SoloBot в Telegram и Полная версия
Попробуйте SoloBot прямо сейчас в Telegram [по этой ссылке](https://t.me/SoloNetVPN_bot).
Вы также можете поддержать автора или купить полную версию бота с сопровождением по установке в поддержке бота!
Полная пошаговая инстуркция по запуску бота предоставляется в поддержке бота
+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 без риска для данных и доступности.
- Чёткие и воспроизводимые шаги.
- Возможные предложения по снижению риска до исправления.
+259
View File
@@ -0,0 +1,259 @@
import hashlib
from collections.abc import AsyncGenerator
from urllib.parse import urlparse
from fastapi import Depends, HTTPException, Header, Query, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime
from audit import set_api_actor
from database import (
async_session_maker,
identities as idb,
identity_sessions as idsess,
)
from database.access.resolution import ResolvedActor, resolve_actor_from_identity
from database.models import Admin, Identity
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
async def bind_identity_actor(
request: Request | None,
session: AsyncSession,
identity: Identity,
) -> ResolvedActor:
actor = await resolve_actor_from_identity(session, identity)
set_api_actor(request, identity_id=actor.identity_id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
return actor
def get_request_actor(request: Request | None) -> ResolvedActor | None:
if request is None:
return None
return getattr(request.state, "actor", None)
async def verify_admin_token(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
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")
set_api_actor(request, tg_id=admin.tg_id)
return admin
AUTH_COOKIE_NAME = "auth_token"
IS_ADMIN_COOKIE_NAME = "is_admin"
AUTH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
def _is_secure_request(request: Request | None) -> bool:
if request is None:
return False
if request.url.scheme == "https":
return True
forwarded_proto = request.headers.get("x-forwarded-proto", "").lower()
return forwarded_proto == "https"
def set_auth_cookie(response: Response, token: str, request: Request | None = None) -> None:
"""Устанавливает HttpOnly cookie с auth-токеном на ответ. Используется во всех login-ручках."""
response.set_cookie(
key=AUTH_COOKIE_NAME,
value=token,
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def clear_auth_cookie(response: Response, request: Request | None = None) -> None:
"""Удаляет auth cookie на стороне браузера."""
response.delete_cookie(
key=AUTH_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
clear_is_admin_cookie(response, request)
def set_is_admin_cookie(response: Response, identity: Identity, request: Request | None = None) -> None:
"""Ставит/гасит `is_admin` cookie в зависимости от текущей identity."""
if getattr(identity, "is_admin", False):
response.set_cookie(
key=IS_ADMIN_COOKIE_NAME,
value="1",
max_age=AUTH_COOKIE_MAX_AGE_SECONDS,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
else:
clear_is_admin_cookie(response, request)
def clear_is_admin_cookie(response: Response, request: Request | None = None) -> None:
response.delete_cookie(
key=IS_ADMIN_COOKIE_NAME,
path="/",
httponly=True,
secure=_is_secure_request(request),
samesite="lax",
)
def _read_auth_cookie(request: Request | None) -> str | None:
if request is None:
return None
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw:
return None
raw = raw.strip()
return raw or None
async def _identity_from_cookie(session: AsyncSession, request: Request | None) -> Identity | None:
token = _read_auth_cookie(request)
if not token:
return None
token_hash = hash_token(token)
sess = await idsess.get_session_by_token_hash(session, token_hash)
if sess is None:
return None
if sess.expires_at is not None and sess.expires_at <= datetime.utcnow():
return None
identity = await idb.get_identity_by_id(session, sess.identity_id)
if identity is None:
return None
await idsess.touch_session_last_seen(session, sess)
if request is not None:
try:
request.state.auth_session = sess
except Exception:
pass
return identity
async def verify_identity_token(
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из HttpOnly cookie `auth_token`; возвращает Identity."""
from database.site_state import mark_site_initialized
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
await bind_identity_actor(request, session, identity)
if getattr(identity, "is_admin", False):
await mark_site_initialized(session)
return identity
async def verify_identity_admin(
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Проверяет токен из cookie и что identity.is_admin; для админских ручек v2."""
from database.site_state import mark_site_initialized
identity = await _identity_from_cookie(session, request)
if identity is None:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
await bind_identity_actor(request, session, identity)
await mark_site_initialized(session)
return identity
async def verify_identity_admin_short(
request: Request,
):
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
identity = None
actor = None
async with async_session_maker() as session:
identity = await _identity_from_cookie(session, request)
if identity:
actor = await resolve_actor_from_identity(session, identity)
await session.commit()
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
if not identity.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
if actor is not None:
set_api_actor(request, identity_id=identity.id, tg_id=actor.telegram_chat_id)
if request is not None:
request.state.actor = actor
else:
set_api_actor(request, identity_id=identity.id, tg_id=identity.tg_id)
return identity
async def verify_admin_token_short(
admin_id: int = Query(..., alias="tg_id"),
token: str = Header(..., alias="X-Token"),
request: Request = None,
) -> Admin:
"""Проверка админа с короткой сессией (для broadcast и др.), чтобы не держать соединение с БД."""
hashed = hash_token(token)
async with async_session_maker() as session:
result = await session.execute(select(Admin).where(Admin.tg_id == admin_id, Admin.token == hashed))
admin = result.scalar_one_or_none()
await session.commit()
if not admin:
raise HTTPException(status_code=401, detail="Unauthorized")
set_api_actor(request, tg_id=admin.tg_id)
return admin
def validate_redirect_url(url: str, base_url: str) -> str:
"""Validate redirect URL is same-origin or relative. Returns safe URL or base_url fallback."""
url = url.strip()
if not url:
return base_url
if url.startswith("/"):
return url
try:
parsed = urlparse(url)
base_parsed = urlparse(base_url)
if parsed.scheme in ("http", "https") and parsed.netloc == base_parsed.netloc:
return url
except Exception:
pass
return base_url
+219
View File
@@ -0,0 +1,219 @@
import asyncio
import hashlib
import os
from time import perf_counter
from fastapi import Depends, FastAPI, Request
from fastapi.responses import ORJSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import Response as StarletteResponse
from starlette.staticfiles import StaticFiles
from audit import ensure_api_context, log_api_access, record_api_access_event_background
from config import API_LOGGING, API_VERSION, API_CORS_ORIGINS
from database import async_session_maker
from logger import logger
if API_VERSION == 1:
from api.v1 import router as api_router, VERSION as API_DOC_VERSION
else:
from api.v2 import VERSION as API_DOC_VERSION
from api.v2.router import router as api_router
app = FastAPI(
title=f"SoloBot API (Alpha) — API v{API_DOC_VERSION}",
version=API_DOC_VERSION,
description=f"Версия API: **v{API_DOC_VERSION}**.",
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
default_response_class=ORJSONResponse,
)
_cors_origins = API_CORS_ORIGINS if API_CORS_ORIGINS != ["*"] else API_CORS_ORIGINS
_cors_credentials = API_CORS_ORIGINS != ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=_cors_credentials,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["X-Identity-Id", "X-Token", "Content-Type", "Authorization"],
)
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=6)
@app.exception_handler(Exception)
async def _generic_exception_handler(request: Request, exc: Exception):
from audit import ensure_api_context
context = ensure_api_context(request)
logger.exception("[API] Unhandled exception at {} {}: {}", request.method, request.url.path, exc)
return ORJSONResponse(
status_code=500,
content={"detail": "Внутренняя ошибка сервера", "request_id": context.request_id},
)
_ETAG_MAX_BODY_BYTES = 256 * 1024
@app.middleware("http")
async def security_and_cache_middleware(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-XSS-Protection", "1; mode=block")
content_type = response.headers.get("content-type", "")
path = request.url.path
if path.startswith("/api/web/uploads/") and request.method == "GET" and response.status_code == 200:
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
return response
if request.method == "GET" and response.status_code == 200 and "application/json" in content_type:
content_length_header = response.headers.get("content-length")
try:
cl = int(content_length_header) if content_length_header is not None else None
except (TypeError, ValueError):
cl = None
if cl is not None and cl > _ETAG_MAX_BODY_BYTES:
response.headers.setdefault("Cache-Control", "no-cache")
return response
chunks: list[bytes] = []
total = 0
too_big = False
async for chunk in response.body_iterator:
total += len(chunk)
if total > _ETAG_MAX_BODY_BYTES:
chunks.append(chunk)
too_big = True
async for remaining in response.body_iterator:
chunks.append(remaining)
break
chunks.append(chunk)
body = b"".join(chunks)
if too_big:
headers = dict(response.headers)
headers["Cache-Control"] = "no-cache"
headers.pop("content-length", None)
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
etag = '"' + hashlib.md5(body).hexdigest() + '"'
if_none_match = request.headers.get("if-none-match", "")
client_etags = [t.strip() for t in if_none_match.split(",") if t.strip()]
if etag in client_etags or if_none_match.strip() == "*":
return StarletteResponse(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
headers = dict(response.headers)
headers["ETag"] = etag
headers["Cache-Control"] = "no-cache"
return StarletteResponse(content=body, status_code=200, headers=headers, media_type=response.media_type)
response.headers.setdefault("Cache-Control", "no-store")
return response
@app.middleware("http")
async def api_access_log_middleware(request: Request, call_next):
context = ensure_api_context(request)
started = perf_counter()
if not API_LOGGING:
response = await call_next(request)
response.headers["X-Request-Id"] = context.request_id
response.headers["X-Response-Time"] = f"{int((perf_counter() - started) * 1000)}ms"
return response
try:
response = await call_next(request)
except Exception as exc:
duration_ms = int((perf_counter() - started) * 1000)
log_api_access(
request,
status_code=500,
duration_ms=duration_ms,
result="fail",
reason=type(exc).__name__,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result="fail",
reason=type(exc).__name__,
status_code=500,
)
)
raise
duration_ms = int((perf_counter() - started) * 1000)
response.headers["X-Request-Id"] = context.request_id
response.headers["X-Response-Time"] = f"{duration_ms}ms"
result = "success" if response.status_code < 400 else "fail"
log_api_access(
request,
status_code=response.status_code,
duration_ms=duration_ms,
result=result,
)
asyncio.create_task(
record_api_access_event_background(
async_session_maker,
request,
result=result,
reason=None if response.status_code < 400 else str(response.status_code),
status_code=response.status_code,
)
)
return response
@app.get("/api/health", include_in_schema=False)
async def health():
return {"status": "ok"}
from api.depends import get_session as _get_session, verify_identity_admin as _verify_admin
@app.get("/api/health/detailed", include_in_schema=False)
async def health_detailed(
session: AsyncSession = Depends(_get_session),
_identity=Depends(_verify_admin),
):
import time
from sqlalchemy import text
from core.redis_cache import _get_redis
checks: dict[str, object] = {"status": "ok", "timestamp": int(time.time())}
try:
await session.execute(text("SELECT 1"))
checks["db"] = {"ok": True}
except Exception as e:
checks["db"] = {"ok": False, "error": str(e)[:200]}
checks["status"] = "degraded"
try:
client = await _get_redis()
if client is not None:
await client.ping()
checks["redis"] = {"ok": True}
else:
checks["redis"] = {"ok": False, "error": "unavailable"}
checks["status"] = "degraded"
except Exception as e:
checks["redis"] = {"ok": False, "error": str(e)[:200]}
checks["status"] = "degraded"
return checks
app.include_router(api_router)
_web_uploads_dir = "static/web_uploads"
os.makedirs(_web_uploads_dir, exist_ok=True)
app.mount("/api/web/uploads", StaticFiles(directory=_web_uploads_dir), name="web_uploads")
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import _identity_from_cookie
async def enforce_rate_limit(
request: Request,
session: AsyncSession,
*,
bucket: str,
max_per_window: int,
window_sec: int,
identity_aware: bool = True,
) -> None:
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
except Exception:
return
owner = "anon"
if identity_aware:
try:
identity = await _identity_from_cookie(session, request)
if identity is not None and getattr(identity, "id", None):
owner = f"id:{identity.id}"
except Exception:
pass
if owner == "anon":
ip = (request.client.host if request.client else "") or "unknown"
owner = f"ip:{ip}"
key = f"rl:{bucket}:{owner}"
try:
count, redis_ok = await cache_incr_checked(key, window_sec)
if not redis_ok:
count = check_and_increment(key, max_per_window, window_sec)
except Exception:
return
if count > max_per_window:
raise HTTPException(status_code=429, detail="Слишком много запросов, подождите и попробуйте снова")
def rate_limit_dependency(*, bucket: str, max_per_window: int, window_sec: int):
from api.depends import get_session
from fastapi import Depends
async def _dep(request: Request, session: AsyncSession = Depends(get_session)) -> None:
await enforce_rate_limit(
request,
session,
bucket=bucket,
max_per_window=max_per_window,
window_sec=window_sec,
)
return _dep
+6
View File
@@ -0,0 +1,6 @@
from api.v1.router import router
VERSION = "1.0.0"
__all__ = ("router", "VERSION")
+38
View File
@@ -0,0 +1,38 @@
from fastapi import APIRouter
from api.v1.routes import (
coupons,
gifts,
keys,
management,
misc,
modules,
partners,
referrals,
servers,
settings,
tariffs,
users,
)
router = APIRouter()
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "Welcome to SoloBot API", "docs": "/api/docs"}
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.router, prefix="/api/keys", tags=["Keys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
+206
View File
@@ -0,0 +1,206 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import (
inspect as sa_inspect,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from api.depends import get_session, verify_admin_token
from database.access.resolution import resolve_user_optional
from database.models import Admin
from handlers.texts import get_site_gift_link, get_telegram_gift_link
def _apply_user_relationship_loader(model: type, stmt):
if model.__name__ in ("ManualBan", "BlockedUser", "TemporaryData"):
return stmt.options(selectinload(model.user))
return stmt
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 normalize_outgoing_object(obj: object) -> None:
if hasattr(obj, "vless") and obj.vless is None:
obj.vless = False
cls_name = type(obj).__name__
if cls_name == "Gift":
gift_id = getattr(obj, "gift_id", None)
if gift_id:
obj.telegram_gift_link = get_telegram_gift_link(gift_id)
obj.site_gift_link = get_site_gift_link(gift_id)
if cls_name in ("ManualBan", "BlockedUser", "TemporaryData"):
insp = sa_inspect(obj)
stored = getattr(obj, "tg_id", None)
if "user" in insp.unloaded:
obj.tg_id = stored
return
rel = getattr(obj, "user", None)
rel_tg = getattr(rel, "tg_id", None) if rel is not None else None
obj.tg_id = stored if stored is not None else rel_tg
def to_schema(schema_response: type, obj: object):
normalize_outgoing_object(obj)
return schema_response.model_validate(obj, from_attributes=True)
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,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
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(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
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 to_schema(schema_response, 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),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, 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 to_schema(schema_response, 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),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(exclude_unset=True)
if "days" in data and data["days"] == 0:
data["days"] = None
obj = model(**data)
session.add(obj)
await session.refresh(obj)
return to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
admin: Admin = Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
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")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.refresh(obj)
return to_schema(schema_response, 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),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
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)
return {"detail": f"{model.__name__} deleted"}
return router
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.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"],
)
+68
View File
@@ -0,0 +1,68 @@
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.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from database.access.resolution import resolve_user_optional
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),
):
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.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)
return {"message": "Подарок и связанные использования удалены"}
+143
View File
@@ -0,0 +1,143 @@
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.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from database.access.resolution import resolve_user_optional
from database.models import Admin, Key, Tariff
from logger import logger
from services.operations import create_key_on_cluster, delete_key_from_cluster, renew_key_in_cluster
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)
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 []
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.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.model_dump(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,
)
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="Ошибка при создании ключа")
+375
View File
@@ -0,0 +1,375 @@
import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token, verify_admin_token_short
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from handlers.admin.sender.scheduled_service import (
ensure_utc_datetime,
execute_broadcast_payload,
execute_scheduled_broadcast,
prepare_broadcast_payload,
scheduled_broadcast_to_dict,
)
from logger import logger
from utils.backup import backup_database
router = APIRouter()
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
channel: Literal["bot", "site", "both"] = "both"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
scheduled_for: datetime
class ScheduledBroadcastUpdatePayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
channel: Literal["bot", "site", "both"] | None = None
text: str | None = None
photo: str | None = None
cluster_name: str | None = None
workers: int | None = None
messages_per_second: int | None = None
scheduled_for: datetime | None = None
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
def _require_future_schedule(value: datetime) -> datetime:
scheduled_for = ensure_utc_datetime(value)
if scheduled_for <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
return scheduled_for
def _resolve_update_payload(
payload: ScheduledBroadcastUpdatePayload,
current: ScheduledBroadcast,
) -> dict:
fields = payload.model_fields_set
text_changed = "text" in fields
send_to = payload.send_to if "send_to" in fields else current.send_to
channel = payload.channel if "channel" in fields else current.channel
text = payload.text if "text" in fields else current.text
photo = payload.photo if "photo" in fields else current.photo
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
workers = payload.workers if "workers" in fields else current.workers
messages_per_second = (
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
)
prepared = prepare_broadcast_payload(
send_to=send_to,
text=text,
photo=photo,
cluster_name=cluster_name,
workers=workers,
messages_per_second=messages_per_second,
channel=channel,
)
if not text_changed:
prepared["text"] = current.text
prepared["keyboard_json"] = current.keyboard_json
if "scheduled_for" in fields:
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
return prepared
async def _restart_bot() -> None:
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
await run_io(lambda: 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:
os._exit(1)
@router.get("/status")
async def get_status(admin=Depends(verify_admin_token)):
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
admin=Depends(verify_admin_token),
):
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
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))
)
)
result = await session.execute(stmt)
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(admin=Depends(verify_admin_token)):
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
admin=Depends(verify_admin_token_short),
):
"""Запуск рассылки. Сессия БД не держится на время рассылки."""
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
@router.post("/broadcast/scheduled")
async def create_broadcast_schedule(
payload: ScheduledBroadcastCreatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
broadcast = await create_scheduled_broadcast(
session,
created_by_tg_id=getattr(admin, "tg_id", None),
send_to=prepared["send_to"],
channel=prepared["channel"],
cluster_name=prepared["cluster_name"],
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
scheduled_for=_require_future_schedule(payload.scheduled_for),
workers=prepared["workers"],
messages_per_second=prepared["messages_per_second"],
)
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
@router.get("/broadcast/scheduled")
async def list_broadcast_schedules(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
status: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
@router.get("/broadcast/scheduled/{broadcast_id}")
async def get_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
return {"item": scheduled_broadcast_to_dict(item)}
@router.patch("/broadcast/scheduled/{broadcast_id}")
async def update_broadcast_schedule(
broadcast_id: str,
payload: ScheduledBroadcastUpdatePayload,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
current = await get_scheduled_broadcast(session, broadcast_id)
if current is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
try:
values = _resolve_update_payload(payload, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
if updated is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
async def cancel_broadcast_schedule(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await cancel_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
async def send_broadcast_schedule_now(
broadcast_id: str,
admin=Depends(verify_admin_token_short),
session: AsyncSession = Depends(get_session),
):
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail=str(exc)) from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
+166
View File
@@ -0,0 +1,166 @@
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.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
Admin,
BlockedUser,
ManualBan,
Notification,
Payment,
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),
):
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.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=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
telegram_path_to_user_id=True,
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="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
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="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
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="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
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 []),
)
+125
View File
@@ -0,0 +1,125 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_admin_token
from core.executor import run_io
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
def sync_list_modules() -> list:
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
if callable(refresh):
refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
item["local_version"] = _read_local_module_version(name)
return modules
@router.get("/")
async def list_modules(admin=Depends(verify_admin_token)):
modules = await run_io(sync_list_modules)
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, admin=Depends(verify_admin_token)):
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"item": _module_state(name)}
+954
View File
@@ -0,0 +1,954 @@
import csv
from datetime import datetime
from io import StringIO
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import ORJSONResponse, StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
try:
from modules.partner_program.settings import PARTNER_BONUS_PERCENTAGES
except Exception:
PARTNER_BONUS_PERCENTAGES = {1: 0.0}
router = APIRouter()
def _parse_percent(value: float) -> float | None:
"""Normalize percent input to 0-100 range."""
try:
val = float(value)
except (TypeError, ValueError):
return None
if 0.0 <= val <= 1.0:
val *= 100.0
if 0.0 <= val <= 100.0:
return val
return None
def _default_partner_percent() -> float:
try:
return float(PARTNER_BONUS_PERCENTAGES.get(1, 0.0)) * 100.0
except Exception:
return 0.0
def _row_dt_iso(value) -> str | None:
if isinstance(value, datetime):
return value.isoformat()
return None
@router.get("/all")
async def get_all_partners(
limit: int = Query(1000, ge=1, le=10000, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список всех партнёров со статистикой.
Структура ответа:
{
"total": int,
"items": [
{
"tg_id": int,
"balance": float,
"percent": float,
"code": str | None,
"method": str | None,
"referred_count": int
}
]
}
"""
partners_sql = text(
"""
SELECT
p.partner_tg_id AS tg_id,
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method,
COUNT(p.joined_tg_id) as joined_count
FROM partners p
LEFT JOIN users u ON u.tg_id = p.partner_tg_id
WHERE p.partner_tg_id IS NOT NULL
GROUP BY p.partner_tg_id, u.partner_balance, u.partner_percent, u.partner_percent_custom, u.partner_code, u.payout_method
ORDER BY partner_balance DESC
LIMIT :limit OFFSET :offset
"""
)
count_sql = text(
"""
SELECT COUNT(DISTINCT partner_tg_id) FROM partners
WHERE partner_tg_id IS NOT NULL
"""
)
result = await session.execute(partners_sql, {"limit": limit, "offset": offset})
partners = result.fetchall()
count_result = await session.execute(count_sql)
total = count_result.scalar() or 0
partners_list = []
default_percent = _default_partner_percent()
for partner in partners:
percent_value = partner[2]
percent_custom = bool(partner[3])
if percent_custom and percent_value is not None:
percent = float(percent_value)
else:
percent = float(default_percent)
partners_list.append({
"tg_id": int(partner[0]),
"balance": float(partner[1] or 0),
"percent": percent,
"code": partner[4] or None,
"method": partner[5] or None,
"referred_count": int(partner[6] or 0),
})
return ORJSONResponse(content={"total": total, "items": partners_list})
@router.get("/stats/all")
async def get_partners_stats(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает общую статистику партнёрской программы.
Структура ответа:
{
"total_partners": int,
"partners_today": int,
"total_referred": int,
"total_balance": float,
"top_partner_tg_id": int,
"top_partner_refs": int
}
"""
stats_sql = text(
"""
WITH partner_refs AS (
SELECT partner_tg_id, COUNT(DISTINCT joined_tg_id) AS ref_count
FROM partners
WHERE partner_tg_id IS NOT NULL
GROUP BY partner_tg_id
)
SELECT
(SELECT COUNT(*) FROM partner_refs) AS total_partners,
(
SELECT COUNT(DISTINCT partner_tg_id)
FROM partners
WHERE partner_tg_id IS NOT NULL
AND DATE(created_at) = CURRENT_DATE
) AS partners_today,
(SELECT COUNT(DISTINCT joined_tg_id) FROM partners WHERE partner_tg_id IS NOT NULL) AS total_referred,
(
SELECT COALESCE(SUM(u.partner_balance), 0.0)
FROM users u
WHERE u.tg_id IN (SELECT partner_tg_id FROM partner_refs)
) AS total_balance,
(SELECT partner_tg_id FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_tg_id,
(SELECT ref_count FROM partner_refs ORDER BY ref_count DESC LIMIT 1) AS top_partner_refs
"""
)
stats_result = await session.execute(stats_sql)
stats_row = stats_result.fetchone()
if stats_row:
stats = {
"total_partners": int(stats_row[0] or 0),
"partners_today": int(stats_row[1] or 0),
"total_referred": int(stats_row[2] or 0),
"total_balance": float(stats_row[3] or 0.0),
"top_partner_tg_id": int(stats_row[4] or 0),
"top_partner_refs": int(stats_row[5] or 0),
}
else:
stats = {
"total_partners": 0,
"partners_today": 0,
"total_referred": 0,
"total_balance": 0.0,
"top_partner_tg_id": 0,
"top_partner_refs": 0,
}
return ORJSONResponse(content=stats)
@router.patch("/{tg_id}")
async def update_partner(
tg_id: int = Path(..., description="Telegram ID партнёра"),
balance: float = Query(..., description="Новый баланс партнёра"),
percent: float = Query(..., description="Новый процент партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет данные партнёра (баланс и процент).
Структура ответа:
{
"success": bool,
"message": str
}
"""
try:
stmt = text(
"""
UPDATE users
SET partner_balance = :balance, partner_percent = :percent
WHERE tg_id = :tg_id
"""
)
result = await session.execute(stmt, {"tg_id": tg_id, "balance": balance, "percent": percent})
if result.rowcount > 0:
return ORJSONResponse(
content={"success": True, "message": f"Партнёр {tg_id} успешно обновлён"},
status_code=200,
)
else:
return ORJSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(
content={"success": False, "message": str(e)},
status_code=500,
)
@router.get("/{tg_id}")
async def get_partner_data(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает партнёрские данные для указанного `tg_id`.
Структура ответа:
{
"tg_id": int,
"partner_balance": float,
"partner_percent": float,
"partner_code": str | None,
"payout_method": str | None,
"invited": [
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
}
"""
meta_sql = text(
"""
SELECT
COALESCE(u.partner_balance, 0) AS partner_balance,
u.partner_percent,
COALESCE(u.partner_percent_custom, false) AS partner_percent_custom,
u.partner_code,
u.payout_method
FROM users u
WHERE u.tg_id = :tg_id
"""
)
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
meta_res = await session.execute(meta_sql, {"tg_id": tg_id})
meta_row = meta_res.fetchone()
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
default_percent = _default_partner_percent()
percent = default_percent
if meta_row:
percent_value = meta_row[1]
percent_custom = bool(meta_row[2])
if percent_custom and percent_value is not None:
percent = float(percent_value)
response = {
"tg_id": tg_id,
"partner_balance": float(meta_row[0] or 0) if meta_row else 0.0,
"partner_percent": percent,
"partner_code": meta_row[3] if meta_row else None,
"payout_method": meta_row[4] if meta_row else None,
"invited": [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
],
}
return ORJSONResponse(content=response)
@router.post("/{tg_id}/invited")
async def add_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Query(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Добавляет приглашённого пользователю партнёра."""
if joined_tg_id == tg_id:
return ORJSONResponse(
content={"success": False, "message": "Нельзя привязать пользователя к самому себе"},
status_code=400,
)
try:
partner_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
if not partner_exists.scalar():
return ORJSONResponse(
content={"success": False, "message": "Партнёр не найден"},
status_code=404,
)
invited_exists = await session.execute(
text("SELECT 1 FROM users WHERE tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
if not invited_exists.scalar():
return ORJSONResponse(
content={"success": False, "message": "Приглашённый пользователь не найден"},
status_code=404,
)
existing = await session.execute(
text("SELECT partner_tg_id FROM partners WHERE joined_tg_id = :joined_tg_id"),
{"joined_tg_id": joined_tg_id},
)
existing_partner = existing.scalar()
if existing_partner is not None:
return ORJSONResponse(
content={
"success": False,
"message": f"Пользователь уже привязан к партнёру {existing_partner}",
},
status_code=409,
)
await session.execute(
text(
"""
INSERT INTO partners (partner_tg_id, joined_tg_id)
VALUES (:partner_tg_id, :joined_tg_id)
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
return ORJSONResponse(
content={
"success": True,
"message": "Приглашённый добавлен",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=201,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.delete("/{tg_id}/invited/{joined_tg_id}")
async def delete_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
joined_tg_id: int = Path(..., description="Telegram ID приглашённого"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Удаляет приглашённого у партнёра."""
try:
result = await session.execute(
text(
"""
DELETE FROM partners
WHERE partner_tg_id = :partner_tg_id
AND joined_tg_id = :joined_tg_id
"""
),
{"partner_tg_id": tg_id, "joined_tg_id": joined_tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(
content={
"success": True,
"message": "Приглашённый удалён",
"partner_tg_id": tg_id,
"joined_tg_id": joined_tg_id,
},
status_code=200,
)
return ORJSONResponse(
content={"success": False, "message": "Связка партнёр-приглашённый не найдена"},
status_code=404,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/percent")
async def update_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
percent: float = Query(..., description="Новый персональный процент (0-100 или 0.0-1.0)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет персональный процент партнёра."""
normalized = _parse_percent(percent)
if normalized is None:
return ORJSONResponse(
content={"success": False, "message": "Неверный процент. Допустимо 0-100 или 0.0-1.0"},
status_code=400,
)
try:
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = :percent, partner_percent_custom = true
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id, "percent": normalized},
)
if result.rowcount > 0:
return ORJSONResponse(
content={"success": True, "message": "Процент обновлён", "percent": normalized},
status_code=200,
)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.patch("/{tg_id}/balance")
async def update_partner_balance(
tg_id: int = Path(..., description="Telegram ID партнёра"),
amount: float = Query(..., description="Сумма операции"),
mode: str = Query("set", description="Режим: set, add, subtract"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Изменяет баланс партнёрской программы."""
mode_normalized = (mode or "set").strip().lower()
if mode_normalized not in {"set", "add", "subtract"}:
return ORJSONResponse(
content={"success": False, "message": "Неверный режим. Используйте set, add или subtract"},
status_code=400,
)
try:
amount_val = float(amount)
except (TypeError, ValueError):
return ORJSONResponse(
content={"success": False, "message": "Неверная сумма"},
status_code=400,
)
if amount_val < 0:
return ORJSONResponse(
content={"success": False, "message": "Сумма не может быть отрицательной"},
status_code=400,
)
try:
current_res = await session.execute(
text("SELECT partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": tg_id},
)
current_balance = current_res.scalar()
if current_balance is None:
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
current_balance = float(current_balance or 0.0)
if mode_normalized == "set":
new_balance = amount_val
elif mode_normalized == "add":
new_balance = current_balance + amount_val
else:
if current_balance < amount_val:
return ORJSONResponse(
content={"success": False, "message": "Недостаточно средств"},
status_code=400,
)
new_balance = current_balance - amount_val
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"tg_id": tg_id, "balance": new_balance},
)
return ORJSONResponse(
content={"success": True, "message": "Баланс обновлён", "balance": new_balance},
status_code=200,
)
except Exception as e:
await session.rollback()
return ORJSONResponse(content={"success": False, "message": str(e)}, status_code=500)
@router.get("/{tg_id}/invited")
async def get_partner_invited(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список приглашённых пользователей конкретного партнёра.
Структура ответа:
[
{ "tg_id": int, "joined_at": str | None, "balance": float, "subs_count": int, "payments_count": int }
]
"""
invited_sql = text(
"""
SELECT
pr.joined_tg_id,
pr.created_at,
COALESCE(u.balance, 0) AS user_balance,
(
SELECT COUNT(*) FROM keys k
WHERE k.tg_id = pr.joined_tg_id
) AS subs_count,
(
SELECT COUNT(*) FROM payments pay
WHERE pay.tg_id = pr.joined_tg_id
AND lower(pay.status) = 'success'
) AS payments_count
FROM partners pr
LEFT JOIN users u ON u.tg_id = pr.joined_tg_id
WHERE pr.partner_tg_id = :tg_id
ORDER BY pr.created_at DESC
"""
)
invited_res = await session.execute(invited_sql, {"tg_id": tg_id})
invited_rows = invited_res.fetchall()
invited_list = [
{
"tg_id": row[0],
"joined_at": row[1].isoformat() if isinstance(row[1], datetime) else None,
"balance": float(row[2] or 0),
"subs_count": int(row[3] or 0),
"payments_count": int(row[4] or 0),
}
for row in invited_rows
]
return ORJSONResponse(content=invited_list)
@router.get("/payouts/pending")
async def get_partner_payouts_pending(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает список ожидающих заявок на вывод."""
where_clause = "WHERE pr.status = 'pending'"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at ASC, pr.id ASC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "pending",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return ORJSONResponse(content={"total": int(total), "items": items})
@router.get("/payouts/history")
async def get_partner_payouts_history(
limit: int = Query(50, ge=1, le=200, description="Лимит результатов"),
offset: int = Query(0, ge=0, description="Смещение"),
partner_tg_id: int | None = Query(None, description="Фильтр по TG ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Возвращает историю выплат (approved/rejected)."""
where_clause = "WHERE pr.status IN ('approved','rejected')"
params = {"limit": limit, "offset": offset}
if partner_tg_id is not None:
where_clause += " AND pr.tg_id = :partner_tg_id"
params["partner_tg_id"] = partner_tg_id
count_sql = text(f"SELECT COUNT(*) FROM payout_requests pr {where_clause}")
rows_sql = text(
f"""
SELECT
pr.id,
pr.tg_id,
pr.amount,
pr.status,
pr.created_at,
COALESCE(pr.method, u.payout_method) AS method,
COALESCE(pr.destination, u.card_number) AS destination
FROM payout_requests pr
LEFT JOIN users u ON u.tg_id = pr.tg_id
{where_clause}
ORDER BY pr.created_at DESC, pr.id DESC
LIMIT :limit OFFSET :offset
"""
)
total = await session.scalar(count_sql) or 0
result = await session.execute(rows_sql, params)
items = []
for row in result.fetchall():
items.append({
"id": int(row[0]),
"tg_id": int(row[1]),
"amount": float(row[2] or 0.0),
"status": row[3] or "",
"created_at": _row_dt_iso(row[4]),
"method": row[5] or None,
"destination": row[6] or None,
})
return ORJSONResponse(content={"total": int(total), "items": items})
@router.post("/payouts/{payout_id}/approve")
async def approve_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Одобряет заявку на вывод."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return ORJSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'approved', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
return ORJSONResponse(content={"success": True, "message": "Заявка одобрена"}, status_code=200)
@router.post("/payouts/{payout_id}/reject")
async def reject_partner_payout(
payout_id: int = Path(..., description="ID заявки"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Отклоняет заявку на вывод и возвращает сумму на баланс."""
req_row = await session.execute(
text("SELECT id, tg_id, amount FROM payout_requests WHERE id = :id AND status = 'pending'"),
{"id": payout_id},
)
req = req_row.fetchone()
if not req:
return ORJSONResponse(
content={"success": False, "message": "Заявка не найдена или уже обработана"},
status_code=404,
)
user_row = await session.execute(
text("SELECT payout_method, card_number, partner_balance FROM users WHERE tg_id = :tg_id"),
{"tg_id": req[1]},
)
user = user_row.fetchone()
payout_method = (user[0] if user else None) or "card"
destination = (user[1] if user else None) or None
destination = (destination or "").strip() or None
await session.execute(
text(
"""
UPDATE payout_requests
SET status = 'rejected', method = :method, destination = :destination
WHERE id = :id
"""
),
{"id": payout_id, "method": payout_method, "destination": destination},
)
if user is not None:
current_balance = float(user[2] or 0.0)
await session.execute(
text("UPDATE users SET partner_balance = :balance WHERE tg_id = :tg_id"),
{"balance": current_balance + float(req[2] or 0.0), "tg_id": req[1]},
)
return ORJSONResponse(content={"success": True, "message": "Заявка отклонена"}, status_code=200)
@router.patch("/{tg_id}/percent/reset")
async def reset_partner_percent(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает персональный процент партнёра к дефолту."""
result = await session.execute(
text(
"""
UPDATE users
SET partner_percent = NULL, partner_percent_custom = false
WHERE tg_id = :tg_id
"""
),
{"tg_id": tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(content={"success": True, "message": "Процент сброшен"}, status_code=200)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.patch("/{tg_id}/code")
async def update_partner_code(
tg_id: int = Path(..., description="Telegram ID партнёра"),
code: str = Query(..., description="Новый код партнёра (латиница/цифры/_)"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Обновляет код партнёрской ссылки."""
raw = (code or "").strip().lower()
if not raw:
return ORJSONResponse(content={"success": False, "message": "Код не может быть пустым"}, status_code=400)
import re
if not re.fullmatch(r"[a-z0-9_]{3,32}", raw):
return ORJSONResponse(
content={"success": False, "message": "Неверный код. Разрешены a-z, 0-9, _ (3-32 символа)"},
status_code=400,
)
exists = await session.execute(
text("SELECT 1 FROM users WHERE partner_code = :code AND tg_id != :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if exists.first():
return ORJSONResponse(content={"success": False, "message": "Такой код уже занят"}, status_code=409)
result = await session.execute(
text("UPDATE users SET partner_code = :code WHERE tg_id = :tg_id"),
{"code": raw, "tg_id": tg_id},
)
if result.rowcount > 0:
return ORJSONResponse(content={"success": True, "message": "Код обновлён", "code": raw}, status_code=200)
return ORJSONResponse(content={"success": False, "message": "Партнёр не найден"}, status_code=404)
@router.post("/reset-disabled-methods")
async def reset_disabled_payout_methods(
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает реквизиты для отключённых способов вывода."""
try:
from modules.partner_program import buttons as B
from modules.partner_program.settings import (
ENABLE_PAYOUT_CARD,
ENABLE_PAYOUT_SBP,
ENABLE_PAYOUT_TON,
ENABLE_PAYOUT_USDT,
)
except Exception:
ENABLE_PAYOUT_CARD = True
ENABLE_PAYOUT_USDT = True
ENABLE_PAYOUT_TON = True
ENABLE_PAYOUT_SBP = True
B = None
disabled = []
if not ENABLE_PAYOUT_CARD and B:
disabled.append(B.METHOD_CARD)
if not ENABLE_PAYOUT_USDT and B:
disabled.append(B.METHOD_USDT)
if not ENABLE_PAYOUT_TON and B:
disabled.append(B.METHOD_TON)
if not ENABLE_PAYOUT_SBP and B:
disabled.append(B.METHOD_SBP)
if not disabled:
return ORJSONResponse(content={"success": True, "message": "Отключённых методов нет"}, status_code=200)
await session.execute(
text(
"""
UPDATE users
SET card_number = NULL
WHERE payout_method = ANY(:methods)
"""
),
{"methods": disabled},
)
return ORJSONResponse(content={"success": True, "message": "Отключённые методы сброшены"}, status_code=200)
@router.get("/{tg_id}/export")
async def export_partner_invites_csv(
tg_id: int = Path(..., description="Telegram ID партнёра"),
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
"""Экспортирует приглашённых партнёром в CSV."""
rows = await session.execute(
text(
"""
SELECT joined_tg_id, created_at
FROM partners
WHERE partner_tg_id = :tg_id
ORDER BY created_at ASC
"""
),
{"tg_id": tg_id},
)
data = rows.fetchall()
if not data:
return ORJSONResponse(content={"success": False, "message": "Нет приглашённых"}, status_code=404)
buffer = StringIO()
writer = csv.writer(buffer, delimiter=";")
writer.writerow(["joined_tg_id", "created_at"])
for joined_tg_id, created_at in data:
writer.writerow([int(joined_tg_id), created_at.isoformat() if created_at else ""])
content = buffer.getvalue().encode("utf-8-sig")
filename = f"partner_invites_{tg_id}.csv"
return StreamingResponse(
iter([content]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+45
View File
@@ -0,0 +1,45 @@
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.v1.routes.base_crud import generate_crud_router
from api.v1.schemas import ReferralResponse
from database.access.resolution import resolve_user_optional
from database.models import Admin, Referral
router = generate_crud_router(
model=Referral,
schema_response=ReferralResponse,
schema_create=None,
schema_update=None,
identifier_field="referrer_user_id",
parameter_name="referrer_tg_id",
telegram_path_to_user_id=True,
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),
):
ru_ref = await resolve_user_optional(session, referrer_tg_id)
rd_ref = await resolve_user_optional(session, referred_tg_id)
if ru_ref is None or rd_ref is None:
raise HTTPException(status_code=404, detail="Referral not found")
result = await session.execute(
select(Referral).where(
Referral.referrer_user_id == ru_ref.id,
Referral.referred_user_id == rd_ref.id,
)
)
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Referral not found")
await session.delete(obj)
return {"status": "deleted_one"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.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"],
)
+151
View File
@@ -0,0 +1,151 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_admin_token
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(admin=Depends(verify_admin_token)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
async def get_configs(admin=Depends(verify_admin_token)):
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(key: str, admin=Depends(verify_admin_token)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
admin=Depends(verify_admin_token),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
settings_cache.delete(key)
return {"detail": "Setting deleted"}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.v1.routes.base_crud import generate_crud_router
from api.v1.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"],
)
+64
View File
@@ -0,0 +1,64 @@
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.v1.routes.base_crud import generate_crud_router
from api.v1.schemas.users import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.access.resolution import resolve_user_optional
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
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:
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
servers = await get_servers(session=s)
cluster_ids = list(servers.keys())
async def _delete_one(cluster_id: str, email: str, client_id: str):
async with async_session_maker() as s:
await delete_key_from_cluster(cluster_id, email, client_id, s)
try:
tasks = [
_delete_one(cluster_id, email, client_id)
for email, client_id in key_records
for cluster_id in cluster_ids
]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
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="Ошибка при удалении пользователя")
+16
View File
@@ -0,0 +1,16 @@
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 .settings import SettingResponse, SettingUpsert
from .tariffs import TariffBase, TariffResponse, TariffUpdate
from .users import UserBase, UserResponse, UserUpdate
+97
View File
@@ -0,0 +1,97 @@
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)
percent: int | None = Field(default=None)
max_discount_amount: int | None = Field(default=None)
min_order_amount: int | None = Field(default=None)
new_users_only: bool = False
@model_validator(mode="after")
def check_coupon_type(self) -> "CouponBase":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided != 1:
raise ValueError("Coupon must have exactly one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
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)
percent: int | None = None
max_discount_amount: int | None = None
min_order_amount: int | None = None
new_users_only: bool | None = None
@model_validator(mode="after")
def validate_coupon_update(self) -> "CouponUpdate":
has_amount = self.amount not in (None, 0)
has_days = self.days is not None
has_percent = self.percent is not None
provided = int(has_amount) + int(has_days) + int(has_percent)
if provided > 1:
raise ValueError("Specify only one of: 'amount', 'days', 'percent'")
if has_days and self.days is not None and self.days <= 0:
raise ValueError("'days' must be > 0")
if has_percent and self.percent is not None and not (1 <= self.percent <= 100):
raise ValueError("'percent' must be between 1 and 100")
if has_percent:
if self.min_order_amount is not None and self.min_order_amount < 0:
raise ValueError("'min_order_amount' must be >= 0")
if self.max_discount_amount is not None and self.max_discount_amount < 0:
raise ValueError("'max_discount_amount' must be >= 0")
return self
class CouponUsageResponse(BaseModel):
coupon_id: int
user_id: int
used_at: datetime
class Config:
from_attributes = True
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from pydantic import BaseModel
class GiftBase(BaseModel):
sender_user_id: int
recipient_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
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_user_id: int | None = None
selected_months: int | None = None
expiry_time: datetime | None = None
gift_link: str | None = None
telegram_gift_link: str | None = None
site_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
+98
View File
@@ -0,0 +1,98 @@
from pydantic import BaseModel, Field
class KeyBase(BaseModel):
user_id: int
client_id: str
tg_id: int | None = None
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
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = None
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
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | None = 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
selected_device_limit: int | None = None
selected_traffic_limit: int | None = None
selected_price_rub: int | None = None
current_device_limit: int | None = None
current_traffic_limit: int | 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="Флаг триального ключа")
+128
View File
@@ -0,0 +1,128 @@
from datetime import datetime
from pydantic import BaseModel
class PaymentBase(BaseModel):
user_id: int
tg_id: int | None = None
amount: float
payment_system: str
status: str
class PaymentResponse(PaymentBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class ReferralResponse(BaseModel):
referred_user_id: int
referrer_user_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_user_id: int
recipient_user_id: int | None = None
selected_months: int
expiry_time: datetime
gift_link: str
telegram_gift_link: str | None = None
site_gift_link: str | None = None
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):
user_id: int
tg_id: int | None = None
banned_at: datetime
reason: str
banned_by: int
until: datetime | None = None
class Config:
from_attributes = True
class TemporaryDataResponse(BaseModel):
user_id: int
tg_id: int | None = None
state: str
data: dict
updated_at: datetime
class Config:
from_attributes = True
class BlockedUserResponse(BaseModel):
user_id: int
tg_id: int | None = None
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_user_id: int
referrer_user_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
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class SettingUpsert(BaseModel):
value: Any | None = None
description: str | None = None
class SettingResponse(BaseModel):
key: str
value: Any | None = None
description: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
+66
View File
@@ -0,0 +1,66 @@
from datetime import datetime
from typing import Any
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
vless: bool = False
external_squad: str | None = None
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, 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
vless: bool | None = None
external_squad: str | None = None
configurable: bool | None = None
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
device_step_rub: int | None = None
device_overrides: dict[str, int] | None = None
traffic_step_rub: int | None = None
traffic_overrides: dict[str, 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 | None = 0.0
trial: int | None = 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
+10
View File
@@ -0,0 +1,10 @@
VERSION = "2.0.0"
__all__ = ("router", "VERSION")
def __getattr__(name: str):
if name == "router":
from api.v2.router import router
return router
raise AttributeError(name)
+171
View File
@@ -0,0 +1,171 @@
from typing import Any
from fastapi import APIRouter, Body, 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_identity_admin
from api.v1.routes.base_crud import (
_apply_user_relationship_loader,
cast_identifier_type,
normalize_outgoing_object,
to_schema,
)
from database.access.resolution import resolve_user_optional
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,
telegram_path_to_user_id: bool = False,
enabled_methods: list[str] = ("get_all", "get_one", "get_by_email", "create", "update", "delete"),
) -> APIRouter:
router = APIRouter()
async def _path_filter(session: AsyncSession, value: int | str):
if telegram_path_to_user_id:
u = await resolve_user_optional(session, int(value))
if u is None:
return None
return model.user_id, u.id
field = getattr(model, identifier_field)
return field, cast_identifier_type(field, value)
if "get_all" in enabled_methods:
@router.get("/", response_model=list[schema_response])
async def get_all(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(_apply_user_relationship_loader(model, select(model)))
items = result.scalars().all()
for item in items:
normalize_outgoing_object(item)
return [schema_response.model_validate(item, from_attributes=True) for item in items]
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(...),
identity=Depends(verify_identity_admin),
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 to_schema(schema_response, 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),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, 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 to_schema(schema_response, 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),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
result = await session.execute(_apply_user_relationship_loader(model, select(model).where(field == casted)))
objs = result.scalars().all()
if not objs:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
for obj in objs:
normalize_outgoing_object(obj)
return [schema_response.model_validate(obj, from_attributes=True) for obj in objs]
if "create" in enabled_methods:
@router.post("/", response_model=schema_response)
async def create(
payload: Any = Body(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
validated = schema_create.model_validate(payload)
data = validated.model_dump(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 to_schema(schema_response, obj)
if "update" in enabled_methods:
@router.patch(f"/{{{parameter_name}}}", response_model=schema_response)
async def update(
payload: Any = Body(...),
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
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")
validated = schema_update.model_validate(payload)
for k, v in validated.model_dump(exclude_unset=True).items():
setattr(obj, k, v)
await session.commit()
await session.refresh(obj)
return to_schema(schema_response, obj)
if "delete" in enabled_methods:
@router.delete(f"/{{{parameter_name}}}", response_model=dict)
async def delete(
value: int | str = Path(..., alias=parameter_name),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
resolved = await _path_filter(session, value)
if resolved is None:
raise HTTPException(status_code=404, detail=f"{model.__name__} not found")
field, casted = resolved
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
__all__ = ("generate_crud_router", "to_schema", "normalize_outgoing_object", "cast_identifier_type")
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter
from api.v2.routes import (
auth,
coupons,
flows,
gifts,
identities,
keys,
management,
misc,
modules,
notifications,
partners,
payment_links,
referrals,
root_router,
servers,
settings,
tariffs,
users,
web,
)
router = APIRouter()
router.include_router(root_router)
router.include_router(auth.router, prefix="/api")
router.include_router(users.router, prefix="/api/users", tags=["Users"])
router.include_router(keys.user_router, prefix="/api/keys", tags=["Keys"])
router.include_router(keys.router, prefix="/api/admin/keys", tags=["AdminKeys"])
router.include_router(coupons.router, prefix="/api/coupons", tags=["Coupons"])
router.include_router(servers.router, prefix="/api/servers", tags=["Servers"])
router.include_router(tariffs.public_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.user_tariff_router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(tariffs.router, prefix="/api/tariffs", tags=["Tariffs"])
router.include_router(gifts.router, prefix="/api/gifts", tags=["Gifts"])
router.include_router(referrals.router, prefix="/api/referrals", tags=["Referrals"])
router.include_router(partners.router, prefix="/api/partners", tags=["Partners"])
router.include_router(payment_links.router, prefix="/api/payment-links", tags=["PaymentLinks"])
router.include_router(identities.router, prefix="/api/identities", tags=["Identities"])
router.include_router(misc.router, prefix="/api")
router.include_router(modules.router, prefix="/api")
router.include_router(management.router, prefix="/api/management", tags=["Management"])
router.include_router(settings.router, prefix="/api/settings", tags=["Settings"])
router.include_router(web.router, prefix="", tags=["Web"])
router.include_router(flows.router, prefix="/api", tags=["Flows"])
router.include_router(notifications.router, prefix="/api", tags=["Notifications"])
+9
View File
@@ -0,0 +1,9 @@
__all__ = ("root_router",)
def __getattr__(name: str):
if name == "root_router":
from api.v2.routes.root import router
return router
raise AttributeError(name)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
import base64
import re
import uuid
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
UPLOAD_DIR = Path("static/web_uploads")
DATA_URI_THRESHOLD_BYTES = 2048
_DATA_URI_RE = re.compile(r"^data:([\w./+-]+);base64,(.+)$", re.DOTALL)
_MIME_TO_EXT = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"video/mp4": ".mp4",
"video/webm": ".webm",
}
def _save_data_uri_to_file(data_uri: str) -> str | None:
match = _DATA_URI_RE.match(data_uri)
if not match:
return None
mime = match.group(1).strip().lower()
payload = match.group(2)
ext = _MIME_TO_EXT.get(mime)
if not ext:
return None
try:
cleaned = "".join(payload.split())
decoded = base64.b64decode(cleaned, validate=False)
except Exception:
return None
if not decoded:
return None
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(UPLOAD_DIR / name).write_bytes(decoded)
return f"/api/web/uploads/{name}"
def migrate_json_data_uris(value: Any) -> tuple[Any, int]:
replaced = 0
def walk(node: Any) -> Any:
nonlocal replaced
if isinstance(node, str):
if not node.startswith("data:"):
return node
if len(node) < DATA_URI_THRESHOLD_BYTES:
return node
url = _save_data_uri_to_file(node)
if url is None:
return node
replaced += 1
return url
if isinstance(node, list):
return [walk(item) for item in node]
if isinstance(node, dict):
return {key: walk(item) for key, item in node.items()}
return node
return walk(value), replaced
async def run_startup_data_uri_migration(session: AsyncSession) -> tuple[int, int]:
from database.models import (
WebBlock,
WebPageVariant,
WebPageVariantBlock,
WebTheme,
)
rows_updated = 0
uris_replaced = 0
for theme in (await session.execute(select(WebTheme))).scalars().all():
cleaned, replaced = migrate_json_data_uris(theme.tokens or {})
if replaced:
theme.tokens = cleaned
rows_updated += 1
uris_replaced += replaced
for variant in (await session.execute(select(WebPageVariant))).scalars().all():
cleaned, replaced = migrate_json_data_uris(variant.theme_tokens or {})
if replaced:
variant.theme_tokens = cleaned
rows_updated += 1
uris_replaced += replaced
for block in (await session.execute(select(WebBlock))).scalars().all():
cleaned, replaced = migrate_json_data_uris(block.data or {})
if replaced:
block.data = cleaned
rows_updated += 1
uris_replaced += replaced
for block in (await session.execute(select(WebPageVariantBlock))).scalars().all():
cleaned, replaced = migrate_json_data_uris(block.data or {})
if replaced:
block.data = cleaned
rows_updated += 1
uris_replaced += replaced
return rows_updated, uris_replaced
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from api.v2.routes.auth import email_verify, google, link, password, session, telegram, yandex
router = APIRouter(prefix="/auth", tags=["Auth"])
router.include_router(password.router)
router.include_router(telegram.router)
router.include_router(google.router)
router.include_router(yandex.router)
router.include_router(link.router)
router.include_router(email_verify.router)
router.include_router(session.router)
__all__ = ["router"]
+144
View File
@@ -0,0 +1,144 @@
from fastapi import Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from api.v2.schemas.identities import IdentityResponse, LoginResponse
from config import API_TOKEN_TTL_DAYS
from logger import logger
from utils.referral_codes import encode_partner_code
TOKEN_TTL_HINT = "бессрочно" if API_TOKEN_TTL_DAYS is None else f"{API_TOKEN_TTL_DAYS} дн."
TELEGRAM_LOGIN_MAX_AGE = 86400
def build_login_response(identity) -> LoginResponse:
return LoginResponse(
identity_id=identity.id,
identity=IdentityResponse.model_validate(identity),
)
_TRUSTED_PROXY_CIDRS: list[str] = []
def _client_ip(request: Request) -> str:
client_host = (request.client.host if request.client else "") or ""
forwarded = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
if not forwarded:
return client_host
if not _TRUSTED_PROXY_CIDRS and client_host not in ("127.0.0.1", "::1"):
return client_host
return forwarded.split(",")[0].strip() or client_host
async def _resolve_partner_snapshot(session: AsyncSession, billing_user_id: int) -> dict[str, object]:
partner_feature_enabled = False
default_percent = 0.0
try:
from modules.partner_program import settings as partner_settings
partner_feature_enabled = True
raw_percent = getattr(partner_settings, "PARTNER_BONUS_PERCENTAGES", {}).get(1, 0.0)
default_percent = float(raw_percent) * 100.0
except Exception:
partner_feature_enabled = False
default_percent = 0.0
payload: dict[str, object] = {
"partner_enabled": partner_feature_enabled,
"partner_code": "",
"partner_balance": 0.0,
"partner_percent": default_percent,
"partner_percent_custom": False,
"partner_referred_total": 0,
"partner_referred_paid": 0,
"partner_payout_method": None,
}
try:
partner_row = (
await session.execute(
text(
"""
SELECT
tg_id,
COALESCE(partner_balance, 0),
partner_percent,
COALESCE(partner_percent_custom, false),
partner_code,
payout_method
FROM users
WHERE id = :user_id
LIMIT 1
"""
),
{"user_id": int(billing_user_id)},
)
).first()
except Exception:
partner_row = None
if partner_row is None:
return payload
tg_id = int(partner_row[0]) if partner_row[0] is not None else None
balance = float(partner_row[1] or 0.0)
percent_raw = partner_row[2]
percent_custom = bool(partner_row[3])
percent_value = float(percent_raw) if (percent_custom and percent_raw is not None) else float(default_percent)
code = str(partner_row[4] or "").strip()
if (not code or code.isdigit() or code.startswith("r1_")) and int(billing_user_id) > 0:
generated_code = encode_partner_code(int(billing_user_id))
code = generated_code
try:
await session.execute(
text("UPDATE users SET partner_code = :code WHERE id = :id"),
{"code": generated_code, "id": int(billing_user_id)},
)
await session.flush()
except Exception as e:
logger.warning("[Auth] Ошибка сохранения partner_code для billing_user_id={}: {}", billing_user_id, e)
payout_method = str(partner_row[5] or "").strip() or None
referred_total = 0
referred_paid = 0
if tg_id is not None:
try:
referred_total = int(
(
await session.execute(
text("SELECT COUNT(*) FROM partners WHERE partner_tg_id = :tg_id"),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_total = 0
try:
referred_paid = int(
(
await session.execute(
text(
"SELECT COUNT(DISTINCT pr.joined_tg_id) "
"FROM partners pr "
"WHERE pr.partner_tg_id = :tg_id "
"AND EXISTS ("
" SELECT 1 FROM payments pay "
" WHERE pay.tg_id = pr.joined_tg_id "
" AND lower(pay.status) = 'success'"
")"
),
{"tg_id": int(tg_id)},
)
).scalar()
or 0
)
except Exception:
referred_paid = 0
payload.update({
"partner_enabled": bool(partner_feature_enabled or code or referred_total > 0 or balance > 0),
"partner_code": code,
"partner_balance": balance,
"partner_percent": percent_value,
"partner_percent_custom": percent_custom,
"partner_referred_total": referred_total,
"partner_referred_paid": referred_paid,
"partner_payout_method": payout_method,
})
return payload
+44
View File
@@ -0,0 +1,44 @@
"""In-memory rate limit fallback для случаев когда Redis недоступен.
Используется только когда Redis не ответил — чтобы критичные auth-эндпоинты
не теряли защиту при кратковременных Redis-сбоях. Per-process, не шарится
между репликами — поэтому на нескольких инстансах лимит будет N*limit.
"""
import time
from collections import deque
from threading import Lock
_BUCKETS: dict[str, deque[float]] = {}
_LOCK = Lock()
_MAX_KEYS = 10000
def _prune(bucket: deque[float], window_sec: int) -> None:
threshold = time.monotonic() - window_sec
while bucket and bucket[0] < threshold:
bucket.popleft()
def _evict_if_full() -> None:
if len(_BUCKETS) < _MAX_KEYS:
return
now = time.monotonic()
dead = [k for k, b in _BUCKETS.items() if not b or b[-1] < now - 3600]
for k in dead:
del _BUCKETS[k]
if len(_BUCKETS) >= _MAX_KEYS:
oldest = min(_BUCKETS.keys(), key=lambda k: _BUCKETS[k][0] if _BUCKETS[k] else 0)
del _BUCKETS[oldest]
def check_and_increment(key: str, limit: int, window_sec: int) -> int:
"""Возвращает текущее значение счётчика после инкремента. Если >= limit — превышение."""
with _LOCK:
_evict_if_full()
bucket = _BUCKETS.setdefault(key, deque())
_prune(bucket, window_sec)
bucket.append(time.monotonic())
return len(bucket)
+78
View File
@@ -0,0 +1,78 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.routes.auth._common import _client_ip
from mail import send_email_verify_code_email, smtp_configured
from utils import web_email_verify_code as verify_util
router = APIRouter()
class VerifyEmailRequest(BaseModel):
code: str = PydanticField(..., min_length=1, max_length=10)
@router.post("/send-verify-code")
async def send_email_verify_code(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Отправить код подтверждения email. Требует авторизации."""
if not smtp_configured():
raise HTTPException(status_code=503, detail="Почтовый сервер не настроен")
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.redis_ready():
raise HTTPException(status_code=503, detail="Сервис временно недоступен")
ip = _client_ip(request)
if not await verify_util.try_consume_ip_send_budget(ip):
raise HTTPException(status_code=429, detail="Слишком много запросов, попробуйте позже")
if not await verify_util.try_consume_email_send_budget(email):
raise HTTPException(status_code=429, detail="Слишком много запросов на этот email")
if not await verify_util.try_acquire_resend_cooldown(email):
raise HTTPException(status_code=429, detail="Подождите минуту перед повторной отправкой")
code = f"{secrets.randbelow(900000) + 100000}"
await verify_util.store_code(email, code)
try:
await send_email_verify_code_email(email, code)
except Exception:
await verify_util.delete_code(email)
raise HTTPException(status_code=503, detail="Не удалось отправить письмо")
return {"ok": True}
@router.post("/verify-email")
async def verify_email(
body: VerifyEmailRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Подтвердить email по коду."""
email = (identity.email or "").strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email не привязан к аккаунту")
if getattr(identity, "email_verified", False):
return {"ok": True, "detail": "Email уже подтверждён"}
if not await verify_util.try_consume_verify_budget(email):
raise HTTPException(status_code=429, detail="Слишком много попыток, попробуйте позже")
if not await verify_util.verify_and_consume_code(email, body.code.strip()):
raise HTTPException(status_code=400, detail="Неверный или просроченный код")
from sqlalchemy import update
from database.models import Identity as IdentityModel
await session.execute(update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True))
return {"ok": True}
+210
View File
@@ -0,0 +1,210 @@
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import _client_ip
from database import identities as idb
from logger import logger
try:
from config import GOOGLE_CLIENT_ID as _GOOGLE_CLIENT_ID
except ImportError:
_GOOGLE_CLIENT_ID = ""
try:
from config import GOOGLE_CLIENT_SECRET as _GOOGLE_CLIENT_SECRET
except ImportError:
_GOOGLE_CLIENT_SECRET = ""
try:
from config import GOOGLE_REDIRECT_URI as _GOOGLE_REDIRECT_URI
except ImportError:
_GOOGLE_REDIRECT_URI = ""
try:
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
except ImportError:
_OAUTH_SUCCESS_URI = "/dashboard"
try:
from config import API_TOKEN as _GOOGLE_STATE_SECRET
except ImportError:
_GOOGLE_STATE_SECRET = "solo-google-state-fallback"
GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_ENDPOINT = "https://openidconnect.googleapis.com/v1/userinfo"
STATE_TTL_SECONDS = 600
router = APIRouter()
def google_configured() -> bool:
return bool(_GOOGLE_CLIENT_ID and _GOOGLE_CLIENT_SECRET and _GOOGLE_REDIRECT_URI)
def _sign_state(payload: str) -> str:
mac = hmac.new(str(_GOOGLE_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
def _make_state(return_to: str) -> str:
nonce = secrets.token_urlsafe(16)
ts = str(int(time.time()))
payload = f"{nonce}.{ts}.{return_to}"
sig = _sign_state(payload)
raw = f"{payload}.{sig}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _verify_state(state: str) -> str | None:
try:
padded = state + "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(padded).decode()
except Exception:
return None
parts = raw.rsplit(".", 1)
if len(parts) != 2:
return None
payload, sig = parts
expected = _sign_state(payload)
if not hmac.compare_digest(sig, expected):
return None
chunks = payload.split(".", 2)
if len(chunks) != 3:
return None
_nonce, ts, return_to = chunks
try:
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
return None
except Exception:
return None
return return_to or _OAUTH_SUCCESS_URI
@router.get("/google/authorize")
async def google_authorize(
request: Request,
return_to: str = Query(default=""),
):
"""Начинает OAuth-флоу Google: редиректит юзера на Google consent screen."""
if not google_configured():
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
state = _make_state(safe_return)
params = {
"client_id": _GOOGLE_CLIENT_ID,
"redirect_uri": _GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
url = f"{GOOGLE_AUTH_ENDPOINT}?{urlencode(params)}"
logger.info("[Auth] Google authorize: ip={}", _client_ip(request))
return RedirectResponse(url, status_code=302)
@router.get("/google/callback")
async def google_callback(
request: Request,
response: Response,
code: str = Query(default=""),
state: str = Query(default=""),
error: str = Query(default=""),
session: AsyncSession = Depends(get_session),
):
"""Коллбек Google: обмен code → token → userinfo → identity."""
if not google_configured():
raise HTTPException(status_code=503, detail="Google Sign-In не настроен на этом сервере")
if error:
logger.warning("[Auth] Google callback error: {} ip={}", error, _client_ip(request))
return RedirectResponse(f"/login?error=google_{error}", status_code=302)
if not code or not state:
raise HTTPException(status_code=400, detail="Отсутствует code или state")
return_to = _verify_state(state)
if return_to is None:
logger.warning("[Auth] Google callback: invalid/expired state ip={}", _client_ip(request))
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
token_res = await client.post(
GOOGLE_TOKEN_ENDPOINT,
data={
"code": code,
"client_id": _GOOGLE_CLIENT_ID,
"client_secret": _GOOGLE_CLIENT_SECRET,
"redirect_uri": _GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Google token exchange network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось связаться с Google") from e
if token_res.status_code != 200:
logger.warning("[Auth] Google token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
raise HTTPException(status_code=401, detail="Google отклонил токен")
token_payload = token_res.json()
access_token = token_payload.get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="Google не вернул access_token")
try:
info_res = await client.get(
GOOGLE_USERINFO_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Google userinfo network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось получить профиль Google") from e
if info_res.status_code != 200:
raise HTTPException(status_code=401, detail="Google не вернул профиль")
info = info_res.json()
google_sub = str(info.get("sub") or "").strip()
email = (info.get("email") or "").strip().lower() or None
email_verified = bool(info.get("email_verified"))
if not google_sub:
raise HTTPException(status_code=401, detail="Google не вернул идентификатор пользователя")
identity = await idb.get_or_create_identity_for_google(
session,
google_sub=google_sub,
email=email if (email and email_verified) else None,
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, google_sub={}, ip={}, method=google",
identity.id,
google_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
set_is_admin_cookie(redirect, identity, request)
return redirect
@router.get("/google/status")
async def google_status():
"""Позволяет фронтенду узнать, настроен ли Google-вход на этом сервере."""
return {"enabled": google_configured()}
+124
View File
@@ -0,0 +1,124 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_is_admin_cookie,
verify_identity_token,
)
from api.v2.routes.auth._common import _client_ip
from api.v2.schemas.identities import (
IdentityResponse,
LinkEmailConfirmRequest,
LinkEmailSendCodeRequest,
)
from database import identities as idb
from mail import send_email_link_code_email, smtp_configured
from utils import web_email_link_code as email_link_code
router = APIRouter()
@router.post("/link-email/send-code")
async def link_email_send_code(
body: LinkEmailSendCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Укажите корректный email")
if identity.email and str(identity.email).strip().lower() == email_norm:
raise HTTPException(status_code=409, detail="Этот email уже привязан к аккаунту")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
existing = await idb.get_identity_by_email(session, email_norm)
if existing and existing.id != identity.id:
our_tg = identity.tg_id
their_tg = existing.tg_id
can_merge = their_tg is None or (our_tg is not None and int(their_tg) == int(our_tg))
if not can_merge:
raise HTTPException(
status_code=409,
detail="Этот email уже привязан к другому аккаунту",
)
ip = _client_ip(request)
if not await email_link_code.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await email_link_code.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await email_link_code.store_code(email_norm, code):
await email_link_code.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_email_link_code_email(email_norm, code)
except Exception:
await email_link_code.release_cooldown(email_norm)
await email_link_code.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код подтверждения отправлен на почту"}
@router.post("/link-email/confirm", response_model=IdentityResponse)
async def link_email_confirm(
body: LinkEmailConfirmRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
email_norm = email_link_code.normalize_email(body.email)
if not email_norm or not body.code or not str(body.code).strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await email_link_code.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await email_link_code.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await email_link_code.verify_and_consume_code(email_norm, str(body.code).strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
result = await idb.attach_email(session, identity.id, email_norm)
if not result:
raise HTTPException(
status_code=409,
detail="Этот email уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
set_is_admin_cookie(response, result, request)
return IdentityResponse.model_validate(result)
+430
View File
@@ -0,0 +1,430 @@
import secrets
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import TOKEN_TTL_HINT, _client_ip, build_login_response
from api.v2.schemas.identities import (
ConfirmPasswordResetRequest,
LoginByCodeRequest,
LoginRequest,
LoginResponse,
RegisterByEmailRequest,
RegisterResponse,
SendLoginCodeRequest,
)
from database import (
add_referral,
get_referral_by_referred_id,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from logger import logger
from mail import (
send_email_verify_code_email,
send_login_code_email,
send_password_reset_code_email,
smtp_configured,
)
from utils import (
web_email_verify_code as email_verify,
web_password_reset_code as pwd_reset,
)
from utils.disposable_emails import is_disposable_email
from utils.referral_codes import decode_referral_code
from utils.turnstile import turnstile_enabled, verify_turnstile_token
from utils.web_login_code import (
delete_code,
normalize_login_email,
redis_ready_for_login_codes,
release_resend_cooldown,
store_code,
try_acquire_resend_cooldown,
try_consume_email_send_budget,
try_consume_email_verify_budget,
try_consume_ip_send_budget,
verify_and_consume_code,
)
router = APIRouter()
_RESET_OK_MESSAGE = {
"ok": True,
"message": "Если для этого адреса есть аккаунт с паролем, мы отправили код. Проверьте почту.",
}
@router.post("/register", response_model=RegisterResponse)
async def register_by_email(
body: RegisterByEmailRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Регистрация по почте и паролю: создаётся идентичность, выдаётся токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"register_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"register_rate:{ip}", 5, 3600)
if count > 5:
raise HTTPException(status_code=429, detail="Слишком много регистраций с этого IP. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not body.password or len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
existing = await idb.get_identity_by_email(session, email)
if existing:
raise HTTPException(status_code=409, detail="Идентичность с таким email уже существует")
raw_referral = str(body.referral_code or "").strip()
if "/referral/" in raw_referral:
raw_referral = raw_referral.split("/referral/", 1)[-1]
if "start=referral_" in raw_referral:
raw_referral = raw_referral.split("start=referral_", 1)[-1]
raw_referral = raw_referral.split("?", 1)[0].split("#", 1)[0].strip()
referrer_legacy = decode_referral_code(raw_referral)
referrer_user = None
if body.referral_code and referrer_legacy is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
if referrer_legacy is not None:
referrer_user = await resolve_user_optional(session, referrer_legacy)
if referrer_user is None:
raise HTTPException(status_code=400, detail="Код приглашения недействителен")
identity, token = await idb.create_identity_with_token(
session, email=email, password=body.password, request=request
)
await bind_identity_actor(request, session, identity)
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if referrer_user is not None and not await get_referral_by_referred_id(session, billing_user_id):
await add_referral(session, billing_user_id, referrer_user.id)
if referrer_user.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_user.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={"referred_user_id": int(billing_user_id)},
)
except Exception:
pass
if smtp_configured():
try:
code = f"{secrets.randbelow(900000) + 100000}"
await email_verify.store_code(email, code)
await send_email_verify_code_email(email, code)
except Exception as e:
logger.warning("[Auth] Не удалось отправить код подтверждения email при регистрации: {}", e)
logger.info("[Auth] Register success: identity={}, email={}, ip={}", identity.id, email, _client_ip(request))
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return RegisterResponse(identity_id=identity.id)
@router.post("/login", response_model=LoginResponse)
async def login(
body: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и паролю. Возвращает identity_id и новый токен. Срок действия токена: """ + TOKEN_TTL_HINT + "."
email = body.email.strip().lower()
if not email:
raise HTTPException(status_code=400, detail="Email обязателен")
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_get, cache_incr_checked
lockout_key = f"login_lockout:{email}"
locked = await cache_get(lockout_key)
if locked:
raise HTTPException(status_code=429, detail="Аккаунт временно заблокирован. Попробуйте через 15 минут.")
rkey = f"login_pwd_rate:{ip}"
count, redis_ok = await cache_incr_checked(rkey, 900)
if not redis_ok:
count = check_and_increment(rkey, 10, 900)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много попыток. Попробуйте позже.")
except HTTPException:
raise
except Exception as e:
logger.warning("[Auth] Ошибка rate-limit проверки для email-логина: {}", e)
result = await idb.login_by_email(session, email, body.password, request=request)
if not result:
from database.setup.web_admin_bootstrap import ensure_web_admin
try:
await ensure_web_admin(session)
await session.flush()
result = await idb.login_by_email(session, email, body.password, request=request)
except Exception as exc:
logger.warning("[Auth] lazy web-admin bootstrap failed: {}", exc)
if not result:
try:
from core.redis_cache import cache_incr, cache_set
fail_key = f"login_fail:{email}"
fails = await cache_incr(fail_key, 900)
if fails >= 10:
await cache_set(f"login_lockout:{email}", "1", 900)
except Exception:
pass
raise HTTPException(status_code=401, detail="Неверный email или пароль")
try:
from core.redis_cache import cache_delete
await cache_delete(f"login_fail:{email}")
except Exception:
pass
identity, token = result
await bind_identity_actor(request, session, identity)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, ip={}, method=password", identity.id, email, ip)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/send-login-code")
async def send_login_code(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
"""Отправить код входа на email (SMTP + Redis)."""
ip = _client_ip(request)
try:
from api.v2.routes.auth._fallback_limiter import check_and_increment
from core.redis_cache import cache_incr_checked
count, redis_ok = await cache_incr_checked(f"send_code_rate:{ip}", 3600)
if not redis_ok:
count = check_and_increment(f"send_code_rate:{ip}", 10, 3600)
if count > 10:
raise HTTPException(status_code=429, detail="Слишком много запросов кодов. Попробуйте позже.")
except HTTPException:
raise
except Exception:
pass
if turnstile_enabled():
if not await verify_turnstile_token(body.turnstile_token, ip):
raise HTTPException(status_code=400, detail="Проверка CAPTCHA не пройдена")
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if is_disposable_email(email_norm):
raise HTTPException(status_code=400, detail="Одноразовые email-адреса не поддерживаются")
if not smtp_configured():
raise HTTPException(
status_code=503,
detail="Отправка кода недоступна: почта не настроена на сервере",
)
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
if not body.allow_register:
return {"ok": True, "message": "Код отправлен на почту"}
identity = await idb.create_identity(session, email=email_norm)
ip = _client_ip(request)
if not await try_consume_ip_send_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await try_acquire_resend_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await store_code(email_norm, code):
await release_resend_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_login_code_email(email_norm, code)
except Exception:
await release_resend_cooldown(email_norm)
await delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return {"ok": True, "message": "Код отправлен на почту"}
@router.post("/login-by-code", response_model=LoginResponse)
async def login_by_code(
body: LoginByCodeRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход по email и коду из письма."""
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if not await redis_ready_for_login_codes():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=401, detail="Аккаунт не найден")
if not getattr(identity, "email_verified", False):
from sqlalchemy import update as sa_update
from database.models import Identity as IdentityModel
await session.execute(
sa_update(IdentityModel).where(IdentityModel.id == identity.id).values(email_verified=True)
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info("[Auth] Login success: identity={}, email={}, method=code", identity.id, email_norm)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/request-password-reset")
async def request_password_reset(
body: SendLoginCodeRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm:
raise HTTPException(status_code=400, detail="Email обязателен")
if not smtp_configured() or not await pwd_reset.redis_ready():
return _RESET_OK_MESSAGE
identity = await idb.get_identity_by_email(session, email_norm)
if not identity or not identity.password_hash:
return _RESET_OK_MESSAGE
ip = _client_ip(request)
if not await pwd_reset.try_consume_ip_budget(ip):
raise HTTPException(
status_code=429,
detail="Слишком много запросов с вашего адреса. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_send_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много запросов для этого адреса. Попробуйте позже.",
)
if not await pwd_reset.try_acquire_cooldown(email_norm):
raise HTTPException(
status_code=429,
detail="Код уже отправлен. Подождите перед повторной отправкой.",
)
code = "".join(secrets.choice("0123456789") for _ in range(6))
if not await pwd_reset.store_code(email_norm, code):
await pwd_reset.release_cooldown(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось сохранить код. Попробуйте позже.",
)
try:
await send_password_reset_code_email(email_norm, code)
except Exception:
await pwd_reset.release_cooldown(email_norm)
await pwd_reset.delete_code(email_norm)
raise HTTPException(
status_code=503,
detail="Не удалось отправить письмо. Попробуйте позже.",
) from None
return _RESET_OK_MESSAGE
@router.post("/confirm-password-reset", response_model=LoginResponse)
async def confirm_password_reset(
body: ConfirmPasswordResetRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
email_norm = normalize_login_email(body.email)
if not email_norm or not body.code or not body.code.strip():
raise HTTPException(status_code=400, detail="Email и код обязательны")
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
if len(body.password) < 8:
raise HTTPException(status_code=400, detail="Пароль минимум 8 символов")
if not await pwd_reset.redis_ready():
raise HTTPException(
status_code=503,
detail="Сервис временно недоступен. Попробуйте позже.",
)
if not await pwd_reset.try_consume_email_verify_budget(email_norm):
raise HTTPException(
status_code=429,
detail="Слишком много попыток. Запросите новый код.",
)
if not await pwd_reset.verify_and_consume_code(email_norm, body.code.strip()):
raise HTTPException(status_code=401, detail="Неверный код или срок действия истёк")
identity = await idb.get_identity_by_email(session, email_norm)
if not identity:
raise HTTPException(status_code=400, detail="Аккаунт не найден")
updated = await idb.set_password_for_identity(session, identity.id, body.password)
if not updated:
raise HTTPException(status_code=400, detail="Не удалось обновить пароль")
await bind_identity_actor(request, session, updated)
token = await idb.issue_token_for_identity(session, updated, request=request)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, updated, request)
return build_login_response(updated)
+601
View File
@@ -0,0 +1,601 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
clear_auth_cookie,
get_request_actor,
get_session,
hash_token,
verify_identity_token,
)
from api.depends import AUTH_COOKIE_NAME
from api.v2.routes.auth._common import _resolve_partner_snapshot
from api.v2.schemas.identities import (
ChangePasswordRequest,
IdentityResponse,
IdentitySessionItem,
IdentitySessionsResponse,
SetPasswordRequest,
)
from api.v2.schemas.web_public import (
AccountSearchHit,
AccountSearchResponse,
AccountSummaryResponse,
)
from database import (
get_balance,
get_keys,
get_trial,
identities as idb,
identity_sessions as idsess,
)
from database.models import CouponUsage, Gift, GiftUsage, IdentityNotifPref, Key, Payment, WebNotification
from database.referrals import get_referral_stats
from database.web_notifications import count_unread_for_identity
from utils.referral_codes import encode_referral_code
router = APIRouter()
@router.get("/me", response_model=IdentityResponse)
async def me(
identity=Depends(verify_identity_token),
):
"""Текущая идентичность по HttpOnly cookie `auth_token`."""
return IdentityResponse.model_validate(identity)
def _current_token_hash(request: Request) -> str | None:
raw = request.cookies.get(AUTH_COOKIE_NAME)
if not raw or not raw.strip():
return None
return hash_token(raw.strip())
@router.get("/sessions", response_model=IdentitySessionsResponse)
async def list_my_sessions(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Возвращает активные сессии текущей identity (все устройства)."""
current_hash = _current_token_hash(request)
rows = await idsess.list_sessions_for_identity(session, identity.id)
items = [
IdentitySessionItem(
id=row.id,
device_label=row.device_label,
ip=row.ip,
created_at=row.created_at,
last_seen_at=row.last_seen_at,
expires_at=row.expires_at,
is_current=bool(current_hash and row.token_hash == current_hash),
)
for row in rows
]
return IdentitySessionsResponse(sessions=items)
@router.delete("/sessions/{session_id}")
async def revoke_my_session(
session_id: str,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет одну сессию текущей identity. Если удалена текущая — очищаем cookie."""
ok = await idsess.delete_session_by_id(
session, session_id=session_id, identity_id=identity.id
)
if not ok:
raise HTTPException(status_code=404, detail="Сессия не найдена")
current_hash = _current_token_hash(request)
rows = await idsess.list_sessions_for_identity(session, identity.id)
if current_hash and not any(r.token_hash == current_hash for r in rows):
clear_auth_cookie(response, request)
return {"ok": True}
@router.post("/sessions/revoke-others")
async def revoke_other_sessions(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет все сессии текущей identity кроме текущей."""
current_hash = _current_token_hash(request)
if not current_hash:
raise HTTPException(status_code=400, detail="Текущая сессия не определена")
removed = await idsess.delete_other_sessions(
session, identity_id=identity.id, keep_token_hash=current_hash
)
return {"ok": True, "removed": removed}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Удаляет текущую сессию из БД и очищает auth cookie. Не требует валидной сессии."""
raw = request.cookies.get(AUTH_COOKIE_NAME)
if raw and raw.strip():
try:
await idsess.delete_session_by_token_hash(session, hash_token(raw.strip()))
except Exception:
pass
clear_auth_cookie(response, request)
return {"ok": True}
@router.post("/me/onboarding/complete", response_model=IdentityResponse)
async def onboarding_complete(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Отмечает, что админ прошёл/скипнул онбординг-тур."""
from datetime import datetime as _dt
if identity.onboarding_completed_at is None:
identity.onboarding_completed_at = _dt.utcnow()
return IdentityResponse.model_validate(identity)
@router.post("/me/onboarding/reset", response_model=IdentityResponse)
async def onboarding_reset(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Сбрасывает флаг онбординга — туториал запустится снова."""
identity.onboarding_completed_at = None
identity.onboarding_stage = "landing"
return IdentityResponse.model_validate(identity)
_ONBOARDING_STAGES = {"landing", "header", "cabinet", "flow", "elements", "done"}
@router.post("/me/onboarding/stage", response_model=IdentityResponse)
async def onboarding_set_stage(
body: dict,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Переводит админа на указанный этап онбординга."""
from datetime import datetime as _dt
stage = str(body.get("stage") or "").strip()
if stage not in _ONBOARDING_STAGES:
raise HTTPException(status_code=400, detail="Неизвестный этап онбординга")
identity.onboarding_stage = stage
if stage == "done" and identity.onboarding_completed_at is None:
identity.onboarding_completed_at = _dt.utcnow()
return IdentityResponse.model_validate(identity)
@router.get("/summary", response_model=AccountSummaryResponse)
async def auth_summary(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
balance = float(await get_balance(session, billing_user_id))
trial_status = await get_trial(session, billing_user_id)
keys = await get_keys(session, billing_user_id)
keys_total = len(keys) if keys else 0
gifts_sent_r = await session.execute(
select(func.count()).select_from(Gift).where(Gift.sender_user_id == billing_user_id)
)
gifts_sent = gifts_sent_r.scalar_one() or 0
gifts_claimed_r = await session.execute(
select(func.count()).select_from(GiftUsage).where(GiftUsage.user_id == billing_user_id)
)
gifts_claimed = gifts_claimed_r.scalar_one() or 0
coupons_r = await session.execute(
select(func.count()).select_from(CouponUsage).where(CouponUsage.user_id == billing_user_id)
)
coupons_used = coupons_r.scalar_one() or 0
ref = await get_referral_stats(session, billing_user_id)
partner = await _resolve_partner_snapshot(session, int(billing_user_id))
unread_notifications = await count_unread_for_identity(session, identity.id)
return AccountSummaryResponse(
identity_id=identity.id,
email=identity.email,
tg_id=identity.tg_id,
linked_telegram=identity.tg_id is not None,
created_at=identity.created_at.isoformat() if identity.created_at else None,
password_set=bool(identity.password_set),
referral_code=encode_referral_code(int(billing_user_id)),
balance=balance,
trial_status=int(trial_status),
keys_total=keys_total,
referrals_total=int(ref.get("total_referrals") or 0),
referrals_active=int(ref.get("active_referrals") or 0),
referral_bonus_total=float(ref.get("total_referral_bonus") or 0),
gifts_sent=int(gifts_sent),
gifts_claimed=int(gifts_claimed),
coupons_used=int(coupons_used),
partner_enabled=bool(partner.get("partner_enabled", False)),
partner_code=str(partner.get("partner_code") or ""),
partner_balance=float(partner.get("partner_balance") or 0.0),
partner_percent=float(partner.get("partner_percent") or 0.0),
partner_percent_custom=bool(partner.get("partner_percent_custom", False)),
partner_referred_total=int(partner.get("partner_referred_total") or 0),
partner_referred_paid=int(partner.get("partner_referred_paid") or 0),
partner_payout_method=partner.get("partner_payout_method"),
unread_notifications=int(unread_notifications),
)
class MyPaymentItem(BaseModel):
id: int
payment_id: str | None
amount: float
currency: str
status: str
provider: str
created_at: str | None
purpose: str | None
class MyPaymentsResponse(BaseModel):
ok: bool = True
payments: list[MyPaymentItem]
@router.get("/me/payments", response_model=MyPaymentsResponse)
async def my_payments(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 50,
):
"""История платежей текущего юзера. Привязка через Identity → User → Payment."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return MyPaymentsResponse(ok=True, payments=[])
safe_limit = max(1, min(200, int(limit) if limit else 50))
rows = await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
payments = rows.scalars().all()
items: list[MyPaymentItem] = []
for p in payments:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = None
if meta:
purpose = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if purpose is not None:
purpose = str(purpose)
items.append(
MyPaymentItem(
id=int(p.id),
payment_id=str(p.payment_id) if p.payment_id else None,
amount=float(p.amount or 0),
currency=str(p.currency or "RUB"),
status=str(p.status or ""),
provider=str(p.payment_system or ""),
created_at=p.created_at.isoformat() if p.created_at else None,
purpose=purpose,
)
)
return MyPaymentsResponse(ok=True, payments=items)
def _esc(value: object) -> str:
s = "" if value is None else str(value)
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)
@router.get("/me/payments/{payment_id}/invoice", response_class=HTMLResponse)
async def get_my_payment_invoice(
payment_id: int = Path(..., ge=1),
request: Request = None, # type: ignore[assignment]
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""HTML-инвойс по конкретному платежу. Браузер может сохранить как PDF (Cmd+P → Save as PDF)."""
actor = get_request_actor(request) if request is not None else None
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
payment = (
await session.execute(
select(Payment).where(Payment.id == payment_id, Payment.user_id == billing_user_id).limit(1)
)
).scalar_one_or_none()
if payment is None:
raise HTTPException(status_code=404, detail="Платёж не найден")
meta = payment.metadata_ if isinstance(payment.metadata_, dict) else {}
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
created = payment.created_at.strftime("%d.%m.%Y %H:%M") if payment.created_at else ""
amount_value = float(payment.amount or 0)
currency = str(payment.currency or "RUB").upper()
status_raw = str(payment.status or "")
status_norm = status_raw.lower()
status_label = "ОПЛАЧЕН" if status_norm in {"completed", "success", "paid"} else "ОЖИДАЕТ" if status_norm in {"pending", "processing"} else "ОТКЛОНЁН"
provider = str(payment.payment_system or "").upper() or ""
user_label = identity.email or (f"tg · {identity.tg_id}" if identity.tg_id else identity.id)
payment_identifier = str(payment.payment_id).strip() if payment.payment_id else ""
title_suffix = payment_identifier if payment_identifier else f"#{payment.id}"
html = f"""<!DOCTYPE html>
<html lang=\"ru\">
<head>
<meta charset=\"utf-8\" />
<title>Квитанция {_esc(title_suffix)}</title>
<style>
@page {{ size: A4; margin: 18mm; }}
body {{ font-family: 'JetBrains Mono', ui-monospace, monospace; color: #111; background: #fff; max-width: 720px; margin: 0 auto; padding: 24px; }}
h1 {{ font-size: 24px; letter-spacing: -0.02em; margin: 0 0 4px; text-transform: uppercase; }}
.sub {{ color: #888; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 32px; }}
table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
td {{ padding: 11px 0; border-bottom: 1px dashed #ddd; vertical-align: top; }}
td.k {{ color: #888; width: 35%; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }}
td.v {{ font-weight: 600; }}
.amount {{ font-size: 32px; font-weight: 800; letter-spacing: -0.02em; margin: 24px 0 8px; }}
.badge {{ display: inline-block; padding: 4px 10px; border: 1px solid #111; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }}
.footer {{ margin-top: 48px; font-size: 10px; color: #aaa; letter-spacing: 0.12em; text-transform: uppercase; text-align: center; }}
@media print {{ .no-print {{ display: none; }} }}
.print-btn {{ position: fixed; top: 16px; right: 16px; padding: 10px 16px; background: #111; color: #fff; border: 0; cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; }}
</style>
</head>
<body>
<button class=\"print-btn no-print\" onclick=\"window.print()\">Сохранить PDF</button>
<h1>Квитанция {_esc(title_suffix)}</h1>
<div class=\"sub\">// {_esc(created)}</div>
<div class=\"amount\">{amount_value:,.2f} {_esc(currency)}</div>
<span class=\"badge\">{_esc(status_label)}</span>
<table>
<tr><td class=\"k\">Назначение</td><td class=\"v\">{_esc(purpose) or ""}</td></tr>
<tr><td class=\"k\">Провайдер</td><td class=\"v\">{_esc(provider)}</td></tr>
<tr><td class=\"k\">Дата</td><td class=\"v\">{_esc(created)}</td></tr>
<tr><td class=\"k\">Получатель</td><td class=\"v\">{_esc(user_label)}</td></tr>
<tr><td class=\"k\">Идентификатор платежа</td><td class=\"v\" style=\"font-size:11px;color:#666\">{_esc(payment_identifier) if payment_identifier else ""}</td></tr>
</table>
<div class=\"footer\">Документ сгенерирован автоматически. Не требует подписи и печати.</div>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)
class NotifChannelPref(BaseModel):
channel: str
enabled: bool
class NotifChannelPrefsResponse(BaseModel):
ok: bool = True
channels: list[NotifChannelPref]
class NotifChannelPrefsUpdateRequest(BaseModel):
channels: list[NotifChannelPref]
_NOTIF_CHANNEL_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
@router.get("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def get_my_notification_prefs(
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.put("/me/notification-prefs", response_model=NotifChannelPrefsResponse)
async def set_my_notification_prefs(
body: NotifChannelPrefsUpdateRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
for entry in body.channels:
channel = str(entry.channel or "").strip()
if not channel or not _NOTIF_CHANNEL_RE.match(channel):
raise HTTPException(status_code=422, detail=f"Некорректный канал: {channel!r}")
existing = (
await session.execute(
select(IdentityNotifPref).where(
IdentityNotifPref.identity_id == identity.id,
IdentityNotifPref.channel == channel,
)
)
).scalar_one_or_none()
if existing is None:
session.add(
IdentityNotifPref(identity_id=identity.id, channel=channel, enabled=bool(entry.enabled))
)
else:
existing.enabled = bool(entry.enabled)
await session.flush()
rows = (
await session.execute(
select(IdentityNotifPref).where(IdentityNotifPref.identity_id == identity.id)
)
).scalars().all()
return NotifChannelPrefsResponse(
ok=True,
channels=[NotifChannelPref(channel=str(r.channel), enabled=bool(r.enabled)) for r in rows],
)
@router.get("/me/search", response_model=AccountSearchResponse)
async def my_search(
q: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
limit: int = 8,
):
"""Поиск по подпискам, платежам, уведомлениям текущего user'а. Простое ILIKE."""
query_raw = (q or "").strip()
if len(query_raw) < 2:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
if billing_user_id is None:
return AccountSearchResponse(query=query_raw, hits=[], total=0)
safe_limit = max(1, min(20, int(limit) if limit else 8))
pattern = f"%{query_raw.lower()}%"
hits: list[AccountSearchHit] = []
# Keys: alias / email / server_id
keys_rows = (
await session.execute(
select(Key)
.where(Key.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Key.alias, ""))
.like(pattern)
| func.lower(func.coalesce(Key.email, "")).like(pattern)
| func.lower(func.coalesce(Key.server_id, "")).like(pattern)
| func.lower(func.coalesce(Key.client_id, "")).like(pattern)
)
.limit(safe_limit)
)
).scalars().all()
for k in keys_rows:
label = (k.alias or k.email or k.client_id or "").strip() or ""
sublabel = (k.server_id or "").strip() or ""
hits.append(AccountSearchHit(kind="subscription", label=label, sublabel=sublabel, href="/dashboard/keys", meta=str(k.client_id)))
# Payments: provider / metadata.purpose
payments_rows = (
await session.execute(
select(Payment)
.where(Payment.user_id == billing_user_id)
.where(
func.lower(func.coalesce(Payment.payment_system, "")).like(pattern)
| func.cast(Payment.metadata_, String).ilike(pattern)
)
.order_by(Payment.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for p in payments_rows:
meta = p.metadata_ if isinstance(p.metadata_, dict) else None
purpose = ""
if meta:
v = meta.get("purpose") or meta.get("description") or meta.get("tariff_name")
if v is not None:
purpose = str(v)
amount_label = f"{float(p.amount or 0):,.0f} {(p.currency or 'RUB').upper()}"
hits.append(AccountSearchHit(
kind="payment",
label=purpose or amount_label,
sublabel=f"{(p.payment_system or '').upper()} · {amount_label}",
href="/dashboard",
meta=str(p.id),
))
# Notifications: title / message
notif_rows = (
await session.execute(
select(WebNotification)
.where(WebNotification.identity_id == identity.id)
.where(
func.lower(WebNotification.title).like(pattern)
| func.lower(WebNotification.message).like(pattern)
)
.order_by(WebNotification.created_at.desc())
.limit(safe_limit)
)
).scalars().all()
for n in notif_rows:
hits.append(AccountSearchHit(
kind="notification",
label=str(n.title or ""),
sublabel=(str(n.message or "")[:80]),
href="/dashboard/notifications",
meta=str(n.id),
))
return AccountSearchResponse(query=query_raw, hits=hits, total=len(hits))
@router.post("/set-password")
async def set_password(
body: SetPasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Пароли не совпадают")
updated = await idb.set_initial_password(session, identity.id, body.password)
if not updated:
raise HTTPException(
status_code=409,
detail="Пароль уже установлен или аккаунт недоступен",
)
await bind_identity_actor(request, session, updated)
return {"ok": True}
@router.post("/change-password")
async def change_password(
body: ChangePasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if body.password != body.password_confirm:
raise HTTPException(status_code=400, detail="Новые пароли не совпадают")
err = await idb.change_identity_password(
session,
identity.id,
body.current_password,
body.password,
)
if err == "no_password":
raise HTTPException(
status_code=409,
detail="Пароль ещё не установлен. Сначала задайте пароль в кабинете.",
)
if err == "wrong_password":
raise HTTPException(status_code=401, detail="Неверный текущий пароль")
refreshed = await idb.get_identity_by_id(session, identity.id)
if refreshed:
await bind_identity_actor(request, session, refreshed)
return {"ok": True}
+236
View File
@@ -0,0 +1,236 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import (
BaseModel,
Field as PydanticField,
)
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
verify_identity_token,
)
from api.v2.routes.auth._common import TELEGRAM_LOGIN_MAX_AGE, TOKEN_TTL_HINT, _client_ip, build_login_response
from api.v2.schemas.identities import (
IdentityResponse,
LinkTelegramRequest,
LoginResponse,
LoginTelegramRequest,
)
from config import API_TOKEN
from database import identities as idb
from logger import logger
from utils.telegram_login import verify_telegram_login
router = APIRouter()
def _get_oidc_credentials() -> tuple[str, str]:
try:
from config import TELEGRAM_CLIENT_ID, TELEGRAM_CLIENT_SECRET
cid = str(TELEGRAM_CLIENT_ID).strip()
secret = str(TELEGRAM_CLIENT_SECRET).strip()
if cid and secret:
return cid, secret
except ImportError:
pass
return "", ""
class LoginTelegramWebAppRequest(BaseModel):
init_data: str = PydanticField(..., min_length=1)
@router.post("/login-telegram", response_model=LoginResponse)
async def login_telegram(
body: LoginTelegramRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
(
"""Вход через Telegram Login Widget (кнопка на сайте). По tg_id находим или создаём Identity, выдаём токен. Срок действия токена: """
+ TOKEN_TTL_HINT
+ "."
)
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
identity = await idb.get_or_create_identity_for_tg(session, body.id)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_widget",
identity.id,
body.id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/login-telegram-webapp", response_model=LoginResponse)
async def login_telegram_webapp(
body: LoginTelegramWebAppRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход через Telegram WebApp initData. Валидирует HMAC, находит/создаёт Identity по tg_id."""
from utils.telegram_login import verify_webapp_init_data
result = verify_webapp_init_data(body.init_data, API_TOKEN)
if not result:
raise HTTPException(status_code=401, detail="Неверная подпись initData")
tg_id = result.get("user_id")
if not tg_id:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из initData")
identity = await idb.get_or_create_identity_for_tg(session, int(tg_id))
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_webapp",
identity.id,
tg_id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
class LoginTelegramOIDCRequest(BaseModel):
code: str = PydanticField(..., min_length=1)
redirect_uri: str = PydanticField(..., min_length=1)
code_verifier: str = PydanticField(default="", description="PKCE code_verifier")
@router.post("/login-telegram-oidc", response_model=LoginResponse)
async def login_telegram_oidc(
body: LoginTelegramOIDCRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
"""Вход через Telegram OIDC (authorization code flow). Обменивает code на id_token, верифицирует JWT."""
import base64
import aiohttp
import jwt as pyjwt
client_id, client_secret = _get_oidc_credentials()
if not client_id or not client_secret:
logger.warning("[Auth] Telegram OIDC credentials missing in config")
raise HTTPException(status_code=503, detail="Вход через Telegram временно недоступен")
token_data = {
"grant_type": "authorization_code",
"code": body.code,
"redirect_uri": body.redirect_uri,
}
if body.code_verifier:
token_data["code_verifier"] = body.code_verifier
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded"}
async with aiohttp.ClientSession() as http:
async with http.post("https://oauth.telegram.org/token", data=token_data, headers=headers) as resp:
if resp.status != 200:
err_text = await resp.text()
logger.warning("[Auth] Telegram OIDC token exchange failed: {} {}", resp.status, err_text[:200])
raise HTTPException(status_code=401, detail="Не удалось обменять код авторизации")
token_response = await resp.json()
id_token = token_response.get("id_token")
if not id_token:
raise HTTPException(status_code=401, detail="Telegram не вернул id_token")
async with aiohttp.ClientSession() as http:
async with http.get("https://oauth.telegram.org/.well-known/jwks.json") as resp:
jwks_data = await resp.json()
try:
signing_key = pyjwt.PyJWKClient.__new__(pyjwt.PyJWKClient)
from jwt.api_jwk import PyJWKSet
jwk_set = PyJWKSet.from_dict(jwks_data)
unverified_header = pyjwt.get_unverified_header(id_token)
kid = unverified_header.get("kid")
key = None
for k in jwk_set.keys:
if k.key_id == kid:
key = k
break
if not key:
raise HTTPException(status_code=401, detail="Ключ подписи не найден в JWKS")
claims = pyjwt.decode(
id_token,
key.key,
algorithms=["RS256"],
audience=client_id,
issuer="https://oauth.telegram.org",
)
except pyjwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="ID токен истёк")
except pyjwt.InvalidTokenError as exc:
logger.warning("[Auth] Telegram OIDC JWT invalid: {}", exc)
raise HTTPException(status_code=401, detail="Невалидный ID токен")
logger.info("[Auth] Telegram OIDC claims: {}", {k: v for k, v in claims.items() if k not in ("iat", "exp", "iss", "aud")})
tg_id = claims.get("id") or claims.get("telegram_id")
if not tg_id:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя из id_token")
try:
tg_id_int = int(tg_id)
except (TypeError, ValueError):
raise HTTPException(status_code=401, detail="Не удалось определить пользователя") from None
if tg_id_int <= 0 or tg_id_int > 2**53:
raise HTTPException(status_code=401, detail="Не удалось определить пользователя")
identity = await idb.get_or_create_identity_for_tg(session, tg_id_int)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
if getattr(identity, "is_admin", False):
from database.site_state import mark_site_initialized
await mark_site_initialized(session)
logger.info(
"[Auth] Login success: identity={}, tg_id={}, ip={}, method=telegram_oidc",
identity.id,
tg_id,
_client_ip(request),
)
set_auth_cookie(response, token, request)
set_is_admin_cookie(response, identity, request)
return build_login_response(identity)
@router.post("/link-telegram", response_model=IdentityResponse)
async def link_telegram(
body: LinkTelegramRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Привязывает Telegram к текущей идентичности. Требуется подпись от Telegram Login Widget (доказательство владения аккаунтом)."""
payload = body.model_dump(mode="json")
if not verify_telegram_login(payload, API_TOKEN, max_age_seconds=TELEGRAM_LOGIN_MAX_AGE):
raise HTTPException(status_code=401, detail="Неверная подпись или устаревшие данные от Telegram")
result = await idb.attach_telegram(session, identity.id, body.id)
if not result:
raise HTTPException(
status_code=409,
detail="Этот Telegram уже привязан к другой идентичности",
)
await bind_identity_actor(request, session, result)
set_is_admin_cookie(response, result, request)
return IdentityResponse.model_validate(result)
+207
View File
@@ -0,0 +1,207 @@
import base64
import hashlib
import hmac
import secrets
import time
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
bind_identity_actor,
get_session,
set_auth_cookie,
set_is_admin_cookie,
)
from api.v2.routes.auth._common import _client_ip
from database import identities as idb
from logger import logger
try:
from config import YANDEX_CLIENT_ID as _YANDEX_CLIENT_ID
except ImportError:
_YANDEX_CLIENT_ID = ""
try:
from config import YANDEX_CLIENT_SECRET as _YANDEX_CLIENT_SECRET
except ImportError:
_YANDEX_CLIENT_SECRET = ""
try:
from config import YANDEX_REDIRECT_URI as _YANDEX_REDIRECT_URI
except ImportError:
_YANDEX_REDIRECT_URI = ""
try:
from config import OAUTH_SUCCESS_URI as _OAUTH_SUCCESS_URI
except ImportError:
_OAUTH_SUCCESS_URI = "/dashboard"
try:
from config import API_TOKEN as _YANDEX_STATE_SECRET
except ImportError:
_YANDEX_STATE_SECRET = "solo-yandex-state-fallback"
YANDEX_AUTH_ENDPOINT = "https://oauth.yandex.ru/authorize"
YANDEX_TOKEN_ENDPOINT = "https://oauth.yandex.ru/token"
YANDEX_USERINFO_ENDPOINT = "https://login.yandex.ru/info"
STATE_TTL_SECONDS = 600
router = APIRouter()
def yandex_configured() -> bool:
return bool(_YANDEX_CLIENT_ID and _YANDEX_CLIENT_SECRET and _YANDEX_REDIRECT_URI)
def _sign_state(payload: str) -> str:
mac = hmac.new(str(_YANDEX_STATE_SECRET).encode(), payload.encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac).decode().rstrip("=")
def _make_state(return_to: str) -> str:
nonce = secrets.token_urlsafe(16)
ts = str(int(time.time()))
payload = f"{nonce}.{ts}.{return_to}"
sig = _sign_state(payload)
raw = f"{payload}.{sig}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def _verify_state(state: str) -> str | None:
try:
padded = state + "=" * (-len(state) % 4)
raw = base64.urlsafe_b64decode(padded).decode()
except Exception:
return None
parts = raw.rsplit(".", 1)
if len(parts) != 2:
return None
payload, sig = parts
expected = _sign_state(payload)
if not hmac.compare_digest(sig, expected):
return None
chunks = payload.split(".", 2)
if len(chunks) != 3:
return None
_nonce, ts, return_to = chunks
try:
if int(time.time()) - int(ts) > STATE_TTL_SECONDS:
return None
except Exception:
return None
return return_to or _OAUTH_SUCCESS_URI
@router.get("/yandex/authorize")
async def yandex_authorize(
request: Request,
return_to: str = Query(default=""),
):
"""Начинает OAuth-флоу Яндекс ID: редиректит юзера на consent screen."""
if not yandex_configured():
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
safe_return = return_to if return_to.startswith("/") else _OAUTH_SUCCESS_URI
state = _make_state(safe_return)
params = {
"client_id": _YANDEX_CLIENT_ID,
"redirect_uri": _YANDEX_REDIRECT_URI,
"response_type": "code",
"state": state,
"force_confirm": "yes",
}
url = f"{YANDEX_AUTH_ENDPOINT}?{urlencode(params)}"
logger.info("[Auth] Yandex authorize: ip={}", _client_ip(request))
return RedirectResponse(url, status_code=302)
@router.get("/yandex/callback")
async def yandex_callback(
request: Request,
response: Response,
code: str = Query(default=""),
state: str = Query(default=""),
error: str = Query(default=""),
session: AsyncSession = Depends(get_session),
):
"""Коллбек Яндекс ID: обмен code → token → userinfo → identity."""
if not yandex_configured():
raise HTTPException(status_code=503, detail="Вход через Яндекс не настроен на этом сервере")
if error:
logger.warning("[Auth] Yandex callback error: {} ip={}", error, _client_ip(request))
return RedirectResponse(f"/login?error=yandex_{error}", status_code=302)
if not code or not state:
raise HTTPException(status_code=400, detail="Отсутствует code или state")
return_to = _verify_state(state)
if return_to is None:
logger.warning("[Auth] Yandex callback: invalid/expired state ip={}", _client_ip(request))
raise HTTPException(status_code=400, detail="Неверный или просроченный state")
async with httpx.AsyncClient(timeout=10.0) as client:
try:
token_res = await client.post(
YANDEX_TOKEN_ENDPOINT,
data={
"grant_type": "authorization_code",
"code": code,
"client_id": _YANDEX_CLIENT_ID,
"client_secret": _YANDEX_CLIENT_SECRET,
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Yandex token exchange network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось связаться с Яндекс") from e
if token_res.status_code != 200:
logger.warning("[Auth] Yandex token exchange failed: {} {}", token_res.status_code, token_res.text[:200])
raise HTTPException(status_code=401, detail="Яндекс отклонил токен")
token_payload = token_res.json()
access_token = token_payload.get("access_token")
if not access_token:
raise HTTPException(status_code=401, detail="Яндекс не вернул access_token")
try:
info_res = await client.get(
YANDEX_USERINFO_ENDPOINT,
headers={"Authorization": f"OAuth {access_token}"},
params={"format": "json"},
)
except httpx.HTTPError as e:
logger.warning("[Auth] Yandex userinfo network error: {}", e)
raise HTTPException(status_code=502, detail="Не удалось получить профиль Яндекс") from e
if info_res.status_code != 200:
raise HTTPException(status_code=401, detail="Яндекс не вернул профиль")
info = info_res.json()
yandex_sub = str(info.get("id") or "").strip()
email = (info.get("default_email") or "").strip().lower() or None
if not yandex_sub:
raise HTTPException(status_code=401, detail="Яндекс не вернул идентификатор пользователя")
identity = await idb.get_or_create_identity_for_yandex(
session,
yandex_sub=yandex_sub,
email=email,
)
await bind_identity_actor(request, session, identity)
token = await idb.issue_token_for_identity(session, identity, request=request)
logger.info(
"[Auth] Login success: identity={}, yandex_sub={}, ip={}, method=yandex",
identity.id,
yandex_sub,
_client_ip(request),
)
redirect = RedirectResponse(return_to, status_code=302)
set_auth_cookie(redirect, token, request)
set_is_admin_cookie(redirect, identity, request)
return redirect
@router.get("/yandex/status")
async def yandex_status():
"""Позволяет фронтенду узнать, настроен ли вход через Яндекс на этом сервере."""
return {"enabled": yandex_configured()}
+31
View File
@@ -0,0 +1,31 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from services.coupons import resolve_percent_coupon
from services.errors import ServiceError
async def resolve_percent_coupon_pricing(
session: AsyncSession,
billing_user_id: int,
base_price_rub: int,
coupon_code: str | None,
) -> tuple[int, int, int | None, str | None]:
"""Применяет процентный купон. Бросает HTTPException при ошибке."""
try:
return await resolve_percent_coupon(
session=session,
billing_user_id=billing_user_id,
base_price_rub=base_price_rub,
coupon_code=coupon_code,
)
except ServiceError as e:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
}
raise HTTPException(
status_code=status_map.get(e.code, 400),
detail=e.message,
)
+73
View File
@@ -0,0 +1,73 @@
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_request_actor, get_session, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import CouponBase, CouponResponse, CouponUpdate
from api.v2.schemas.web_public import CouponApplyRequest, CouponApplyResponse
from database import identities as idb
from database.models import Coupon
from services.coupons import apply_fixed_coupon
from services.errors import LimitExceededError, NotFoundError, ServiceError, ValidationError
router = 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"],
)
async def _resolve_coupon_user_id(session: AsyncSession, request: Request, identity) -> tuple[int, int | None]:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tg_id = actor.telegram_chat_id if actor else None
return int(billing_user_id), tg_id
def _service_error_to_http(e: ServiceError) -> HTTPException:
status_map = {
"not_found": 404,
"limit_exceeded": 409,
"validation_error": 400,
"forbidden": 403,
}
return HTTPException(status_code=status_map.get(e.code, 400), detail=e.message)
@router.post("/apply", response_model=CouponApplyResponse)
async def apply_coupon(
body: CouponApplyRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="coupon_apply", max_per_window=10, window_sec=60)
user_id, tg_id = await _resolve_coupon_user_id(session, request, identity)
try:
result = await apply_fixed_coupon(
session=session,
user_id=user_id,
tg_id=tg_id,
code=str(body.code or ""),
)
return CouponApplyResponse(
ok=True,
message="Купон успешно активирован",
coupon_code=result.coupon_code,
amount=result.amount,
balance=result.balance,
)
except ServiceError as e:
await session.rollback()
raise _service_error_to_http(e)
except Exception:
await session.rollback()
raise HTTPException(status_code=500, detail="Ошибка активации купона")
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.flows import FlowCreate, FlowResponse, FlowUpdate
from database.models import WebFlow
from database.site_revision import bump_site_revision
router = APIRouter()
def _flow_to_response(flow: WebFlow) -> FlowResponse:
return FlowResponse(
id=flow.id,
name=flow.name,
nodes=flow.nodes or [],
edges=flow.edges or [],
entry_node_id=flow.entry_node_id,
version=flow.version,
)
@router.get("/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def get_flow_public(flow_id: str, session: AsyncSession = Depends(get_session)):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.get("/admin/flows", response_model=list[FlowResponse], response_model_by_alias=True)
async def list_flows(
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
result = await session.execute(select(WebFlow))
return [_flow_to_response(f) for f in result.scalars().all()]
@router.get("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def get_flow_admin(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
return _flow_to_response(flow)
@router.post("/admin/flows", response_model=FlowResponse, status_code=201, response_model_by_alias=True)
async def create_flow(
body: FlowCreate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
existing = await session.get(WebFlow, body.id)
if existing:
raise HTTPException(409, "Flow with this ID already exists")
flow = WebFlow(
id=body.id,
name=body.name,
nodes=[n.model_dump() for n in body.nodes],
edges=[e.model_dump() for e in body.edges],
entry_node_id=body.entry_node_id,
version=1,
updated_at=datetime.now(UTC),
)
session.add(flow)
await bump_site_revision(session)
await session.refresh(flow)
return _flow_to_response(flow)
@router.put("/admin/flows/{flow_id}", response_model=FlowResponse, response_model_by_alias=True)
async def update_flow(
flow_id: str,
body: FlowUpdate,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
if body.name is not None:
flow.name = body.name
flow.nodes = [n.model_dump() for n in body.nodes]
flow.edges = [e.model_dump() for e in body.edges]
flow.entry_node_id = body.entry_node_id
flow.version = flow.version + 1
flow.updated_at = datetime.now(UTC)
await bump_site_revision(session)
await session.refresh(flow)
return _flow_to_response(flow)
@router.delete("/admin/flows/{flow_id}", status_code=204)
async def delete_flow(
flow_id: str,
session: AsyncSession = Depends(get_session),
_identity=Depends(verify_identity_admin),
):
flow = await session.get(WebFlow, flow_id)
if not flow:
raise HTTPException(404, "Flow not found")
await session.delete(flow)
await bump_site_revision(session)
+337
View File
@@ -0,0 +1,337 @@
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.tariffs import _resolve_default_web_payment_provider, _resolve_public_base_url
from api.v2.schemas import GiftBase, GiftResponse, GiftUpdate, GiftUsageResponse
from api.v2.schemas.web_public import (
GiftCreatePreviewResponse,
GiftCreateRequest,
GiftCreateResponse,
GiftRedeemRequest,
GiftRedeemResponse,
GiftUsageEntry,
MyGiftItem,
MyGiftsResponse,
)
from config import GIFT_BUTTON
from core.bootstrap import BUTTONS_CONFIG
from database import (
get_balance,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.models import Gift, GiftUsage, Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from services.errors import NotFoundError, ValidationError
from services.formatting import get_site_gift_link
from services.gifts import (
create_gift as service_create_gift,
redeem_gift as service_redeem_gift,
)
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.tariffs import calculate_config_price
router = APIRouter()
def _check_gifts_enabled():
if not bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)):
raise HTTPException(status_code=403, detail="Подарки отключены")
@router.post("/create", tags=["Gifts"])
async def create_gift_for_user(
body: GiftCreateRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
if not preview:
await enforce_rate_limit(request, session, bucket="gift_create", max_per_window=10, window_sec=60)
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or tariff.get("group_code") != "gifts" or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
balance = float(await get_balance(session, billing_user_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if preview:
return GiftCreatePreviewResponse(
ok=True,
price_rub=price,
balance_rub=balance,
sufficient_funds=balance >= price,
tariff_name=str(tariff.get("name", "")),
duration_days=int(tariff.get("duration_days") or 0),
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_price_rub": int(price),
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_payment",
{
"payment_flow": "gift_create",
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
},
)
return GiftCreateResponse(
ok=True,
message="Требуется оплата для создания подарка",
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
from services.errors import InsufficientFundsError, NotFoundError
try:
result = await service_create_gift(
session=session,
sender_user_ref=billing_user_id,
tariff_id=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=price,
)
except InsufficientFundsError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
new_balance = float(await get_balance(session, billing_user_id))
return GiftCreateResponse(
ok=True,
message=f"Подарок создан — {result.tariff_name} на {result.duration_text}",
gift_id=result.gift_id,
site_gift_link=result.site_gift_link,
tariff_name=result.tariff_name,
duration_days=result.duration_days,
price_charged=result.price_charged,
balance_rub=new_balance,
)
@router.get("/my", response_model=MyGiftsResponse, tags=["Gifts"])
async def get_my_gifts(
request: Request,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
base_filter = Gift.sender_user_id == billing_user_id
total = (await session.execute(select(func.count()).select_from(Gift).where(base_filter))).scalar_one()
result = await session.execute(
select(Gift).where(base_filter).order_by(Gift.created_at.desc()).limit(limit).offset(offset)
)
gifts = result.scalars().all()
tariff_ids = {g.tariff_id for g in gifts if g.tariff_id}
tariff_map: dict[int, str] = {}
duration_map: dict[int, int] = {}
if tariff_ids:
tariff_rows = await session.execute(select(Tariff).where(Tariff.id.in_(tariff_ids)))
for t in tariff_rows.scalars().all():
tariff_map[t.id] = t.name or ""
duration_map[t.id] = int(t.duration_days or 0)
gift_ids = [g.gift_id for g in gifts]
usages_map: dict[str, list[GiftUsageEntry]] = {gid: [] for gid in gift_ids}
if gift_ids:
usage_rows = await session.execute(select(GiftUsage).where(GiftUsage.gift_id.in_(gift_ids)))
for u in usage_rows.scalars().all():
usages_map.setdefault(u.gift_id, []).append(
GiftUsageEntry(
user_id=int(u.user_id),
used_at=u.used_at.isoformat() if u.used_at else None,
)
)
items = []
for g in gifts:
items.append(
MyGiftItem(
gift_id=g.gift_id,
tariff_name=tariff_map.get(g.tariff_id, ""),
duration_days=duration_map.get(g.tariff_id, 0),
price_rub=int(g.selected_price_rub or 0),
created_at=g.created_at.isoformat() if g.created_at else None,
expiry_time=g.expiry_time.isoformat() if g.expiry_time else None,
is_used=bool(g.is_used),
is_unlimited=bool(g.is_unlimited),
max_usages=g.max_usages,
site_gift_link=get_site_gift_link(g.gift_id),
usages=usages_map.get(g.gift_id, []),
)
)
return MyGiftsResponse(ok=True, gifts=items, total=total, limit=limit, offset=offset)
@router.delete("/my/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_my_gift(
request: Request,
gift_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Удаляет свой подарок."""
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
result = await session.execute(select(Gift).where(Gift.gift_id == gift_id))
gift = result.scalar_one_or_none()
if not gift or gift.sender_user_id != billing_user_id:
raise HTTPException(status_code=404, detail="Подарок не найден")
await session.execute(delete(GiftUsage).where(GiftUsage.gift_id == gift_id))
await session.delete(gift)
return {"ok": True, "message": "Подарок удалён"}
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.post("/redeem", response_model=GiftRedeemResponse, tags=["Gifts"])
async def redeem_gift(
body: GiftRedeemRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="gift_redeem", max_per_window=10, window_sec=60)
_check_gifts_enabled()
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
try:
result = await service_redeem_gift(session, body.gift_code, billing_user_id)
except ValidationError as e:
raise HTTPException(status_code=400, detail=str(e)) from None
except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from None
except Exception:
raise HTTPException(status_code=500, detail="Не удалось активировать подарок") from None
return GiftRedeemResponse(
ok=True,
message=result.message,
gift_id=result.gift_id,
tariff_id=result.tariff_id,
duration_days=result.duration_days,
)
@router.get("/by_tg_id/{tg_id}", response_model=list[GiftResponse], tags=["Gifts"])
async def get_gifts_by_tg_id(
tg_id: int = Path(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список подарков по tg_id отправителя."""
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Gifts not found")
result = await session.execute(select(Gift).where(Gift.sender_user_id == u.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=["Gifts", "GiftUsages"])
@router.delete("/{gift_id}", response_model=dict, tags=["Gifts"])
async def delete_gift_with_usages(
gift_id: str = Path(..., description="ID подарка"),
identity=Depends(verify_identity_admin),
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)
return {"message": "Подарок и связанные использования удалены"}
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas.identities import (
IdentityAttachEmail,
IdentityAttachTelegram,
IdentityCreate,
IdentityResponse,
)
from database import identities as idb
router = APIRouter(tags=["Identities"])
@router.post("/", response_model=IdentityResponse)
async def create_identity(
body: IdentityCreate,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Создаёт идентичность; можно указать email и/или tg_id."""
email = body.email.strip().lower() if body.email and body.email.strip() else None
if not email and not body.tg_id:
raise HTTPException(status_code=400, detail="Укажите email и/или tg_id")
identity = await idb.create_identity(session, email=email, tg_id=body.tg_id)
return IdentityResponse.model_validate(identity)
@router.get("/{identity_id}", response_model=IdentityResponse)
async def get_identity(
identity_id: str = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по id."""
identity = await idb.get_identity_by_id(session, identity_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/email", response_model=IdentityResponse)
async def get_identity_by_email(
email: str = Query(..., min_length=1),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по email."""
identity = await idb.get_identity_by_email(session, email)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.get("/by/tg_id/{tg_id}", response_model=IdentityResponse)
async def get_identity_by_tg_id(
tg_id: int = Path(...),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Возвращает идентичность по Telegram ID."""
identity = await idb.get_identity_by_tg_id(session, tg_id)
if not identity:
raise HTTPException(status_code=404, detail="Identity not found")
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-email", response_model=IdentityResponse)
async def attach_email(
identity_id: str = Path(...),
body: IdentityAttachEmail = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает email к идентичности."""
identity = await idb.attach_email(session, identity_id, body.email)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или email уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
@router.patch("/{identity_id}/attach-telegram", response_model=IdentityResponse)
async def attach_telegram(
identity_id: str = Path(...),
body: IdentityAttachTelegram = ...,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Привязывает Telegram (tg_id) к идентичности."""
identity = await idb.attach_telegram(session, identity_id, body.tg_id)
if not identity:
raise HTTPException(
status_code=404,
detail="Identity not found или tg_id уже привязан к другой идентичности",
)
return IdentityResponse.model_validate(identity)
+5
View File
@@ -0,0 +1,5 @@
from . import admin, user # noqa: F401 — import triggers endpoint registration
from ._common import router, user_router
__all__ = ["router", "user_router"]
+271
View File
@@ -0,0 +1,271 @@
import asyncio
import re
from base64 import b64encode
from datetime import datetime
from io import BytesIO
from math import ceil
from typing import Any
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import (
get_request_actor,
get_session,
validate_redirect_url,
verify_identity_admin,
verify_identity_token,
)
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import KeyBase, KeyCreateRequest, KeyResponse, KeyUpdate
from api.v2.schemas.web_public import (
AccountKeyActionResponse,
AccountKeyActionsAvailability,
AccountKeyActionsConfigResponse,
AccountKeyAddonOptionResponse,
AccountKeyAddonsPreviewRequest,
AccountKeyAddonsPreviewResponse,
AccountKeyAliasUpdateRequest,
AccountKeyApplyAddonsResponse,
AccountKeyChangeLocationRequest,
AccountKeyChangeLocationResponse,
AccountKeyConnectionResponse,
AccountKeyDetailsResponse,
AccountKeyLocationOptionResponse,
AccountKeyLocationsResponse,
AccountKeyQrResponse,
AccountKeyRenewRequest,
AccountKeyRenewResponse,
AccountKeyResetHwidResponse,
AccountKeyResponse,
)
from config import (
ENABLE_DELETE_KEY_BUTTON,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
QRCODE,
REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, PAYMENTS_CONFIG, TARIFFS_CONFIG
from core.settings.tariffs_config import normalize_tariff_config
from database import (
check_server_name_by_cluster,
filter_cluster_by_subgroup,
get_balance,
get_key_details,
get_keys,
get_tariff_by_id,
identities as idb,
save_key_config_with_mode,
update_balance,
)
from database.access.resolution import resolve_user_optional
from database.coupons import mark_coupon_used
from database.models import Key, Server, ServerSpecialgroup, Tariff
from database.temporary_data import create_temporary_data
from handlers.buttons import CONNECT_DEVICE, ROUTER_BUTTON, TV_BUTTON
from handlers.keys.key_view import build_key_view_payload
from handlers.tariffs.addons.key_addons_pack import calc_pack_full_price_rub, get_pack_flags
from handlers.tariffs.addons.utils import calc_remaining_ratio_seconds, is_not_downgrade
from handlers.utils import ALLOWED_GROUP_CODES, is_full_remnawave_cluster
from logger import logger
from panels._3xui import delete_client, get_xui_instance
from panels.remnawave import RemnawaveAPI, get_vless_link_for_remnawave_by_username
from panels.remnawave_runtime import get_remnawave_profile, invalidate_remnawave_profile, with_remnawave_api
from services.operations import (
create_client_on_server,
create_key_on_cluster,
delete_key_from_cluster,
renew_key_in_cluster,
)
from services.operations.aggregated_links import make_aggregated_link
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
from services.tariffs.tariff_display import GB, get_effective_limits_for_key, get_key_tariff_addons_state
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"],
)
user_router = APIRouter()
def _key_actions_config() -> AccountKeyActionsConfigResponse:
addons_mode = str(TARIFFS_CONFIG.get("KEY_ADDONS_PACK_MODE", "") or "").strip().lower()
if addons_mode not in {"", "traffic", "devices", "all"}:
addons_mode = ""
addons_enabled_default = addons_mode in {"", "traffic", "devices", "all"}
return AccountKeyActionsConfigResponse(
renew_enabled=True,
delete_enabled=bool(BUTTONS_CONFIG.get("DELETE_KEY_BUTTON_ENABLE", ENABLE_DELETE_KEY_BUTTON)),
qr_enabled=bool(BUTTONS_CONFIG.get("QRCODE_BUTTON_ENABLE", QRCODE)),
hwid_reset_enabled=bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
country_change_enabled=bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
instructions_enabled=bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
addons_enabled=addons_enabled_default,
addons_mode=addons_mode,
tv_connect_enabled=bool(BUTTONS_CONFIG.get("ANDROID_TV_BUTTON_ENABLE")),
)
def _extract_key_actions_from_markup(markup) -> AccountKeyActionsAvailability:
actions = AccountKeyActionsAvailability()
rows = getattr(markup, "inline_keyboard", None) or []
for row in rows:
for button in row:
callback_data = str(getattr(button, "callback_data", "") or "")
text = str(getattr(button, "text", "") or "")
has_url = bool(getattr(button, "url", None))
has_web_app = bool(getattr(button, "web_app", None))
if callback_data.startswith("connect_router|") or text == ROUTER_BUTTON:
actions.can_connect_router = True
if callback_data.startswith("connect_tv|") or text == TV_BUTTON:
actions.can_connect_tv = True
if callback_data.startswith("connect_device|") or (text == CONNECT_DEVICE and (has_url or has_web_app)):
actions.can_connect_device = True
if callback_data.startswith("renew_key|"):
actions.can_renew = True
if callback_data.startswith("key_addons|"):
actions.can_addons = True
if callback_data.startswith("reset_hwid|"):
actions.can_reset_hwid = True
if callback_data.startswith("show_qr|"):
actions.can_qr = True
if callback_data.startswith("delete_key|"):
actions.can_delete = True
if callback_data.startswith("change_location|"):
actions.can_change_location = True
return actions
async def _resolve_available_location_servers(session: AsyncSession, db_key: Key) -> list[str]:
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
return []
cluster_info = await check_server_name_by_cluster(session, current_server)
if not cluster_info:
return []
cluster_name = str(cluster_info.get("cluster_name") or "")
if not cluster_name:
return []
q = (
select(
Server.id,
Server.server_name,
Server.api_url,
Server.panel_type,
Server.enabled,
Server.max_keys,
)
.where(Server.cluster_name == cluster_name)
.where(Server.server_name != current_server)
)
servers = [dict(m) for m in (await session.execute(q)).mappings().all()]
if not servers:
return []
server_ids = [s["id"] for s in servers if s.get("id") is not None]
groups_map: dict[int, list[str]] = {}
if server_ids:
r = await session.execute(
select(ServerSpecialgroup.server_id, ServerSpecialgroup.group_code).where(
ServerSpecialgroup.server_id.in_(server_ids)
)
)
for sid, gc in r.all():
groups_map.setdefault(int(sid), []).append(gc)
for server in servers:
sid_raw = server.get("id")
sid = int(sid_raw) if sid_raw is not None else -1
server["special_groups"] = [g for g in groups_map.get(sid, []) if g in ALLOWED_GROUP_CODES]
key_tariff_id = getattr(db_key, "tariff_id", None)
subgroup_title = None
tariff_dict = None
if key_tariff_id:
tariff_dict = await get_tariff_by_id(session, int(key_tariff_id))
if tariff_dict:
subgroup_title = tariff_dict.get("subgroup_title")
available_servers = [s for s in servers if bool(s.get("enabled", True))]
if subgroup_title and available_servers:
filtered_servers = await filter_cluster_by_subgroup(
session=session,
cluster=available_servers,
target_subgroup=str(subgroup_title).strip(),
cluster_id=cluster_name,
tariff_id=int(key_tariff_id) if key_tariff_id else None,
)
if filtered_servers:
available_servers = filtered_servers
else:
available_servers = []
if available_servers and tariff_dict:
special = None
gc = str(tariff_dict.get("group_code") or "").lower()
if gc and gc in ALLOWED_GROUP_CODES:
special = gc
if special:
bound_servers = [s for s in available_servers if special in (s.get("special_groups") or [])]
if bound_servers:
available_servers = bound_servers
names = sorted({
str(s.get("server_name") or "").strip() for s in available_servers if str(s.get("server_name") or "").strip()
})
return names
async def _resolve_billing_user_id(request: Request, identity, session: AsyncSession) -> int:
actor = get_request_actor(request)
billing_user_id = actor.billing_user_id if actor and actor.billing_user_id is not None else None
if billing_user_id is None:
billing_user_id = await idb.ensure_billing_user_for_identity(session, identity)
return int(billing_user_id)
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _normalize_expiry_ms(raw_value: int | float | None) -> int:
if not raw_value:
return 0
value = int(raw_value)
if value > 10**13:
value //= 1000
elif value < 10**10:
value *= 1000
return value
+115
View File
@@ -0,0 +1,115 @@
from ._common import * # noqa: F401,F403
from ._common import router, user_router # noqa: F401
@router.delete("/by_email/{email}", response_model=dict)
async def delete_key_by_email(
email: str = Path(..., description="Email клиента"),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_admin),
):
"""Удаляет ключ по email с кластера и из БД."""
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)
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),
identity=Depends(verify_identity_admin),
):
"""Список ключей пользователя с тарифами группы routers."""
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 []
u = await resolve_user_optional(session, tg_id)
if u is None:
return []
keys_result = await session.execute(select(Key).where(Key.user_id == u.id, Key.tariff_id.in_(tariff_ids)))
return keys_result.scalars().all()
@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),
identity=Depends(verify_identity_admin),
):
"""Обновляет ключ по email и синхронизирует с кластером."""
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.model_dump(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,
)
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),
identity=Depends(verify_identity_admin),
):
"""Создаёт ключ на кластере."""
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="Ошибка при создании ключа")
+1
View File
@@ -0,0 +1 @@
from . import addons, core, hwid, location, renew # noqa: F401 — trigger registration
+662
View File
@@ -0,0 +1,662 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("/{client_id}/addons-preview", response_model=AccountKeyAddonsPreviewResponse)
async def user_key_addons_preview(
client_id: str,
request: Request,
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
include_device: bool | None = Query(None),
include_traffic: bool | None = Query(None),
coupon_code: str | None = Query(None),
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = (
bool(include_device) if include_device is not None else selected_device_limit is not None
)
include_traffic_effective = (
bool(include_traffic) if include_traffic is not None else selected_traffic_gb is not None
)
selected_device = selected_device_limit if selected_device_limit is not None else None
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = selected_device_limit if selected_device_limit is not None else current_device_limit
selected_traffic = selected_traffic_gb if selected_traffic_gb is not None else current_traffic_gb
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_rub = int(base_price_for_current + diff_full)
else:
total_price_rub = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, total_price_rub - base_price_for_current))
final_extra_price_rub, discount_rub, _coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=coupon_code,
)
return AccountKeyAddonsPreviewResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
addons_mode=str(pack_mode or ""),
has_device_option=bool(has_device_option),
has_traffic_option=bool(has_traffic_option),
current_device_limit=int(current_device_limit) if current_device_limit is not None else None,
current_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
selected_device_limit=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
device_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=(
"Безлимит устройств"
if int(val) <= 0
else (f"+{int(val)} устройств" if pack_mode else f"{int(val)} устройств")
),
)
for val in device_options
],
traffic_options=[
AccountKeyAddonOptionResponse(
value=int(val),
label=("Безлимит трафика" if int(val) <= 0 else (f"+{int(val)} ГБ" if pack_mode else f"{int(val)} ГБ")),
)
for val in traffic_options
],
total_price_rub=int(total_price_rub),
extra_price_rub=int(max(0, extra_price_rub)),
discount_rub=int(discount_rub),
final_price_rub=int(max(0, final_extra_price_rub)),
applied_coupon_code=applied_coupon_code,
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
@user_router.post("/{client_id}/apply-addons", response_model=AccountKeyApplyAddonsResponse)
async def user_key_apply_addons(
client_id: str,
body: AccountKeyAddonsPreviewRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.addons_enabled:
raise HTTPException(status_code=403, detail="Доп. опции отключены в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff:
raise HTTPException(status_code=404, detail="Тариф не найден")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
(
_tariff_name,
_subgroup_title,
_traffic_limit_gb,
_device_limit,
_panel,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(session=session, key_record=key_details, db_key=db_key)
if not is_tariff_configurable:
raise HTTPException(status_code=400, detail="Тариф не поддерживает доп. опции")
cfg = normalize_tariff_config(tariff)
raw_device_options = cfg.get("device_options") or tariff.get("device_options") or []
raw_traffic_options = cfg.get("traffic_options_gb") or tariff.get("traffic_options_gb") or []
device_options: list[int] = []
for value in raw_device_options:
try:
device_options.append(int(value))
except (TypeError, ValueError):
continue
traffic_options: list[int] = []
for value in raw_traffic_options:
try:
traffic_options.append(int(value))
except (TypeError, ValueError):
continue
device_options = sorted(set(device_options), key=lambda val: (int(val == 0), val))
traffic_options = sorted(set(traffic_options), key=lambda val: (int(val == 0), val))
has_device_option = bool(device_options) and bool(addons_devices_enabled)
has_traffic_option = bool(traffic_options) and bool(addons_traffic_enabled)
pack_devices, pack_traffic, pack_mode = get_pack_flags()
if pack_mode:
has_device_option = has_device_option and bool(pack_devices)
has_traffic_option = has_traffic_option and bool(pack_traffic)
if not has_device_option:
device_options = []
if not has_traffic_option:
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
selected_device_limit_db = key_details.get("selected_device_limit")
selected_traffic_limit_db = key_details.get("selected_traffic_limit")
current_device_limit_db = key_details.get("current_device_limit")
current_traffic_limit_db = key_details.get("current_traffic_limit")
base_devices = tariff.get("device_limit")
base_devices = int(base_devices) if base_devices is not None else None
base_traffic_bytes = tariff.get("traffic_limit")
base_traffic_gb_from_tariff = int(base_traffic_bytes / GB) if base_traffic_bytes else None
current_device_limit = (
int(current_device_limit_db)
if current_device_limit_db is not None
else (int(selected_device_limit_db) if selected_device_limit_db is not None else base_devices)
)
current_traffic_gb = (
int(current_traffic_limit_db)
if current_traffic_limit_db is not None
else (int(selected_traffic_limit_db) if selected_traffic_limit_db is not None else base_traffic_gb_from_tariff)
)
if pack_mode and current_device_limit is not None and int(current_device_limit) == 0:
has_device_option = False
device_options = []
if pack_mode and current_traffic_gb is not None and int(current_traffic_gb) == 0:
has_traffic_option = False
traffic_options = []
if not has_device_option and not has_traffic_option:
raise HTTPException(status_code=400, detail="Доп. опции для этой подписки недоступны")
if pack_mode:
include_device_effective = (
bool(body.include_device) if body.include_device is not None else body.selected_device_limit is not None
)
include_traffic_effective = (
bool(body.include_traffic) if body.include_traffic is not None else body.selected_traffic_gb is not None
)
selected_device = body.selected_device_limit if body.selected_device_limit is not None else None
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else None
else:
include_device_effective = has_device_option
include_traffic_effective = has_traffic_option
selected_device = body.selected_device_limit if body.selected_device_limit is not None else current_device_limit
selected_traffic = body.selected_traffic_gb if body.selected_traffic_gb is not None else current_traffic_gb
if (
has_device_option
and include_device_effective
and selected_device is not None
and int(selected_device) not in device_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет устройств недоступен")
if (
has_traffic_option
and include_traffic_effective
and selected_traffic is not None
and int(selected_traffic) not in traffic_options
):
raise HTTPException(status_code=400, detail="Выбранный пакет трафика недоступен")
current_devices_for_price = int(current_device_limit) if current_device_limit is not None else None
current_traffic_for_price = int(current_traffic_gb) if current_traffic_gb is not None else None
base_price_for_current = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=current_devices_for_price,
selected_traffic_gb=current_traffic_for_price,
)
)
total_price_after_purchase = base_price_for_current
if pack_mode:
diff_full = int(
calc_pack_full_price_rub(
tariff=tariff,
has_device_option=bool(has_device_option and include_device_effective),
has_traffic_option=bool(has_traffic_option and include_traffic_effective),
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
)
)
recalc_enabled = bool(
MODES_CONFIG.get(
"KEY_ADDONS_RECALC_PRICE",
TARIFFS_CONFIG.get("KEY_ADDONS_RECALC_PRICE", False),
)
)
if recalc_enabled:
remaining_seconds, total_seconds = calc_remaining_ratio_seconds(
key_details.get("expiry_time"),
tariff,
)
extra_price_rub = int((diff_full * remaining_seconds + total_seconds - 1) // total_seconds)
else:
extra_price_rub = diff_full
total_price_after_purchase = int(base_price_for_current + diff_full)
else:
selected_total_price = int(
calculate_config_price(
tariff=tariff,
selected_device_limit=int(selected_device)
if has_device_option and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and selected_traffic is not None
else None,
)
)
extra_price_rub = int(max(0, selected_total_price - base_price_for_current))
total_price_after_purchase = selected_total_price
allow_downgrade = bool(TARIFFS_CONFIG.get("ALLOW_DOWNGRADE", True))
device_downgrade = (
allow_downgrade
and has_device_option
and current_device_limit is not None
and selected_device is not None
and not is_not_downgrade(current_device_limit, selected_device)
)
traffic_downgrade = (
allow_downgrade
and has_traffic_option
and current_traffic_gb is not None
and selected_traffic is not None
and not is_not_downgrade(current_traffic_gb, selected_traffic)
)
if device_downgrade or traffic_downgrade:
raise HTTPException(status_code=400, detail="Снижение параметров через сайт пока не поддерживается")
final_extra_price_rub, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(billing_user_id),
base_price_rub=int(max(0, extra_price_rub)),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, int(billing_user_id)))
required_amount = int(max(0, ceil(float(final_extra_price_rub) - balance)))
if extra_price_rub <= 0:
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доплата не требуется",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=0,
discount_rub=0,
final_price_rub=0,
applied_coupon_code=None,
charged_rub=0,
balance_rub=balance,
)
expiry_time = int(getattr(db_key, "expiry_time", 0) or 0)
email = str(getattr(db_key, "email", "") or "")
server_id = str(getattr(db_key, "server_id", "") or "")
if not email or not server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_addons",
"tariff_id": int(tariff_id),
"email": email,
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_addons_payment",
{
"tariff_id": int(tariff_id),
"email": email,
"required_amount": int(required_amount),
"selected_device_limit": int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
"selected_traffic_gb": int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
"current_device_limit": int(current_device_limit) if current_device_limit is not None else None,
"current_traffic_gb": int(current_traffic_gb) if current_traffic_gb is not None else None,
"original_price": int(base_price_for_current),
"base_price_rub": int(max(0, extra_price_rub)),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return AccountKeyApplyAddonsResponse(
ok=True,
message="Требуется оплата для применения доп. опций",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=0,
balance_rub=balance,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
target_subgroup = tariff.get("subgroup_title")
current_subgroup = None
current_tariff_id = key_details.get("tariff_id")
if current_tariff_id:
current_tariff = await get_tariff_by_id(session, int(current_tariff_id))
if current_tariff:
current_subgroup = current_tariff.get("subgroup_title")
if pack_mode:
device_limit_effective_current, traffic_limit_bytes_effective_current = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=int(current_device_limit) if current_device_limit is not None else None,
selected_traffic_gb=int(current_traffic_gb) if current_traffic_gb is not None else None,
)
traffic_limit_gb_effective_current = (
int(traffic_limit_bytes_effective_current / GB) if traffic_limit_bytes_effective_current else 0
)
new_device_limit_effective = device_limit_effective_current
new_traffic_limit_gb_effective = traffic_limit_gb_effective_current
if has_device_option and include_device_effective and selected_device is not None:
pack_devices_val = int(selected_device)
if pack_devices_val <= 0 or (
new_device_limit_effective is not None and int(new_device_limit_effective) <= 0
):
new_device_limit_effective = 0
else:
if new_device_limit_effective is None:
new_device_limit_effective = pack_devices_val
else:
new_device_limit_effective = int(new_device_limit_effective) + pack_devices_val
if has_traffic_option and include_traffic_effective and selected_traffic is not None:
pack_traffic_val = int(selected_traffic)
if pack_traffic_val <= 0 or int(new_traffic_limit_gb_effective) <= 0:
new_traffic_limit_gb_effective = 0
else:
new_traffic_limit_gb_effective = int(new_traffic_limit_gb_effective) + pack_traffic_val
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(new_traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(new_device_limit_effective) if new_device_limit_effective is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(new_device_limit_effective) if new_device_limit_effective is not None else None,
selected_traffic_gb=int(new_traffic_limit_gb_effective)
if new_traffic_limit_gb_effective is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="pack",
)
else:
selected_device_for_effective = (
int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None
)
selected_traffic_for_effective = (
int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else 0
)
device_limit_effective_new, traffic_limit_bytes_effective_new = await get_effective_limits_for_key(
session=session,
tariff_id=int(tariff_id),
selected_device_limit=selected_device_for_effective,
selected_traffic_gb=selected_traffic_for_effective,
)
traffic_limit_gb_effective = (
int(traffic_limit_bytes_effective_new / GB) if traffic_limit_bytes_effective_new else 0
)
await renew_key_in_cluster(
cluster_id=server_id,
email=email,
client_id=str(getattr(db_key, "client_id", "") or ""),
new_expiry_time=expiry_time,
total_gb=int(traffic_limit_gb_effective),
session=session,
hwid_device_limit=int(device_limit_effective_new) if device_limit_effective_new is not None else 0,
reset_traffic=False,
target_subgroup=target_subgroup,
old_subgroup=current_subgroup,
plan=int(tariff_id),
)
await save_key_config_with_mode(
session=session,
email=email,
selected_devices=int(selected_device)
if has_device_option and include_device_effective and selected_device is not None
else None,
selected_traffic_gb=int(selected_traffic)
if has_traffic_option and include_traffic_effective and selected_traffic is not None
else None,
total_price=int(total_price_after_purchase),
has_device_choice=bool(has_device_option and include_device_effective),
has_traffic_choice=bool(has_traffic_option and include_traffic_effective),
config_mode="addon",
)
await update_balance(session, int(billing_user_id), -int(final_extra_price_rub))
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(billing_user_id))
return AccountKeyApplyAddonsResponse(
ok=True,
message="Доп. опции применены",
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=int(tariff_id),
total_price_rub=int(total_price_after_purchase),
extra_price_rub=int(extra_price_rub),
discount_rub=int(discount_rub),
final_price_rub=int(final_extra_price_rub),
applied_coupon_code=applied_coupon_code,
charged_rub=int(final_extra_price_rub),
balance_rub=float(await get_balance(session, int(billing_user_id))),
)
+299
View File
@@ -0,0 +1,299 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
import time
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("", response_model=list[AccountKeyResponse])
async def user_keys(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
keys = await get_keys(session, billing_user_id)
result: list[AccountKeyResponse] = []
for key in keys:
key_actions = AccountKeyActionsAvailability()
try:
key_ref = str(getattr(key, "client_id", "") or getattr(key, "email", "") or "")
_, markup, _ = await build_key_view_payload(session, int(billing_user_id), key_ref)
key_actions = _extract_key_actions_from_markup(markup)
except Exception:
key_actions = AccountKeyActionsAvailability()
result.append(
AccountKeyResponse(
email=str(getattr(key, "email", "") or ""),
alias=getattr(key, "alias", None),
client_id=str(getattr(key, "client_id", "") or ""),
tariff_id=getattr(key, "tariff_id", None),
server_id=str(getattr(key, "server_id", "") or ""),
created_at=int(getattr(key, "created_at", 0) or 0),
expiry_time=int(getattr(key, "expiry_time", 0) or 0),
key=getattr(key, "key", None),
remnawave_link=getattr(key, "remnawave_link", None),
is_frozen=bool(getattr(key, "is_frozen", False)),
actions=key_actions,
)
)
return result
@user_router.get("/actions-config", response_model=AccountKeyActionsConfigResponse)
async def user_keys_actions_config(
identity=Depends(verify_identity_token),
):
_ = identity
return _key_actions_config()
@user_router.get("/{client_id}/connection", response_model=AccountKeyConnectionResponse)
async def user_key_connection(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Лёгкая инфо о текущей подписке: онлайн/offline, сервер, протокол, дни до окончания."""
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_name = str(getattr(db_key, "server_id", "") or "")
cluster_name = ""
panel_type = ""
if server_name:
srv = (
await session.execute(
select(Server).where(Server.server_name == server_name).limit(1)
)
).scalar_one_or_none()
if srv is not None:
cluster_name = str(getattr(srv, "cluster_name", "") or "")
panel_type = str(getattr(srv, "panel_type", "") or "").lower()
expiry_ms = int(getattr(db_key, "expiry_time", 0) or 0)
is_frozen = bool(getattr(db_key, "is_frozen", False))
now_ms = int(time.time() * 1000)
online = not is_frozen and expiry_ms > now_ms
expires_in_days = max(0, int((expiry_ms - now_ms) / (1000 * 60 * 60 * 24))) if expiry_ms > 0 else 0
if panel_type == "remnawave":
protocol = "VLESS"
elif panel_type == "marzban":
protocol = "VLESS"
elif panel_type == "3xui":
protocol = "VLESS"
else:
protocol = panel_type.upper() or "VLESS"
return AccountKeyConnectionResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
online=online,
is_frozen=is_frozen,
expiry_time=expiry_ms,
expires_in_days=expires_in_days,
server_name=server_name,
cluster_name=cluster_name,
panel_type=panel_type,
protocol=protocol,
)
@user_router.get("/{client_id}/details", response_model=AccountKeyDetailsResponse)
async def user_key_details(
client_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
key_details = await get_key_details(session, str(getattr(db_key, "email", "") or ""))
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
tariff_name = ""
subgroup_title = ""
traffic_limit_gb = 0
device_limit = 0
is_tariff_configurable = False
addons_devices_enabled = False
addons_traffic_enabled = False
(
tariff_name,
subgroup_title,
traffic_limit_gb,
device_limit,
_,
is_tariff_configurable,
addons_devices_enabled,
addons_traffic_enabled,
) = await get_key_tariff_addons_state(
session=session,
key_record=key_details,
db_key=db_key,
)
connected_devices = 0
used_traffic_gb = None
try:
profile = await get_remnawave_profile(
session,
str(getattr(db_key, "server_id", "") or ""),
client_id,
fallback_any=True,
)
if profile:
connected_devices = int(profile.get("hwid_count") or 0)
used_raw = profile.get("used_gb")
used_traffic_gb = float(used_raw) if used_raw is not None else None
traffic_limit_bytes_actual = profile.get("traffic_limit_bytes")
if traffic_limit_bytes_actual is not None:
try:
traffic_limit_bytes_actual = int(traffic_limit_bytes_actual)
traffic_limit_gb = int(traffic_limit_bytes_actual / GB) if traffic_limit_bytes_actual > 0 else 0
except (TypeError, ValueError):
pass
except Exception:
connected_devices = 0
used_traffic_gb = None
return AccountKeyDetailsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
tariff_name=str(tariff_name or ""),
subgroup_title=str(subgroup_title or ""),
traffic_limit_gb=int(traffic_limit_gb or 0),
used_traffic_gb=used_traffic_gb,
device_limit=int(device_limit or 0),
connected_devices=int(connected_devices or 0),
is_tariff_configurable=bool(is_tariff_configurable),
addons_devices_enabled=bool(addons_devices_enabled),
addons_traffic_enabled=bool(addons_traffic_enabled),
)
@user_router.get("/{client_id}/qr", response_model=AccountKeyQrResponse)
async def user_key_qr(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_qr", max_per_window=30, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.qr_enabled:
raise HTTPException(status_code=403, detail="QR для подписок отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
qr_data = str(getattr(db_key, "key", "") or "").strip() or str(getattr(db_key, "remnawave_link", "") or "").strip()
if not qr_data:
raise HTTPException(status_code=400, detail="Ссылка для подключения отсутствует")
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return AccountKeyQrResponse(
ok=True,
message="QR-код готов",
link=qr_data,
image_data_url=f"data:image/png;base64,{image_data}",
)
@user_router.patch("/{client_id}/alias", response_model=AccountKeyResponse)
async def user_key_update_alias(
client_id: str,
body: AccountKeyAliasUpdateRequest,
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_alias", max_per_window=20, window_sec=60)
alias = str(body.alias or "").strip()
if not alias:
raise HTTPException(status_code=400, detail="Укажите alias")
if len(alias) > 10:
raise HTTPException(status_code=400, detail="Alias должен быть не длиннее 10 символов")
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9@._-]+$", alias):
raise HTTPException(status_code=400, detail="Alias содержит недопустимые символы")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
db_key.alias = alias
return AccountKeyResponse(
email=str(getattr(db_key, "email", "") or ""),
alias=getattr(db_key, "alias", None),
client_id=str(getattr(db_key, "client_id", "") or ""),
tariff_id=getattr(db_key, "tariff_id", None),
server_id=str(getattr(db_key, "server_id", "") or ""),
created_at=int(getattr(db_key, "created_at", 0) or 0),
expiry_time=int(getattr(db_key, "expiry_time", 0) or 0),
key=getattr(db_key, "key", None),
remnawave_link=getattr(db_key, "remnawave_link", None),
is_frozen=bool(getattr(db_key, "is_frozen", False)),
)
@user_router.delete("/{client_id}", response_model=AccountKeyActionResponse)
async def user_key_delete(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
await enforce_rate_limit(request, session, bucket="key_delete", max_per_window=10, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.delete_enabled:
raise HTTPException(status_code=403, detail="Удаление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
cluster_id = str(getattr(db_key, "server_id", "") or "")
email = str(getattr(db_key, "email", "") or "")
if cluster_id and email:
await delete_key_from_cluster(
cluster_id=cluster_id,
email=email,
client_id=client_id,
session=session,
)
await session.delete(db_key)
return AccountKeyActionResponse(ok=True, message="Подписка удалена")
+73
View File
@@ -0,0 +1,73 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.post("/{client_id}/reset-hwid", response_model=AccountKeyResetHwidResponse)
async def user_key_reset_hwid(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.hwid_reset_enabled:
raise HTTPException(status_code=403, detail="Сброс устройств отключен в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
server_id = str(getattr(db_key, "server_id", "") or "")
if not server_id:
raise HTTPException(status_code=400, detail="У подписки не указан сервер")
async def _reset_devices(api):
devices = await api.get_user_hwid_devices(client_id)
if not devices:
return 0, 0
reset_local = 0
for device in devices:
hwid = device.get("hwid")
if hwid and await api.delete_user_hwid_device(client_id, hwid):
reset_local += 1
return len(devices), reset_local
reset_result = await with_remnawave_api(
session,
server_id,
_reset_devices,
fallback_any=True,
timeout_sec=12.0,
)
if reset_result is None:
raise HTTPException(status_code=502, detail="Не удалось выполнить сброс устройств")
total_devices, reset_devices = reset_result
await invalidate_remnawave_profile(
session,
server_id,
str(client_id),
fallback_any=True,
)
return AccountKeyResetHwidResponse(
ok=True,
message="Устройства сброшены" if total_devices > 0 else "Устройства не были привязаны",
total_devices=int(total_devices),
reset_devices=int(reset_devices),
)
+225
View File
@@ -0,0 +1,225 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.get("/{client_id}/locations", response_model=AccountKeyLocationsResponse)
async def user_key_locations(
client_id: str,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
names = await _resolve_available_location_servers(session, db_key)
return AccountKeyLocationsResponse(
client_id=str(getattr(db_key, "client_id", "") or ""),
current_server=str(getattr(db_key, "server_id", "") or ""),
locations=[AccountKeyLocationOptionResponse(server_name=name) for name in names],
)
@user_router.post("/{client_id}/change-location", response_model=AccountKeyChangeLocationResponse)
async def user_key_change_location(
client_id: str,
body: AccountKeyChangeLocationRequest,
request: Request,
force_web: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
actions = _key_actions_config()
if not force_web and not actions.country_change_enabled:
raise HTTPException(status_code=403, detail="Смена локации отключена в настройках")
target_server = str(body.server_name or "").strip()
if not target_server:
raise HTTPException(status_code=400, detail="Укажите целевую локацию")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
current_server = str(getattr(db_key, "server_id", "") or "")
if not current_server:
raise HTTPException(status_code=400, detail="У подписки не указан текущий сервер")
if current_server == target_server:
raise HTTPException(status_code=400, detail="Подписка уже в этой локации")
available_names = await _resolve_available_location_servers(session, db_key)
if target_server not in available_names:
raise HTTPException(status_code=400, detail="Выбранная локация недоступна")
email = str(getattr(db_key, "email", "") or "")
if not email:
raise HTTPException(status_code=400, detail="У подписки отсутствует email")
key_details = await get_key_details(session, email)
if not key_details:
raise HTTPException(status_code=404, detail="Подписка не найдена")
old_server_info = (
await session.execute(select(Server).where(Server.server_name == current_server).limit(1))
).scalar_one_or_none()
if old_server_info:
old_panel_type = str(getattr(old_server_info, "panel_type", "") or "").lower()
try:
if old_panel_type == "3x-ui":
xui = await get_xui_instance(str(getattr(old_server_info, "api_url", "") or ""))
await delete_client(
xui,
int(getattr(old_server_info, "inbound_id", 0) or 0),
email,
str(getattr(db_key, "client_id", "") or ""),
)
elif old_panel_type == "remnawave":
remna_del = RemnawaveAPI(str(getattr(old_server_info, "api_url", "") or ""))
if await remna_del.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
await remna_del.delete_user(str(getattr(db_key, "client_id", "") or ""))
except Exception:
pass
target_server_info = (
await session.execute(select(Server).where(Server.server_name == target_server).limit(1))
).scalar_one_or_none()
if target_server_info is None:
raise HTTPException(status_code=404, detail="Целевая локация не найдена")
tariff_id = getattr(db_key, "tariff_id", None)
tariff = await get_tariff_by_id(session, int(tariff_id)) if tariff_id else None
need_vless_key = bool(tariff.get("vless")) if tariff else False
external_squad_uuid = (tariff.get("external_squad") if tariff else None) or None
selected_traffic_gb = getattr(db_key, "selected_traffic_limit", None)
selected_device_limit = getattr(db_key, "selected_device_limit", None)
if selected_traffic_gb is not None:
traffic_limit_bytes = int(selected_traffic_gb) * GB
else:
raw_traffic_limit = int(tariff.get("traffic_limit") or 0) if tariff else 0
traffic_limit_bytes = raw_traffic_limit * GB if raw_traffic_limit > 0 else 0
if selected_device_limit is not None:
device_limit = int(selected_device_limit)
else:
device_limit = int(tariff.get("device_limit") or 0) if tariff else 0
key_client_id = str(getattr(db_key, "client_id", "") or "")
expiry_timestamp = int(getattr(db_key, "expiry_time", 0) or 0)
target_cluster_info = await check_server_name_by_cluster(session, target_server)
target_cluster_name = str((target_cluster_info or {}).get("cluster_name") or "")
full_remnawave_cluster = (
await is_full_remnawave_cluster(target_cluster_name, session) if target_cluster_name else False
)
panel_type = str(getattr(target_server_info, "panel_type", "") or "").lower()
remnawave_link = None
if panel_type == "remnawave" or full_remnawave_cluster:
remna = RemnawaveAPI(str(getattr(target_server_info, "api_url", "") or ""))
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise HTTPException(status_code=502, detail="Не удалось авторизоваться в Remnawave")
expire_at = datetime.utcfromtimestamp(expiry_timestamp / 1000).isoformat() + "Z"
user_data: dict[str, Any] = {
"username": email,
"trafficLimitStrategy": "NO_RESET",
"expireAt": expire_at,
"telegramId": int(key_details.get("tg_id") or 0),
"activeInternalSquads": [getattr(target_server_info, "inbound_id", None)],
"uuid": key_client_id,
}
if traffic_limit_bytes > 0:
user_data["trafficLimitBytes"] = traffic_limit_bytes
if device_limit > 0:
user_data["hwidDeviceLimit"] = device_limit
if external_squad_uuid:
user_data["externalSquadUuid"] = external_squad_uuid
result = await remna.create_user(user_data)
if not result:
raise HTTPException(status_code=502, detail="Не удалось создать подписку в новой локации")
key_client_id = str(result.get("uuid") or result.get("id") or key_client_id)
if need_vless_key:
try:
remnawave_link = await get_vless_link_for_remnawave_by_username(remna, email, email)
except Exception:
remnawave_link = None
if not remnawave_link:
try:
sub = await remna.get_subscription_by_username(email)
except Exception:
sub = None
if sub:
links = sub.get("links") or []
remnawave_link = (
next(
(link for link in links if isinstance(link, str) and link.lower().startswith("vless://")),
None,
)
if need_vless_key
else None
)
if not remnawave_link:
remnawave_link = sub.get("subscriptionUrl")
if panel_type == "3x-ui":
await create_client_on_server(
{
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
},
int(key_details.get("tg_id") or 0),
key_client_id,
email,
expiry_timestamp,
asyncio.Semaphore(1),
plan=int(tariff_id) if tariff_id else None,
session=session,
is_trial=False,
total_traffic_limit_bytes=traffic_limit_bytes,
device_limit_value=device_limit,
)
subgroup_code = tariff.get("subgroup_title") if tariff and tariff.get("subgroup_title") else None
public_link = await make_aggregated_link(
session=session,
cluster_all=[
{
"server_name": str(getattr(target_server_info, "server_name", "") or ""),
"api_url": str(getattr(target_server_info, "api_url", "") or ""),
"panel_type": str(getattr(target_server_info, "panel_type", "") or ""),
"inbound_id": getattr(target_server_info, "inbound_id", None),
"enabled": True,
"max_keys": getattr(target_server_info, "max_keys", None),
}
],
cluster_id=target_cluster_name or target_server,
email=email,
client_id=key_client_id,
tg_id=int(key_details.get("tg_id") or 0),
subgroup_code=subgroup_code,
remna_link_override=remnawave_link,
plan=int(tariff_id) if tariff_id else None,
)
db_key.server_id = target_server
db_key.client_id = key_client_id
db_key.key = public_link if isinstance(public_link, str) and public_link.strip() else None
db_key.remnawave_link = remnawave_link
return AccountKeyChangeLocationResponse(
ok=True,
message="Локация успешно изменена",
client_id=str(getattr(db_key, "client_id", "") or ""),
server_id=str(getattr(db_key, "server_id", "") or ""),
link=str(getattr(db_key, "key", "") or ""),
remnawave_link=getattr(db_key, "remnawave_link", None),
)
+192
View File
@@ -0,0 +1,192 @@
"""User-facing key endpoints (/api/keys/*).
Регистрирует эндпоинты на ``user_router`` из ``_common``. Импорт этого модуля
из ``__init__.py`` запускает регистрацию декораторов.
"""
from .._common import * # noqa: F401,F403 — подтягиваем все имена для endpoints
from .._common import (
_key_actions_config,
_normalize_expiry_ms,
_resolve_available_location_servers,
_resolve_billing_user_id,
_resolve_default_web_payment_provider,
_resolve_public_base_url,
router,
user_router,
)
@user_router.post("/{client_id}/renew", response_model=AccountKeyRenewResponse)
async def user_key_renew(
client_id: str,
body: AccountKeyRenewRequest,
request: Request,
force_web: bool = Query(False),
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
from api.ratelimit import enforce_rate_limit
from services.errors import ServiceError
from services.keys import (
calculate_renewal_pricing,
execute_renewal,
normalize_expiry_ms as _svc_normalize_expiry,
)
if not preview:
await enforce_rate_limit(request, session, bucket="key_renew", max_per_window=10, window_sec=60)
actions = _key_actions_config()
if not force_web and not actions.renew_enabled:
raise HTTPException(status_code=403, detail="Продление подписки отключено в настройках")
billing_user_id = await _resolve_billing_user_id(request, identity, session)
db_key = (
await session.execute(select(Key).where(Key.user_id == billing_user_id, Key.client_id == client_id).limit(1))
).scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="Подписка не найдена")
if bool(getattr(db_key, "is_frozen", False)):
raise HTTPException(status_code=400, detail="Продление для замороженной подписки недоступно")
tariff_id = getattr(db_key, "tariff_id", None)
if not tariff_id:
raise HTTPException(status_code=400, detail="Для подписки не назначен тариф")
key_email = str(getattr(db_key, "email", "") or "")
key_server_id = str(getattr(db_key, "server_id", "") or "")
try:
pricing = await calculate_renewal_pricing(
session=session,
billing_user_id=int(billing_user_id),
key_email=key_email,
tariff_id=int(tariff_id),
coupon_code=body.coupon_code,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
if preview:
return AccountKeyRenewResponse(
ok=True,
message="Расчет обновлен",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=pricing.payment_required,
required_amount_rub=pricing.required_amount,
payment_id=None,
payment_url=None,
)
if pricing.payment_required:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(billing_user_id),
amount=pricing.required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "key_renewal",
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(billing_user_id),
"waiting_for_renewal_payment",
{
"tariff_id": int(tariff_id),
"client_id": str(client_id),
"email": key_email,
"cost": pricing.final_price_rub,
"required_amount": pricing.required_amount,
"selected_duration_days": pricing.duration_days,
"selected_device_limit": pricing.selected_device_limit,
"selected_traffic_limit": pricing.selected_traffic_limit,
"selected_price_rub": pricing.final_price_rub,
"total_gb": pricing.total_gb,
"base_price_rub": pricing.base_price_rub,
"discount_rub": pricing.discount_rub,
"applied_coupon_code": pricing.applied_coupon_code,
"coupon_id": pricing.coupon_id,
},
)
return AccountKeyRenewResponse(
ok=True,
message="Требуется оплата для продления подписки",
client_id=str(client_id),
tariff_id=int(tariff_id),
charged_rub=0,
balance_rub=pricing.balance,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
payment_required=True,
required_amount_rub=pricing.required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
expiry_raw = _normalize_expiry_ms(getattr(db_key, "expiry_time", None))
now_ms = int(datetime.utcnow().timestamp() * 1000)
base_expiry = now_ms if expiry_raw <= now_ms else expiry_raw
new_expiry_time = int(base_expiry + pricing.duration_days * 24 * 60 * 60 * 1000)
if not key_email or not key_server_id:
raise HTTPException(status_code=400, detail="Некорректные данные подписки")
try:
result = await execute_renewal(
session=session,
billing_user_id=int(billing_user_id),
client_id=str(client_id),
key_email=key_email,
key_server_id=key_server_id,
tariff_id=int(tariff_id),
new_expiry_time=new_expiry_time,
total_gb=pricing.total_gb,
cost=float(pricing.final_price_rub),
selected_device_limit=pricing.selected_device_limit,
selected_traffic_limit=pricing.selected_traffic_limit,
selected_price_rub=pricing.final_price_rub,
coupon_id=pricing.coupon_id,
)
except ServiceError as e:
raise HTTPException(status_code=400, detail=e.message)
return AccountKeyRenewResponse(
ok=True,
message="Подписка продлена",
client_id=result.client_id,
tariff_id=result.tariff_id,
charged_rub=result.charged_rub,
balance_rub=result.balance_rub,
base_price_rub=pricing.base_price_rub,
discount_rub=pricing.discount_rub,
final_price_rub=pricing.final_price_rub,
applied_coupon_code=pricing.applied_coupon_code,
)
+514
View File
@@ -0,0 +1,514 @@
import asyncio
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from typing import Literal
import psutil
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import distinct, exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin, verify_identity_admin_short
from api.v2.schemas.audit import (
AuditEventListResponse,
AuditEventResponse,
AuditStatsResponse,
)
from audit import drain_audit_redis_to_db, get_audit_funnel, get_audit_stats, list_audit_events
from config import API_TOKEN, BOT_SERVICE
from core.bootstrap import MANAGEMENT_CONFIG
from core.executor import run_io
from core.redis_cache import cache_incr
from core.settings.management_config import update_management_config
from database import async_session_maker
from database.models import Key, ScheduledBroadcast, Server, User
from database.scheduled_broadcasts import (
cancel_scheduled_broadcast,
create_scheduled_broadcast,
get_scheduled_broadcast,
list_scheduled_broadcasts,
mark_scheduled_broadcast_failed,
mark_scheduled_broadcast_sent,
start_scheduled_broadcast,
update_scheduled_broadcast,
)
from handlers.admin.sender.scheduled_service import (
ensure_utc_datetime,
execute_broadcast_payload,
execute_scheduled_broadcast,
prepare_broadcast_payload,
scheduled_broadcast_to_dict,
)
from logger import logger
from utils.backup import backup_database
router = APIRouter()
async def _admin_rate_limit(request_or_identity, action: str, max_calls: int, window_sec: int) -> None:
identity_id = getattr(request_or_identity, "id", "unknown")
key = f"admin_rl:{action}:{identity_id}"
count = await cache_incr(key, window_sec)
if count > max_calls:
raise HTTPException(status_code=429, detail="Слишком много запросов. Попробуйте позже.")
class MaintenanceUpdate(BaseModel):
enabled: bool
class DomainChange(BaseModel):
domain: str
class BroadcastLaunchPayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] = "all"
channel: Literal["bot", "site", "both"] = "both"
text: str
photo: str | None = None
cluster_name: str | None = None
workers: int = 5
messages_per_second: int = 35
class ScheduledBroadcastCreatePayload(BroadcastLaunchPayload):
scheduled_for: datetime
class ScheduledBroadcastUpdatePayload(BaseModel):
send_to: Literal["all", "subscribed", "unsubscribed", "untrial", "trial", "hotleads", "cluster"] | None = None
channel: Literal["bot", "site", "both"] | None = None
text: str | None = None
photo: str | None = None
cluster_name: str | None = None
workers: int | None = None
messages_per_second: int | None = None
scheduled_for: datetime | None = None
_broadcast_bot: Bot | None = None
def _get_broadcast_bot() -> Bot:
"""Возвращает экземпляр бота для рассылки."""
global _broadcast_bot
if _broadcast_bot is None:
_broadcast_bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
return _broadcast_bot
def _require_future_schedule(value: datetime) -> datetime:
scheduled_for = ensure_utc_datetime(value)
if scheduled_for <= datetime.now(timezone.utc):
raise HTTPException(status_code=400, detail="scheduled_for must be in the future")
return scheduled_for
def _resolve_update_payload(
payload: ScheduledBroadcastUpdatePayload,
current: ScheduledBroadcast,
) -> dict:
fields = payload.model_fields_set
text_changed = "text" in fields
send_to = payload.send_to if "send_to" in fields else current.send_to
channel = payload.channel if "channel" in fields else current.channel
text = payload.text if "text" in fields else current.text
photo = payload.photo if "photo" in fields else current.photo
cluster_name = payload.cluster_name if "cluster_name" in fields else current.cluster_name
workers = payload.workers if "workers" in fields else current.workers
messages_per_second = (
payload.messages_per_second if "messages_per_second" in fields else current.messages_per_second
)
prepared = prepare_broadcast_payload(
send_to=send_to,
text=text,
photo=photo,
cluster_name=cluster_name,
workers=workers,
messages_per_second=messages_per_second,
channel=channel,
)
if not text_changed:
prepared["text"] = current.text
prepared["keyboard_json"] = current.keyboard_json
if "scheduled_for" in fields:
prepared["scheduled_for"] = _require_future_schedule(payload.scheduled_for)
return prepared
async def _restart_bot() -> None:
"""Перезапуск процесса бота (systemctl или execv)."""
await asyncio.sleep(1)
try:
parent = psutil.Process(os.getpid()).parent()
is_systemd = parent and "systemd" in parent.name().lower()
if is_systemd:
await run_io(lambda: 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:
os._exit(1)
@router.get("/status")
async def get_status(identity=Depends(verify_identity_admin)):
"""Текущий статус: maintenance и management config."""
return {
"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False)),
"management": dict(MANAGEMENT_CONFIG or {}),
}
@router.post("/maintenance")
async def set_maintenance(
payload: MaintenanceUpdate,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Включение/выключение режима обслуживания."""
current_config = dict(MANAGEMENT_CONFIG or {})
current_config["MAINTENANCE_ENABLED"] = bool(payload.enabled)
await update_management_config(session, current_config)
return {"maintenance_enabled": bool(MANAGEMENT_CONFIG.get("MAINTENANCE_ENABLED", False))}
@router.post("/restart")
async def restart_bot(
background: BackgroundTasks,
identity=Depends(verify_identity_admin),
):
"""Запуск перезапуска бота в фоне."""
await _admin_rate_limit(identity, "restart", max_calls=3, window_sec=60)
background.add_task(_restart_bot)
return {"status": "restarting"}
@router.post("/change-domain")
async def change_domain(
payload: DomainChange,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Массовая замена домена в ключах и remnawave_link."""
await _admin_rate_limit(identity, "change_domain", max_calls=3, window_sec=300)
domain = payload.domain.strip()
if not domain or " " in domain or not re.fullmatch(r"[a-zA-Z0-9.-]+", domain):
raise HTTPException(status_code=400, detail="Invalid domain")
new_domain_url = f"https://{domain}"
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))
)
)
result = await session.execute(stmt)
return {"updated": result.rowcount or 0}
@router.post("/restore-trials")
async def restore_trials(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Сбрасывает trial=0 у пользователей без ключей."""
await _admin_rate_limit(identity, "restore_trials", max_calls=3, window_sec=300)
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
return {"restored": result.rowcount or 0}
@router.post("/backup")
async def trigger_backup(identity=Depends(verify_identity_admin)):
"""Запуск бэкапа БД в фоне."""
await _admin_rate_limit(identity, "backup", max_calls=2, window_sec=300)
async def _run_backup() -> None:
exception = await backup_database()
if exception:
logger.error(f"[Management] Backup finished with error: {exception}")
asyncio.create_task(_run_backup())
return {"status": "backup_started"}
@router.get("/broadcast/clusters")
async def get_broadcast_clusters(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список кластеров для рассылки по кластеру."""
result = await session.execute(select(distinct(Server.cluster_name)).where(Server.cluster_name.is_not(None)))
clusters = sorted([row[0] for row in result.all() if row and row[0]])
return {"clusters": clusters}
def _parse_date_range(
date: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
) -> tuple[datetime, datetime]:
"""Возвращает (date_from, date_to) в UTC. Либо date=YYYY-MM-DD (один день), либо date_from + date_to."""
tz = timezone.utc
if date:
try:
d = datetime.strptime(date, "%Y-%m-%d").date()
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=tz)
end = start + timedelta(days=1)
return start, end
except ValueError:
raise HTTPException(status_code=400, detail="date должен быть YYYY-MM-DD")
if date_from and date_to:
try:
start = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
end = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
if start.tzinfo is None:
start = start.replace(tzinfo=tz)
if end.tzinfo is None:
end = end.replace(tzinfo=tz)
if start >= end:
raise HTTPException(status_code=400, detail="date_from должен быть раньше date_to")
return start, end
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Неверный формат дат: {e}")
end = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
start = end - timedelta(days=1)
return start, end
@router.get("/audit-stats", response_model=AuditStatsResponse)
async def get_audit_stats_endpoint(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
date: str | None = Query(None, description="Один день: YYYY-MM-DD"),
date_from: str | None = Query(None, description="Начало периода (ISO)"),
date_to: str | None = Query(None, description="Конец периода (ISO)"),
):
"""Статистика аудита за период: какие пути отрабатывают хорошо/плохо, воронка старт→оплата.
Данные только из БД (события из Redis учитываются после drain)."""
start, end = _parse_date_range(date=date, date_from=date_from, date_to=date_to)
stats = await get_audit_stats(session, date_from=start, date_to=end)
funnel = await get_audit_funnel(session, date_from=start, date_to=end)
return AuditStatsResponse(
summary=stats["summary"],
by_path=stats["by_path"],
funnel=funnel,
)
@router.get("/audit-events", response_model=AuditEventListResponse)
async def get_audit_events_history(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
identity_id: str | None = Query(None, description="Фильтр по identity_id"),
tg_id: int | None = Query(None, description="Фильтр по Telegram user id"),
channel: str | None = Query(None, description="api или telegram"),
event_type: str | None = Query(None, description="Точный event_type"),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
"""История аудита клиента по identity_id и/или tg_id."""
if identity_id is None and tg_id is None:
raise HTTPException(status_code=400, detail="Укажите identity_id или tg_id")
events = await list_audit_events(
session,
identity_id=identity_id,
tg_id=tg_id,
channel=channel,
event_type=event_type,
limit=limit,
offset=offset,
)
return AuditEventListResponse(
items=[
AuditEventResponse(
id=getattr(event, "id", None),
event_type=event.event_type,
channel=event.channel,
actor_identity_id=event.actor_identity_id,
actor_tg_id=event.actor_tg_id,
path_or_handler=event.path_or_handler,
entity_type=event.entity_type,
entity_id=event.entity_id,
result=event.result,
reason=event.reason,
metadata=event.metadata_,
request_id=event.request_id,
created_at=event.created_at,
)
for event in events
],
limit=limit,
offset=offset,
)
@router.post("/audit-drain")
async def post_audit_drain(identity=Depends(verify_identity_admin_short)):
"""Выгружает буфер аудита из Redis в БД. Для вызова по крону (например 0 0 * * * в 00:00)."""
try:
count = await drain_audit_redis_to_db(async_session_maker)
return {"success": True, "drained": count}
except Exception as exc:
logger.warning("audit-drain failed: {}", exc)
raise HTTPException(status_code=500, detail="Внутренняя ошибка при дренаже аудита") from exc
@router.post("/broadcast")
async def launch_broadcast(
payload: BroadcastLaunchPayload,
identity=Depends(verify_identity_admin_short),
):
"""Запуск рассылки по выбранной аудитории. Сессия БД не держится на время рассылки."""
await _admin_rate_limit(identity, "broadcast", max_calls=5, window_sec=300)
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return await execute_broadcast_payload(prepared, bot=_get_broadcast_bot())
@router.post("/broadcast/scheduled")
async def create_broadcast_schedule(
payload: ScheduledBroadcastCreatePayload,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
try:
prepared = prepare_broadcast_payload(
send_to=payload.send_to,
text=payload.text,
photo=payload.photo,
cluster_name=payload.cluster_name,
workers=payload.workers,
messages_per_second=payload.messages_per_second,
channel=payload.channel,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
broadcast = await create_scheduled_broadcast(
session,
created_by_tg_id=getattr(identity, "tg_id", None),
send_to=prepared["send_to"],
channel=prepared["channel"],
cluster_name=prepared["cluster_name"],
text=prepared["text"],
photo=prepared["photo"],
keyboard_json=prepared["keyboard_json"],
scheduled_for=_require_future_schedule(payload.scheduled_for),
workers=prepared["workers"],
messages_per_second=prepared["messages_per_second"],
)
return {"success": True, "item": scheduled_broadcast_to_dict(broadcast)}
@router.get("/broadcast/scheduled")
async def list_broadcast_schedules(
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
status: str | None = Query(None, description="Фильтр статусов через запятую"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
statuses = [item.strip() for item in (status or "").split(",") if item.strip()] or None
items = await list_scheduled_broadcasts(session, statuses=statuses, limit=limit, offset=offset)
return {"items": [scheduled_broadcast_to_dict(item) for item in items], "limit": limit, "offset": offset}
@router.get("/broadcast/scheduled/{broadcast_id}")
async def get_broadcast_schedule(
broadcast_id: str,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
item = await get_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
return {"item": scheduled_broadcast_to_dict(item)}
@router.patch("/broadcast/scheduled/{broadcast_id}")
async def update_broadcast_schedule(
broadcast_id: str,
payload: ScheduledBroadcastUpdatePayload,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
current = await get_scheduled_broadcast(session, broadcast_id)
if current is None:
raise HTTPException(status_code=404, detail="Scheduled broadcast not found")
try:
values = _resolve_update_payload(payload, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updated = await update_scheduled_broadcast(session, broadcast_id, **values)
if updated is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be edited")
return {"success": True, "item": scheduled_broadcast_to_dict(updated)}
@router.post("/broadcast/scheduled/{broadcast_id}/cancel")
async def cancel_broadcast_schedule(
broadcast_id: str,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
item = await cancel_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be cancelled")
return {"success": True, "item": scheduled_broadcast_to_dict(item)}
@router.post("/broadcast/scheduled/{broadcast_id}/send-now")
async def send_broadcast_schedule_now(
broadcast_id: str,
identity=Depends(verify_identity_admin_short),
session: AsyncSession = Depends(get_session),
):
await _admin_rate_limit(identity, "broadcast_now", max_calls=5, window_sec=300)
item = await start_scheduled_broadcast(session, broadcast_id)
if item is None:
raise HTTPException(status_code=409, detail="Scheduled broadcast can no longer be sent now")
try:
result = await execute_scheduled_broadcast(item, bot=_get_broadcast_bot())
except Exception as exc:
logger.error("[Broadcast] send-now failed for {}: {}", broadcast_id, exc)
await mark_scheduled_broadcast_failed(session, broadcast_id, str(exc))
raise HTTPException(status_code=500, detail="Ошибка при выполнении рассылки") from exc
if result.get("success"):
item = await mark_scheduled_broadcast_sent(session, broadcast_id, result)
else:
item = await mark_scheduled_broadcast_failed(session, broadcast_id, result.get("message", "Broadcast failed"))
return {"success": bool(result.get("success")), "item": scheduled_broadcast_to_dict(item), "result": result}
+165
View File
@@ -0,0 +1,165 @@
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import (
BlockedUserResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
TemporaryDataResponse,
TrackingSourceResponse,
)
from database import get_tracking_source_stats
from database.access.resolution import resolve_user_optional
from database.models import (
BlockedUser,
ManualBan,
Notification,
Payment,
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_identity_admin)],
)
@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(...),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Список платежей по tg_id пользователя."""
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Payments not found")
result = await session.execute(select(Payment).where(Payment.user_id == u.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=Notification,
schema_response=NotificationResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/notifications",
tags=["Notifications"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=ManualBan,
schema_response=ManualBanResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/manual-bans",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=BlockedUser,
schema_response=BlockedUserResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/blocked-users",
tags=["Bans"],
dependencies=[Depends(verify_identity_admin)],
)
router.include_router(
generate_crud_router(
model=TemporaryData,
schema_response=TemporaryDataResponse,
schema_create=None,
schema_update=None,
identifier_field="user_id",
parameter_name="tg_id",
telegram_path_to_user_id=True,
enabled_methods=["get_all", "get_one", "delete"],
),
prefix="/temporary-data",
tags=["TemporaryData"],
dependencies=[Depends(verify_identity_admin)],
)
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_identity_admin)],
)
@router.get(
"/tracking-sources/{code}", response_model=TrackingSourceResponse, dependencies=[Depends(verify_identity_admin)]
)
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 []),
)
+125
View File
@@ -0,0 +1,125 @@
import pkgutil
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from api.depends import verify_identity_admin
from core.executor import run_io
from logger import logger
from utils.modules_loader import _is_safe_module_name
from utils.modules_manager import manager
router = APIRouter(prefix="/modules", tags=["Modules"])
MODULES_DIR = Path(__file__).resolve().parents[3] / "modules"
class ModuleAction(BaseModel):
action: Literal["start", "stop", "restart"]
def _available_module_names() -> list[str]:
"""Имена модулей из папки modules, прошедшие проверку безопасности."""
candidates: set[str] = set()
if MODULES_DIR.is_dir():
for _finder, name, _ispkg in pkgutil.iter_modules([str(MODULES_DIR)]):
name = (name or "").strip()
if name and _is_safe_module_name(name):
candidates.add(name)
return sorted(n for n in candidates if _is_safe_module_name(n))
def _prune_missing_state(installed: set[str]) -> None:
"""Удаляет из состояния менеджера модули, которых нет в файловой системе."""
changed = False
stale_disabled = {name for name in manager.disabled if name not in installed}
if stale_disabled:
for name in stale_disabled:
manager.disabled.discard(name)
changed = True
stale_registry = [name for name in list(manager.registry.keys()) if name not in installed]
if stale_registry:
for name in stale_registry:
manager.registry.pop(name, None)
changed = True
if changed:
save_state = getattr(manager, "_save_state", None)
if callable(save_state):
save_state()
def _module_state(name: str) -> dict:
"""Состояние модуля: enabled, loaded, autostart."""
normalized = name.strip()
record = manager.registry.get(normalized)
is_enabled = manager.is_enabled(normalized)
return {
"name": normalized,
"enabled": is_enabled,
"loaded": bool(record and record.enabled),
"autostart": manager.should_autostart(normalized),
}
def _read_local_module_version(name: str) -> str | None:
"""Читает VERSION из папки модуля."""
version_file = MODULES_DIR / name / "VERSION"
if not version_file.exists() or not version_file.is_file():
return None
try:
with version_file.open("r", encoding="utf-8") as handle:
for line in handle:
value = line.strip()
if value:
return value
except Exception:
return None
return None
def sync_list_modules() -> list:
"""Вся синхронная работа со списком модулей (файлы, состояние). Вызывать через run_io()."""
refresh = getattr(manager, "refresh_state", None) or getattr(manager, "_load_state", None)
if callable(refresh):
refresh()
module_names = _available_module_names()
_prune_missing_state(set(module_names))
modules = [_module_state(name) for name in module_names]
for item in modules:
name = str(item.get("name") or "").strip()
item["local_version"] = _read_local_module_version(name)
return modules
@router.get("/")
async def list_modules(identity=Depends(verify_identity_admin)):
"""Список модулей с состоянием и локальной версией."""
modules = await run_io(sync_list_modules)
return {"items": modules}
@router.post("/{module_name}/actions")
async def control_module(module_name: str, payload: ModuleAction, identity=Depends(verify_identity_admin)):
"""Запуск, остановка или перезапуск модуля."""
name = (module_name or "").strip()
if not _is_safe_module_name(name):
raise HTTPException(status_code=404, detail="Module not found")
try:
if payload.action == "start":
await manager.start(name)
elif payload.action == "stop":
await manager.stop(name)
elif payload.action == "restart":
await manager.restart(name)
else:
raise HTTPException(status_code=400, detail="Unsupported action")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
logger.error("[Modules] action failed for {}: {}", name, exc)
raise HTTPException(status_code=500, detail="Ошибка при выполнении операции модуля") from exc
return {"item": _module_state(name)}
+112
View File
@@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from database import web_notifications as wn_db
from database.models import Identity
router = APIRouter()
class PushSubscribeRequest(BaseModel):
endpoint: str
keys: dict
class NotificationItem(BaseModel):
id: str
type: str
title: str
message: str
read: bool
created_at: str
data: dict | None = None
class NotificationsResponse(BaseModel):
ok: bool = True
notifications: list[NotificationItem]
unread_count: int
@router.post("/push/subscribe", tags=["Notifications"])
async def push_subscribe(
body: PushSubscribeRequest,
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
user_id = identity.tg_id or 0
await wn_db.upsert_push_subscription(
session,
user_id=user_id,
identity_id=identity.id,
endpoint=body.endpoint,
keys_json=body.keys,
)
return {"ok": True}
@router.get("/notifications", response_model=NotificationsResponse, tags=["Notifications"])
async def get_notifications(
limit: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
notifications = await wn_db.get_notifications_for_identity(
session,
identity.id,
limit=limit,
)
unread_count = await wn_db.count_unread_for_identity(session, identity.id)
items = [
NotificationItem(
id=n.id,
type=n.type,
title=n.title,
message=n.message,
read=n.read,
created_at=n.created_at.isoformat() if n.created_at else "",
data=n.data,
)
for n in notifications
]
return NotificationsResponse(notifications=items, unread_count=unread_count)
@router.post("/notifications/read-all", tags=["Notifications"])
async def read_all_notifications(
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
count = await wn_db.mark_all_read_for_identity(session, identity.id)
return {"ok": True, "updated": count}
@router.post("/notifications/{notification_id}/read", tags=["Notifications"])
async def read_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Пометить одно уведомление прочитанным. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.mark_one_read_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
@router.delete("/notifications/{notification_id}", tags=["Notifications"])
async def delete_one_notification(
notification_id: str = Path(..., min_length=1, max_length=64),
session: AsyncSession = Depends(get_session),
identity: Identity = Depends(verify_identity_token),
):
"""Удалить одно уведомление. 404 если не найдено или не принадлежит юзеру."""
ok = await wn_db.delete_one_for_identity(session, identity.id, notification_id)
if not ok:
raise HTTPException(status_code=404, detail="Уведомление не найдено")
return {"ok": True}
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.payment_links import PaymentLinkCreateRequest, PaymentLinkCreateResponse, PaymentLinkStatusResponse
from config import REDIS_URL
from database import (
async_session_maker,
get_payment_by_payment_id,
get_payment_from_db_by_payment_id,
identities as idb,
)
from database.temporary_data import create_temporary_data
from logger import logger
from services.payments.payment_events import payment_events_channel
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
router = APIRouter(tags=["PaymentLinks"])
async def _store_payment_intent(
session: AsyncSession,
billing_user_ref: int,
metadata: dict | None,
amount: int | float,
) -> None:
if not isinstance(metadata, dict):
return
payment_flow = str(metadata.get("payment_flow") or "").strip().lower()
required_amount = int(round(float(amount)))
if payment_flow == "tariff_purchase":
tariff_id = metadata.get("tariff_id")
if tariff_id in (None, ""):
return
payload: dict[str, int | str] = {
"tariff_id": int(tariff_id),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or required_amount),
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_limit_gb"] = int(selected_traffic_gb)
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_payment", payload)
return
if payment_flow == "key_renewal":
required_fields = ("tariff_id", "client_id", "email", "cost")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"client_id": str(metadata["client_id"]),
"email": str(metadata["email"]),
"cost": int(metadata["cost"]),
"required_amount": required_amount,
"selected_price_rub": int(metadata.get("selected_price_rub") or metadata["cost"]),
}
selected_duration_days = metadata.get("selected_duration_days")
if selected_duration_days not in (None, ""):
payload["selected_duration_days"] = int(selected_duration_days)
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_limit = metadata.get("selected_traffic_limit")
if selected_traffic_limit not in (None, ""):
payload["selected_traffic_limit"] = int(selected_traffic_limit)
total_gb = metadata.get("total_gb")
if total_gb not in (None, ""):
payload["total_gb"] = int(total_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_renewal_payment", payload)
return
if payment_flow == "key_addons":
required_fields = ("tariff_id", "email", "original_price")
if any(metadata.get(field) in (None, "") for field in required_fields):
return
payload: dict[str, int | str] = {
"tariff_id": int(metadata["tariff_id"]),
"email": str(metadata["email"]),
"original_price": int(metadata["original_price"]),
"required_amount": required_amount,
}
selected_device_limit = metadata.get("selected_device_limit")
if selected_device_limit not in (None, ""):
payload["selected_device_limit"] = int(selected_device_limit)
selected_traffic_gb = metadata.get("selected_traffic_gb")
if selected_traffic_gb not in (None, ""):
payload["selected_traffic_gb"] = int(selected_traffic_gb)
current_device_limit = metadata.get("current_device_limit")
if current_device_limit not in (None, ""):
payload["current_device_limit"] = int(current_device_limit)
current_traffic_gb = metadata.get("current_traffic_gb")
if current_traffic_gb not in (None, ""):
payload["current_traffic_gb"] = int(current_traffic_gb)
coupon_id = metadata.get("coupon_id")
if coupon_id not in (None, ""):
payload["coupon_id"] = int(coupon_id)
discount_rub = metadata.get("discount_rub")
if discount_rub not in (None, ""):
payload["discount_rub"] = int(discount_rub)
base_price_rub = metadata.get("base_price_rub")
if base_price_rub not in (None, ""):
payload["base_price_rub"] = int(base_price_rub)
applied_coupon_code = metadata.get("applied_coupon_code")
if applied_coupon_code not in (None, ""):
payload["applied_coupon_code"] = str(applied_coupon_code)
await create_temporary_data(session, billing_user_ref, "waiting_for_addons_payment", payload)
@router.post("/", response_model=PaymentLinkCreateResponse)
async def create_link(
body: PaymentLinkCreateRequest,
http_request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Создаёт платёжную ссылку для текущего авторизованного пользователя."""
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment_request = PaymentLinkRequest(
legacy_user_ref=billing_user_ref,
amount=body.amount,
currency=body.currency or "RUB",
provider_id=body.provider_id,
success_url=body.success_url,
failure_url=body.failure_url,
metadata=body.metadata,
)
result = await create_payment_link(session, payment_request)
if result.success:
await _store_payment_intent(
session=session,
billing_user_ref=billing_user_ref,
metadata=body.metadata,
amount=body.amount,
)
return PaymentLinkCreateResponse(
success=result.success,
payment_id=result.payment_id,
payment_url=result.payment_url,
error=result.error,
)
@router.get("/stream")
async def payment_events_stream(
request: Request,
x_identity_id: str = "",
token: str = "",
):
identity_id = str(request.headers.get("X-Identity-Id") or x_identity_id or "").strip()
token = str(request.headers.get("X-Token") or token or "").strip()
if not identity_id or not token:
raise HTTPException(status_code=401, detail="Unauthorized")
async with async_session_maker() as session:
identity = await idb.verify_identity_token(session, identity_id, token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
await session.commit()
async def event_generator():
redis_client = None
pubsub = None
channel = payment_events_channel(int(billing_user_ref))
try:
from redis.asyncio import from_url
redis_client = from_url(REDIS_URL, encoding="utf-8", decode_responses=True, max_connections=8)
pubsub = redis_client.pubsub(ignore_subscribe_messages=True)
await pubsub.subscribe(channel)
logger.info(f"[Payments] SSE subscribed: user_ref={billing_user_ref}, channel={channel}")
yield "retry: 1500\n\n"
while True:
if await request.is_disconnected():
logger.info(f"[Payments] SSE disconnected by client: user_ref={billing_user_ref}")
break
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0)
if message and message.get("type") == "message":
raw_data = message.get("data")
payload = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
if isinstance(payload, dict):
logger.info(
f"[Payments] SSE emit: user_ref={billing_user_ref}, "
f"status={payload.get('status')}, flow={payload.get('flow')}"
)
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
continue
yield ": keepalive\n\n"
await asyncio.sleep(0.1)
finally:
if pubsub is not None:
try:
await pubsub.unsubscribe(channel)
await pubsub.close()
except Exception:
pass
if redis_client is not None:
try:
await redis_client.aclose()
except Exception:
pass
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{payment_id}", response_model=PaymentLinkStatusResponse)
async def get_link_status(
payment_id: str,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
billing_user_ref = await idb.ensure_billing_user_for_identity(session, identity)
payment = await get_payment_from_db_by_payment_id(session, payment_id)
if payment is None:
payment = await get_payment_by_payment_id(session, payment_id)
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
owner_ref = payment.get("user_id")
if owner_ref is None:
owner_ref = payment.get("tg_id")
if owner_ref is None or int(owner_ref) != int(billing_user_ref):
raise HTTPException(status_code=404, detail="Payment not found")
status = str(payment.get("status") or "").lower() or None
return PaymentLinkStatusResponse(
success=True,
payment_id=payment_id,
status=status,
completed=status in {"success", "failed", "cancelled"},
paid=status == "success",
)
+250
View File
@@ -0,0 +1,250 @@
from base64 import b64encode
from io import BytesIO
from urllib.parse import urlsplit
import qrcode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_token
from api.v2.schemas.web_public import (
ReferralApplyRequest,
ReferralApplyResponse,
ReferralConditionsResponse,
ReferralListEntry,
ReferralListResponse,
ReferralQrResponse,
ReferralTopEntryResponse,
ReferralTopResponse,
)
from sqlalchemy import select
from database.models import Referral
from config import (
CHECK_REFERRAL_REWARD_ISSUED,
REFERRAL_BONUS_PERCENTAGES,
REFERRAL_BUTTON,
REFERRAL_QR,
TOP_REFERRAL_BUTTON,
)
from core.bootstrap import BUTTONS_CONFIG
from database import (
add_referral,
get_referral_by_referred_id,
get_user_referral_count,
identities as idb,
)
from database.access.resolution import resolve_user_optional
from database.referrals import get_referral_position, get_top_referrals
from utils.referral_codes import decode_referral_code, encode_referral_code
router = APIRouter()
def _normalize_referrer_code(value: str | None, fallback_tg_id: int | None) -> int | None:
raw = str(value or "").strip()
if raw:
if "/referral/" in raw:
raw = raw.split("/referral/", 1)[-1]
if "start=referral_" in raw:
raw = raw.split("start=referral_", 1)[-1]
raw = raw.split("?", 1)[0].split("#", 1)[0].strip()
parsed = decode_referral_code(raw)
if parsed is not None:
return parsed
if fallback_tg_id is not None and int(fallback_tg_id) > 0:
return int(fallback_tg_id)
return None
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
@router.post("/apply", response_model=ReferralApplyResponse, tags=["Referrals"])
async def apply_referral(
body: ReferralApplyRequest,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
referrer_legacy = _normalize_referrer_code(body.referrer_code, body.referrer_tg_id)
if referrer_legacy is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
referrer_u = await resolve_user_optional(session, referrer_legacy)
if referrer_u is None:
raise HTTPException(status_code=400, detail="Приглашение недействительно")
if billing_uid == referrer_u.id:
raise HTTPException(status_code=400, detail="Нельзя использовать собственную ссылку")
if await get_referral_by_referred_id(session, billing_uid):
raise HTTPException(status_code=409, detail="Реферальная связь уже сохранена")
await add_referral(session, billing_uid, referrer_u.id)
referred_u = await resolve_user_optional(session, billing_uid)
if referrer_u.tg_id is not None:
try:
from database.web_notifications import notify_web
await notify_web(
session,
tg_id=int(referrer_u.tg_id),
type="referral_joined",
title="Ваш реферал присоединился",
message="Новый пользователь зарегистрировался по вашей реферальной ссылке.",
data={
"referred_tg_id": int(referred_u.tg_id) if referred_u and referred_u.tg_id else None,
"referred_user_id": int(billing_uid),
},
)
except Exception:
pass
return ReferralApplyResponse(
ok=True,
message="Приглашение применено",
referrer_code=str(referrer_u.id),
referrer_user_id=int(referrer_u.id),
referrer_tg_id=referrer_u.tg_id,
referred_user_id=int(billing_uid),
referred_tg_id=referred_u.tg_id if referred_u is not None else None,
)
@router.get("/top", response_model=ReferralTopResponse, tags=["Referrals"])
async def referral_top(
limit: int = Query(5, ge=1, le=20),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Топ рефералов отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
user_referral_count = int(await get_user_referral_count(session, billing_uid))
user_position = int(await get_referral_position(session, user_referral_count)) if user_referral_count > 0 else None
top_rows = await get_top_referrals(session, limit=limit)
top: list[ReferralTopEntryResponse] = []
for index, row in enumerate(top_rows, 1):
referrer_user_id = int(row.get("referrer_user_id") or 0)
referrals_count = int(row.get("referral_count") or 0)
display_id = encode_referral_code(referrer_user_id)
top.append(
ReferralTopEntryResponse(
position=index,
referrer_user_id=referrer_user_id,
referrals_count=referrals_count,
display_id=display_id,
)
)
return ReferralTopResponse(
user_referrals_count=user_referral_count,
user_position=user_position,
top=top,
)
@router.get("/list", response_model=ReferralListResponse, tags=["Referrals"])
async def referral_list(
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
rows_stmt = (
select(Referral)
.where(Referral.referrer_user_id == int(billing_uid))
.limit(limit)
)
result = await session.execute(rows_stmt)
rows = result.scalars().all()
items = [
ReferralListEntry(
referred_user_id=int(r.referred_user_id),
referred_tg_id=int(r.referred_tg_id) if r.referred_tg_id is not None else None,
display_id=encode_referral_code(int(r.referred_user_id)),
reward_issued=bool(r.reward_issued),
)
for r in rows
]
return ReferralListResponse(total=len(items), items=items)
@router.get("/qr", response_model=ReferralQrResponse, tags=["Referrals"])
async def referral_qr(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
if not bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)):
raise HTTPException(status_code=403, detail="QR реферальной ссылки отключен в настройках")
billing_uid = await idb.ensure_billing_user_for_identity(session, identity)
base_url = _resolve_public_base_url(request)
referral_link = f"{base_url}/referral/{encode_referral_code(int(billing_uid))}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(referral_link)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = BytesIO()
img.save(buffer, format="PNG")
image_data = b64encode(buffer.getvalue()).decode("ascii")
return ReferralQrResponse(
ok=True,
link=referral_link,
image_data_url=f"data:image/png;base64,{image_data}",
)
@router.get("/conditions", response_model=ReferralConditionsResponse, tags=["Referrals"])
async def referral_conditions(
identity=Depends(verify_identity_token),
):
if not bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)):
raise HTTPException(status_code=403, detail="Реферальная программа отключена")
del identity
level_lines: list[str] = []
for level in sorted(REFERRAL_BONUS_PERCENTAGES.keys()):
value = REFERRAL_BONUS_PERCENTAGES[level]
if isinstance(value, float):
label = f"{int(value * 100)}% от суммы оплаты"
else:
label = f"{float(value):g} RUB"
level_lines.append(f"{level} уровень: {label}")
one_time_mode = bool(CHECK_REFERRAL_REWARD_ISSUED)
bonus_mode = "one_time" if one_time_mode else "each_payment"
bonus_mode_label = (
"Бонус за первую успешную оплату реферала" if one_time_mode else "Бонус за каждую успешную оплату реферала"
)
rules = [
"Бонус начисляется только за реальных приглашённых пользователей.",
"Нельзя использовать собственную реферальную ссылку.",
"Реферальную связь можно применить только один раз.",
"Размер бонуса зависит от уровня реферальной программы.",
]
return ReferralConditionsResponse(
title="Условия реферальной программы",
summary=f"Режим начисления: {bonus_mode_label}.",
bonus_mode=bonus_mode,
bonus_mode_label=bonus_mode_label,
level_lines=level_lines,
rules=rules,
)
+353
View File
@@ -0,0 +1,353 @@
import asyncio
import os
import re
import time
import aiohttp
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session
from config import (
BALANCE_BUTTON,
CAPTCHA_ENABLE,
CHANNEL_EXISTS,
CHANNEL_REQUIRED,
DONATIONS_ENABLE,
GIFT_BUTTON,
HAPP_CRYPTOLINK,
HWID_RESET_BUTTON,
INSTRUCTIONS_BUTTON,
PROJECT_NAME,
REFERRAL_BUTTON,
REFERRAL_QR,
REMNAWAVE_WEBAPP,
REMNAWAVE_WEBAPP_OPEN_IN_BROWSER,
TELEGRAM_WEBAPP_DIRECT_LINK,
TELEGRAM_WEBAPP_SHORT_NAME,
TOP_REFERRAL_BUTTON,
TRIAL_TIME_DISABLE,
USERNAME_BOT,
USE_COUNTRY_SELECTION,
)
from core.bootstrap import BUTTONS_CONFIG, MODES_CONFIG, MONEY_CONFIG, PAYMENTS_CONFIG
from core.settings.money_config import get_currency_mode
from core.settings.web_config import WEB_CONFIG
from services.payments.providers import PROVIDERS_BASE, TELEGRAM_ONLY_PROVIDER_IDS, WEB_LINK_PROVIDER_IDS
router = APIRouter(tags=["Root"])
def _telegram_web_app_return_base() -> str | None:
direct = str(TELEGRAM_WEBAPP_DIRECT_LINK or "").strip().rstrip("/")
if direct:
if direct.lower().startswith("http://"):
direct = "https://" + direct[7:]
if direct.lower().startswith("https://t.me/"):
return direct
bot = USERNAME_BOT.replace("@", "").strip()
sn = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip()
if bot and sn:
return f"https://t.me/{bot}/{sn}"
if bot:
return f"https://t.me/{bot}"
return None
def _partner_feature_enabled() -> bool:
try:
from modules.partner_program import settings as partner_settings
except Exception:
return False
for key in ("PARTNER_PROGRAM_ENABLED", "PARTNER_BUTTON_ENABLED", "PARTNER_ENABLED"):
value = getattr(partner_settings, key, None)
if isinstance(value, bool):
return value
return True
@router.get("/api", include_in_schema=False)
async def root():
return {"message": "SoloBot API v2", "docs": "/api/docs"}
@router.get("/api/version", include_in_schema=True)
async def version():
return {"version": 2, "api": "v2"}
@router.get("/api/telegram-widget-bot", include_in_schema=True)
async def telegram_widget_bot():
"""Имя бота и имя проекта для веб-клиента."""
bot_username = str(USERNAME_BOT or "").replace("@", "").strip()
project_name = (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo"
telegram_client_id = ""
try:
from config import TELEGRAM_CLIENT_ID
telegram_client_id = str(TELEGRAM_CLIENT_ID).strip()
except ImportError:
pass
return {
"bot_username": bot_username,
"project_name": project_name,
"telegram_client_id": telegram_client_id,
}
@router.get("/api/site/init-state", include_in_schema=True)
async def site_init_state(session: AsyncSession = Depends(get_session)):
"""Прошёл ли сайт первую настройку админом. Используется middleware веб-клиента."""
from database.site_state import is_site_initialized
initialized = await is_site_initialized(session)
return {"initialized": bool(initialized)}
@router.get("/api/site/revision", include_in_schema=True)
async def site_revision(session: AsyncSession = Depends(get_session)):
"""Глобальный счётчик ревизии контента. Фронт опрашивает его в фоне и при
изменении инвалидирует свои SWR-кэши, подтягивая свежие правки админа."""
from database.site_revision import get_site_revision
revision = await get_site_revision(session)
return {"revision": int(revision)}
@router.get("/api/site-config", include_in_schema=True)
async def site_config():
"""Настройки витрины и кабинета для веб-клиента (флаги из runtime-конфигов бота)."""
bot_username = USERNAME_BOT.replace("@", "").strip()
pay_flags = {name: bool(PAYMENTS_CONFIG.get(name)) for name in PROVIDERS_BASE}
any_pay = any(pay_flags.values())
web_link_provider_ids = [provider_id for provider_id in WEB_LINK_PROVIDER_IDS if pay_flags.get(provider_id, False)]
telegram_only_provider_ids = [
provider_id for provider_id in TELEGRAM_ONLY_PROVIDER_IDS if pay_flags.get(provider_id, False)
]
currency_mode, currency_one_screen = get_currency_mode()
try:
cb_raw = MONEY_CONFIG.get("CASHBACK", 0)
cashback_percent = float(cb_raw) if cb_raw not in (None, False) else 0.0
except (TypeError, ValueError):
cashback_percent = 0.0
webapp_short = str(TELEGRAM_WEBAPP_SHORT_NAME or "").strip() or None
webapp_return_base = _telegram_web_app_return_base()
return {
"bot_username": bot_username or None,
"telegram_web_app_short_name": webapp_short,
"telegram_web_app_return_base": webapp_return_base,
"project_name": (PROJECT_NAME or "Solo").strip() if isinstance(PROJECT_NAME, str) else "Solo",
"site_mode": str(WEB_CONFIG.get("SITE_MODE", "full")).strip() or "full",
"auth": {
"telegram_login_enabled": bool(bot_username),
"email_code_login_enabled": bool(MODES_CONFIG.get("WEB_EMAIL_CODE_LOGIN_ENABLED", True)),
},
"mobile": {
"prefer_mini_app_on_telegram_mobile": bool(MODES_CONFIG.get("PREFER_MINI_APP_ON_TELEGRAM_MOBILE", False)),
},
"features": {
"channel_enabled": bool(BUTTONS_CONFIG.get("CHANNEL_BUTTON_ENABLE", CHANNEL_EXISTS)),
"donations_enabled": bool(BUTTONS_CONFIG.get("DONATIONS_BUTTON_ENABLE", DONATIONS_ENABLE)),
"balance_enabled": bool(BUTTONS_CONFIG.get("BALANCE_BUTTON_ENABLE", BALANCE_BUTTON)),
"referral_qr_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_QR_BUTTON_ENABLE", REFERRAL_QR)),
"instructions_enabled": bool(BUTTONS_CONFIG.get("INSTRUCTIONS_BUTTON_ENABLE", INSTRUCTIONS_BUTTON)),
"gift_enabled": bool(BUTTONS_CONFIG.get("GIFT_BUTTON_ENABLE", GIFT_BUTTON)),
"referral_enabled": bool(BUTTONS_CONFIG.get("REFERRAL_BUTTON_ENABLED", REFERRAL_BUTTON)),
"top_referral_enabled": bool(BUTTONS_CONFIG.get("TOP_REFERRAL_BUTTON_ENABLE", TOP_REFERRAL_BUTTON)),
"coupon_enabled": bool(BUTTONS_CONFIG.get("COUPON_BUTTON_ENABLE", True)),
"qr_subscription_enabled": bool(MODES_CONFIG.get("HAPP_CRYPTOLINK_ENABLED", HAPP_CRYPTOLINK)),
"hwid_reset_enabled": bool(BUTTONS_CONFIG.get("HWID_RESET_BUTTON_ENABLE", HWID_RESET_BUTTON)),
"country_selection_enabled": bool(MODES_CONFIG.get("COUNTRY_SELECTION_ENABLED", USE_COUNTRY_SELECTION)),
"captcha_enabled": bool(MODES_CONFIG.get("CAPTCHA_ENABLED", CAPTCHA_ENABLE)),
"channel_check_enabled": bool(MODES_CONFIG.get("CHANNEL_CHECK_ENABLED", CHANNEL_REQUIRED)),
"trial_enabled": not bool(MODES_CONFIG.get("TRIAL_TIME_DISABLED", TRIAL_TIME_DISABLE)),
"mini_app_enabled": bool(MODES_CONFIG.get("REMNAWAVE_WEBAPP_ENABLED", REMNAWAVE_WEBAPP)),
"mini_app_open_in_browser": bool(
MODES_CONFIG.get("REMNAWAVE_WEBAPP_OPEN_IN_BROWSER", REMNAWAVE_WEBAPP_OPEN_IN_BROWSER)
),
"partner_enabled": bool(_partner_feature_enabled()),
},
"payments": {
"any_enabled": any_pay,
"any_web_link_enabled": bool(web_link_provider_ids),
"any_telegram_only_enabled": bool(telegram_only_provider_ids),
"web_link_provider_ids": web_link_provider_ids,
"telegram_only_provider_ids": telegram_only_provider_ids,
"yookassa_enabled": pay_flags.get("YOOKASSA", False),
"yoomoney_enabled": pay_flags.get("YOOMONEY", False),
"robokassa_enabled": pay_flags.get("ROBOKASSA", False),
"kassai_cards_enabled": pay_flags.get("KASSAI_CARDS", False),
"kassai_sbp_enabled": pay_flags.get("KASSAI_SBP", False),
"tribute_enabled": pay_flags.get("TRIBUTE", False),
"heleket_enabled": pay_flags.get("HELEKET", False),
"cryptobot_enabled": pay_flags.get("CRYPTOBOT", False),
"freekassa_enabled": pay_flags.get("FREEKASSA", False),
"stars_enabled": pay_flags.get("STARS", False),
},
"money": {
"currency_mode": currency_mode,
"currency_one_screen": currency_one_screen,
"cashback_enabled": cashback_percent > 0,
"cashback_percent": cashback_percent,
},
}
_UPDATE_CHECK_CACHE: dict[str, object] = {"fetched_at": 0.0, "data": None}
_UPDATE_CHECK_LOCK = asyncio.Lock()
_UPDATE_CHECK_TTL_SEC = 600
_SEMVER_RE = re.compile(
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<pre>[0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$"
)
def _parse_semver(tag: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]] | None:
"""Returns a tuple comparable per semver spec.
Release > prerelease (second-to-last slot: 1 for release, 0 for prerelease).
Last slot tuple of prerelease identifiers; numeric ids compare numerically,
alphanumeric ids compare lexically, numeric < alphanumeric.
"""
match = _SEMVER_RE.match(tag.strip())
if not match:
return None
major = int(match.group("major"))
minor = int(match.group("minor"))
patch = int(match.group("patch"))
pre_raw = match.group("pre")
if not pre_raw:
return (major, minor, patch, 1, ())
identifiers: list[tuple[int, int | str]] = []
for part in pre_raw.split("."):
if part.isdigit():
identifiers.append((0, int(part)))
else:
identifiers.append((1, part))
return (major, minor, patch, 0, tuple(identifiers))
async def _fetch_ghcr_tags(image: str) -> list[str]:
"""Возвращает все теги образа в GHCR. Поддерживает paginate через Link header."""
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15)) as session:
token_url = f"https://ghcr.io/token?scope=repository:{image}:pull"
async with session.get(token_url) as token_resp:
if token_resp.status != 200:
return []
token_data = await token_resp.json()
token = str(token_data.get("token") or "").strip()
if not token:
return []
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
all_tags: list[str] = []
next_url: str | None = f"https://ghcr.io/v2/{image}/tags/list?n=1000"
guard = 0
while next_url and guard < 20:
guard += 1
async with session.get(next_url, headers=headers) as tags_resp:
if tags_resp.status != 200:
break
payload = await tags_resp.json()
page_tags = payload.get("tags") or []
if isinstance(page_tags, list):
all_tags.extend(str(t) for t in page_tags)
link_header = tags_resp.headers.get("Link") or ""
next_url = None
for part in link_header.split(","):
part = part.strip()
if not part or 'rel="next"' not in part:
continue
inner = part.split(";", 1)[0].strip()
if inner.startswith("<") and inner.endswith(">"):
inner = inner[1:-1]
if inner.startswith("/"):
next_url = f"https://ghcr.io{inner}"
else:
next_url = inner
break
return all_tags
def _is_dev_version(v: str) -> bool:
return "-dev" in v or "dev." in v
def _latest_for_channel(tags: list[str], dev: bool) -> str | None:
versions = []
for raw in tags:
parsed = _parse_semver(str(raw))
if parsed is None:
continue
is_dev = _is_dev_version(str(raw))
if is_dev == dev:
versions.append((parsed, str(raw)))
if not versions:
return None
versions.sort(key=lambda item: item[0], reverse=True)
return versions[0][1]
@router.get("/api/meta/update-check", include_in_schema=True)
async def update_check(current: str | None = Query(default=None)):
"""Сравнивает переданную версию с последним тегом в GHCR. Канал (dev/release) определяется по current."""
current_v = (current or "").strip()
image = (os.environ.get("GHCR_IMAGE") or "vladless/solo-brick").strip()
if not image:
return {"current": current_v or None, "latest": None, "hasUpdate": False}
now = time.time()
is_dev = _is_dev_version(current_v) if current_v else True
cache_key = f"data-{'dev' if is_dev else 'release'}"
cached = _UPDATE_CHECK_CACHE.get(cache_key)
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
if cached is not None and now - fetched_at < _UPDATE_CHECK_TTL_SEC:
cached_resp = dict(cached)
cached_resp["current"] = current_v or None
if current_v:
cur = _parse_semver(current_v)
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
return cached_resp
latest: str | None = None
async with _UPDATE_CHECK_LOCK:
fetched_at = float(_UPDATE_CHECK_CACHE.get("fetched_at") or 0.0)
if now - fetched_at < _UPDATE_CHECK_TTL_SEC:
cached = _UPDATE_CHECK_CACHE.get(cache_key)
if cached is not None:
cached_resp = dict(cached)
cached_resp["current"] = current_v or None
if current_v:
cur = _parse_semver(current_v)
nxt = _parse_semver(str(cached_resp.get("latest") or ""))
cached_resp["hasUpdate"] = bool(cur and nxt and nxt > cur)
return cached_resp
try:
tags = await _fetch_ghcr_tags(image)
latest_dev = _latest_for_channel(tags, dev=True)
latest_rel = _latest_for_channel(tags, dev=False)
_UPDATE_CHECK_CACHE["data-dev"] = {"latest": latest_dev, "hasUpdate": False, "image": image}
_UPDATE_CHECK_CACHE["data-release"] = {"latest": latest_rel, "hasUpdate": False, "image": image}
_UPDATE_CHECK_CACHE["fetched_at"] = now
latest = latest_dev if is_dev else latest_rel
except Exception:
latest = None
has_update = False
if current_v and latest:
cur = _parse_semver(current_v)
nxt = _parse_semver(latest)
if cur and nxt:
has_update = nxt > cur
return {
"current": current_v or None,
"latest": latest,
"hasUpdate": has_update,
"image": image or None,
"channel": "dev" if is_dev else "release",
"checkedAt": int(now),
}
+16
View File
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import ServerBase, ServerResponse, ServerUpdate
from database.models import Server
router = 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"],
)
+147
View File
@@ -0,0 +1,147 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, verify_identity_admin
from api.v2.schemas import SettingResponse, SettingUpsert
from core.settings.buttons_config import BUTTONS_CONFIG, update_buttons_config
from core.settings.modes_config import MODES_CONFIG, update_modes_config
from core.settings.money_config import MONEY_CONFIG, update_money_config
from core.settings.notifications_config import NOTIFICATIONS_CONFIG, update_notifications_config
from core.settings.payments_config import PAYMENTS_CONFIG, update_payments_config
from core.settings.providers_order_config import PROVIDERS_ORDER, update_providers_order
from core.settings.tariffs_config import TARIFFS_CONFIG, update_tariffs_config
from database.models import Setting
from database.settings import set_setting
from database.settings_cache import settings_cache
router = APIRouter()
class ConfigUpdatePayload(BaseModel):
value: dict[str, Any] | None = None
@router.get("/", response_model=list[SettingResponse])
async def get_all_settings(identity=Depends(verify_identity_admin)):
"""Список всех настроек (из кэша, без запроса к БД)."""
return settings_cache.get_all()
@router.get("/configs")
async def get_configs(identity=Depends(verify_identity_admin)):
"""Все конфиги (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
return {
"payments": dict(PAYMENTS_CONFIG),
"buttons": dict(BUTTONS_CONFIG),
"notifications": dict(NOTIFICATIONS_CONFIG),
"modes": dict(MODES_CONFIG),
"money": dict(MONEY_CONFIG),
"providers_order": dict(PROVIDERS_ORDER),
"tariffs": dict(TARIFFS_CONFIG),
}
@router.post("/configs/{scope}")
async def update_config_scope(
scope: str,
payload: ConfigUpdatePayload,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Обновление конфига по scope (payments, buttons, notifications, modes, money, providers_order, tariffs)."""
data = dict(payload.value or {})
normalized = scope.strip().lower().replace("-", "_")
if normalized == "payments":
cleaned = {key: bool(value) for key, value in data.items()}
await update_payments_config(session, cleaned)
return {"payments": dict(PAYMENTS_CONFIG)}
if normalized == "buttons":
cleaned = {key: bool(value) for key, value in data.items()}
await update_buttons_config(session, cleaned)
return {"buttons": dict(BUTTONS_CONFIG)}
if normalized == "notifications":
await update_notifications_config(session, data)
return {"notifications": dict(NOTIFICATIONS_CONFIG)}
if normalized == "modes":
cleaned = {key: bool(value) for key, value in data.items()}
await update_modes_config(session, cleaned)
return {"modes": dict(MODES_CONFIG)}
if normalized == "money":
await update_money_config(session, data)
return {"money": dict(MONEY_CONFIG)}
if normalized == "providers_order":
cleaned: dict[str, int] = {}
for key, value in data.items():
try:
cleaned[key] = int(value)
except (TypeError, ValueError):
continue
await update_providers_order(session, cleaned)
return {"providers_order": dict(PROVIDERS_ORDER)}
if normalized == "tariffs":
cleaned = dict(data)
if "ALLOW_DOWNGRADE" in cleaned:
cleaned["ALLOW_DOWNGRADE"] = bool(cleaned.get("ALLOW_DOWNGRADE"))
if "KEY_ADDONS_RECALC_PRICE" in cleaned:
cleaned["KEY_ADDONS_RECALC_PRICE"] = bool(cleaned.get("KEY_ADDONS_RECALC_PRICE"))
if "KEY_ADDONS_PACK_MODE" in cleaned:
mode = str(cleaned.get("KEY_ADDONS_PACK_MODE") or "").strip().lower()
cleaned["KEY_ADDONS_PACK_MODE"] = mode if mode in {"", "traffic", "devices", "all"} else ""
await update_tariffs_config(session, cleaned)
return {"tariffs": dict(TARIFFS_CONFIG)}
raise HTTPException(status_code=404, detail="Unsupported config scope")
@router.get("/{key}", response_model=SettingResponse)
async def get_setting_by_key(key: str, identity=Depends(verify_identity_admin)):
"""Настройка по ключу (из кэша, без запроса к БД)."""
obj = settings_cache.get(key)
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
return obj
@router.post("/{key}", response_model=SettingResponse)
async def upsert_setting(
key: str,
payload: SettingUpsert,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Создание или обновление настройки по ключу."""
obj = await set_setting(
session=session,
key=key,
value=payload.value,
description=payload.description,
)
await session.refresh(obj)
settings_cache.update(
key,
obj.value,
obj.description,
created_at=getattr(obj, "created_at", None),
updated_at=getattr(obj, "updated_at", None),
)
return obj
@router.delete("/{key}", response_model=dict)
async def delete_setting(
key: str,
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаление настройки по ключу."""
result = await session.execute(select(Setting).where(Setting.key == key))
obj = result.scalar_one_or_none()
if not obj:
raise HTTPException(status_code=404, detail="Setting not found")
await session.delete(obj)
settings_cache.delete(key)
return {"detail": "Setting deleted"}
+452
View File
@@ -0,0 +1,452 @@
from datetime import datetime, timedelta
from math import ceil
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pytz import timezone as tz_moscow
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.depends import get_session, validate_redirect_url, verify_identity_token
from api.v2.base_crud import generate_crud_router
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
from api.v2.schemas.web_public import (
TariffConfigPriceResponse,
TariffPurchaseRequest,
TariffPurchaseResponse,
)
from core.bootstrap import PAYMENTS_CONFIG
from core.redis_cache import cache_get, cache_key, cache_set
from database import (
get_balance,
identities as idb,
)
from database.coupons import mark_coupon_used
from database.models import Tariff
from database.tariffs import get_tariff_by_id
from database.temporary_data import create_temporary_data
from logger import logger
from services.keys import create_vpn_key_headless
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
from services.payments.providers import WEB_LINK_PROVIDER_IDS
from services.tariffs import calculate_config_price
def _tariff_to_public(t: Tariff) -> TariffPublic:
dev_opts = getattr(t, "device_options", None)
tr_opts = getattr(t, "traffic_options_gb", None)
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
if isinstance(dev_opts, list):
device_options = []
for x in dev_opts:
try:
device_options.append(int(x))
except (TypeError, ValueError):
continue
if not device_options:
device_options = None
if isinstance(tr_opts, list):
traffic_options_gb = []
for x in tr_opts:
try:
traffic_options_gb.append(int(x))
except (TypeError, ValueError):
continue
if not traffic_options_gb:
traffic_options_gb = None
return TariffPublic(
id=t.id,
name=t.name or "",
group_code=t.group_code or "",
duration_days=t.duration_days or 0,
price_rub=t.price_rub or 0,
traffic_limit=t.traffic_limit,
device_limit=t.device_limit,
subgroup_title=t.subgroup_title,
sort_order=t.sort_order,
vless=bool(getattr(t, "vless", False)),
configurable=bool(getattr(t, "configurable", False)),
device_options=device_options,
traffic_options_gb=traffic_options_gb,
)
public_router = APIRouter()
def _resolve_public_base_url(request: Request) -> str:
origin = str(request.headers.get("origin") or "").strip()
if origin.startswith(("http://", "https://")):
return origin.rstrip("/")
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
if referer.startswith(("http://", "https://")):
parsed = urlsplit(referer)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
host = forwarded_host or str(request.headers.get("host") or "").strip()
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
if host:
return f"{scheme}://{host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_web_payment_provider() -> str | None:
for provider_id in WEB_LINK_PROVIDER_IDS:
if bool(PAYMENTS_CONFIG.get(provider_id)):
return provider_id
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
def _public_tariffs_cache_key(
group_code: str | None,
tariff_ids: str | None,
filter_vless: str | None,
) -> str:
normalized_group = (group_code or "").strip().lower()
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
normalized_vless = (filter_vless or "").strip().lower()
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
@public_router.get("/groups", response_model=list[TariffGroup])
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
q = (
select(Tariff.group_code)
.where(Tariff.is_active.is_(True), Tariff.group_code.isnot(None), Tariff.group_code != "")
.distinct()
.order_by(Tariff.group_code)
)
result = await session.execute(q)
values = result.scalars().all()
return [TariffGroup(group_code=v or "") for v in values]
@public_router.get("/public", response_model=list[TariffPublic])
async def get_tariffs_public(
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
filter_vless: str | None = Query(
None,
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
),
session: AsyncSession = Depends(get_session),
):
"""Публичный список активных тарифов (без авторизации)."""
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
cached = await cache_get(cache_token)
if isinstance(cached, list):
return cached
q = (
select(Tariff)
.where(Tariff.is_active.is_(True))
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
)
if tariff_ids:
try:
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
if not ids:
return []
q = q.where(Tariff.id.in_(ids))
except ValueError:
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
elif group_code:
q = q.where(Tariff.group_code == group_code)
if filter_vless == "router":
q = q.where(Tariff.vless.is_(True))
elif filter_vless == "app":
q = q.where(Tariff.vless.is_(False))
result = await session.execute(q)
rows = result.scalars().all()
payload = [_tariff_to_public(t).model_dump() for t in rows]
await cache_set(cache_token, payload, 30)
return payload
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
async def get_tariff_config_price(
tariff_id: int = Query(..., ge=1),
selected_device_limit: int | None = Query(None),
selected_traffic_gb: int | None = Query(None),
session: AsyncSession = Depends(get_session),
):
tariff = await get_tariff_by_id(session, tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
return TariffConfigPriceResponse(price_rub=price)
user_tariff_router = APIRouter()
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
async def purchase_tariff_with_balance(
body: TariffPurchaseRequest,
request: Request,
preview: bool = Query(False),
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
tariff = await get_tariff_by_id(session, body.tariff_id)
if not tariff or not tariff.get("is_active", True):
raise HTTPException(status_code=404, detail="Тариф не найден")
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
if price <= 0:
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
session=session,
billing_user_id=int(tg_id),
base_price_rub=int(price),
coupon_code=body.coupon_code,
)
balance = float(await get_balance(session, tg_id))
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
required_amount = int(max(0, ceil(float(final_price) - balance)))
if preview:
return TariffPurchaseResponse(
ok=True,
message="Расчет обновлен",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=required_amount > 0,
required_amount_rub=int(required_amount),
payment_id=None,
payment_url=None,
)
if required_amount > 0:
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=success_url,
failure_url=failure_url,
metadata={
"payment_flow": "tariff_purchase",
"tariff_id": int(body.tariff_id),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"selected_price_rub": int(final_price),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"tariff_id": int(body.tariff_id),
"required_amount": int(required_amount),
"selected_price_rub": int(final_price),
"selected_device_limit": body.selected_device_limit,
"selected_traffic_limit_gb": body.selected_traffic_gb,
"selected_duration_days": int(duration),
"base_price_rub": int(price),
"discount_rub": int(discount_rub),
"applied_coupon_code": applied_coupon_code,
"coupon_id": int(coupon_id) if coupon_id is not None else None,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для оформления подписки",
key_email=None,
charged_rub=0,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=body.tariff_id,
selected_device_limit=body.selected_device_limit,
selected_traffic_gb=body.selected_traffic_gb,
selected_price_rub=final_price,
)
if coupon_id is not None:
await mark_coupon_used(session, int(coupon_id), int(tg_id))
except Exception:
logger.exception("web tariff purchase failed")
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
return TariffPurchaseResponse(
ok=True,
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
key_email=None,
charged_rub=final_price,
base_price_rub=int(price),
discount_rub=int(discount_rub),
final_price_rub=int(final_price),
applied_coupon_code=applied_coupon_code,
)
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
async def activate_trial(
request: Request,
session: AsyncSession = Depends(get_session),
identity=Depends(verify_identity_token),
):
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
from database import get_trial, update_trial
from database.tariffs import get_tariffs
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
trial_status = await get_trial(session, tg_id)
if trial_status not in (0, -1):
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
trial_tariffs = await get_tariffs(session, group_code="trial")
if not trial_tariffs:
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
tariff = trial_tariffs[0]
price = int(tariff.get("price_rub", 0) or 0)
duration = int(tariff.get("duration_days") or 0)
if duration <= 0:
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
if price <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=0,
skip_balance_charge=True,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=0,
base_price_rub=0,
final_price_rub=0,
)
balance = float(await get_balance(session, tg_id))
required_amount = int(max(0, ceil(float(price) - balance)))
if required_amount <= 0:
moscow_tz = tz_moscow("Europe/Moscow")
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
try:
await create_vpn_key_headless(
session=session,
tg_id=tg_id,
expiry_time=expiry,
plan=int(tariff["id"]),
selected_price_rub=price,
is_trial=True,
)
await update_trial(session, tg_id, 1)
except Exception:
logger.exception("web paid trial activation failed")
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
return TariffPurchaseResponse(
ok=True,
message="Пробная подписка активирована!",
charged_rub=price,
base_price_rub=price,
final_price_rub=price,
)
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
if not provider_id:
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
base_url = _resolve_public_base_url(request)
payment_request = PaymentLinkRequest(
legacy_user_ref=int(tg_id),
amount=required_amount,
currency="RUB",
provider_id=provider_id,
success_url=f"{base_url}/payment-success",
failure_url=f"{base_url}/payment-failure",
metadata={
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
payment_result = await create_payment_link(session, payment_request)
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
await create_temporary_data(
session,
int(tg_id),
"waiting_for_payment",
{
"payment_flow": "trial_purchase",
"tariff_id": int(tariff["id"]),
"required_amount": required_amount,
"selected_price_rub": price,
"selected_duration_days": duration,
},
)
return TariffPurchaseResponse(
ok=True,
message="Требуется оплата для активации пробной подписки",
charged_rub=0,
base_price_rub=price,
final_price_rub=price,
payment_required=True,
required_amount_rub=required_amount,
payment_id=payment_result.payment_id,
payment_url=payment_result.payment_url,
)
router = 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"],
)
+63
View File
@@ -0,0 +1,63 @@
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_identity_admin
from api.v2.base_crud import generate_crud_router
from api.v2.schemas import UserBase, UserResponse, UserUpdate
from database import async_session_maker, delete_user_data, get_servers
from database.access.resolution import resolve_user_optional
from database.models import Key, User
from logger import logger
from services.operations import delete_key_from_cluster
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", "create", "update"],
)
@router.delete("/{tg_id}", response_model=dict)
async def delete_user(
tg_id: int = Path(..., description="Telegram ID пользователя"),
identity=Depends(verify_identity_admin),
session: AsyncSession = Depends(get_session),
):
"""Удаляет пользователя и его ключи на серверах."""
try:
u = await resolve_user_optional(session, tg_id)
if u is None:
raise HTTPException(status_code=404, detail="Пользователь не найден")
result = await session.execute(select(Key.email, Key.client_id).where(Key.user_id == u.id))
key_records = result.all()
async with async_session_maker() as s:
servers = await get_servers(session=s)
cluster_ids = list(servers.keys())
async def _delete_one(cluster_id: str, email: str, client_id: str):
async with async_session_maker() as s:
await delete_key_from_cluster(cluster_id, email, client_id, s)
try:
tasks = [
_delete_one(cluster_id, email, client_id)
for email, client_id in key_records
for cluster_id in cluster_ids
]
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.error(f"[DELETE] Ошибка при удалении ключей с серверов для пользователя {tg_id}: {e}")
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="Ошибка при удалении пользователя")
+1128
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
from api.v1.schemas import (
BlockedUserResponse,
CouponBase,
CouponResponse,
CouponUpdate,
CouponUsageResponse,
GiftBase,
GiftResponse,
GiftUpdate,
GiftUsageResponse,
KeyDetailsResponse,
KeyResponse,
ManualBanResponse,
NotificationResponse,
PaymentResponse,
ReferralResponse,
ServerBase,
ServerResponse,
ServerUpdate,
TariffBase,
TariffResponse,
TariffUpdate,
TemporaryDataResponse,
TrackingSourceResponse,
UserBase,
UserResponse,
UserUpdate,
)
from api.v1.schemas.keys import KeyBase, KeyCreateRequest, KeyUpdate
from api.v1.schemas.settings import SettingResponse, SettingUpsert
from api.v2.schemas.web import (
WebBlockResponse,
WebPageResponse,
WebPageUpdate,
WebPageVariantCreate,
WebPageVariantSummary,
WebPageVariantUpdate,
WebPageVariantsResponse,
WebTheme,
)
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class AuditEventResponse(BaseModel):
id: int | None = None
event_type: str
channel: str
actor_identity_id: str | None
actor_tg_id: int | None
path_or_handler: str
entity_type: str | None
entity_id: str | None
result: str
reason: str | None
metadata: dict[str, Any] | None
request_id: str | None
created_at: datetime | None
class AuditEventListResponse(BaseModel):
items: list[AuditEventResponse]
limit: int
offset: int
class AuditPathStat(BaseModel):
step: str
label: str
total: int
success: int
fail: int
unique_users: int
fail_rate_pct: float
class AuditFunnelStep(BaseModel):
step: str
label: str
count: int
conversion_from_prev_pct: float | None
class AuditStatsSummary(BaseModel):
date_from: str
date_to: str
total_events: int
unique_users: int
class AuditStatsResponse(BaseModel):
summary: AuditStatsSummary
by_path: list[AuditPathStat]
funnel: list[AuditFunnelStep]
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
_FLOW_CONFIG = ConfigDict(populate_by_name=True, alias_generator=to_camel)
class EdgeConditionSchema(BaseModel):
model_config = _FLOW_CONFIG
field: str
operator: str
value: Any = None
class EdgeConditionGroupSchema(BaseModel):
model_config = _FLOW_CONFIG
logic: Literal["and", "or"] = "and"
conditions: list[EdgeConditionSchema] = []
class FlowEdgeSchema(BaseModel):
model_config = _FLOW_CONFIG
id: str
source: str
target: str
condition: EdgeConditionSchema | None = None
condition_group: EdgeConditionGroupSchema | None = None
label: str | None = None
priority: int | None = None
class FlowNodeActionConfigSchema(BaseModel):
model_config = _FLOW_CONFIG
action_type: str
params: dict[str, Any] = {}
class FlowNodeSchema(BaseModel):
model_config = _FLOW_CONFIG
id: str
type: str
label: str
label_en: str | None = None
enabled: bool = True
page_slug: str | None = None
cabinet_tab: str | None = None
screen_group: str | None = None
screen_id: str | None = None
auto_skip_if: EdgeConditionSchema | EdgeConditionGroupSchema | None = None
action_config: FlowNodeActionConfigSchema | None = None
config: dict = {}
position: dict
class FlowResponse(BaseModel):
model_config = _FLOW_CONFIG
id: str
name: str
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None
version: int
class FlowUpdate(BaseModel):
model_config = _FLOW_CONFIG
name: str | None = None
nodes: list[FlowNodeSchema]
edges: list[FlowEdgeSchema]
entry_node_id: str | None = None
class FlowCreate(BaseModel):
model_config = _FLOW_CONFIG
id: str
name: str
nodes: list[FlowNodeSchema] = []
edges: list[FlowEdgeSchema] = []
entry_node_id: str | None = None
+132
View File
@@ -0,0 +1,132 @@
from datetime import datetime
from pydantic import BaseModel, Field
class IdentityCreate(BaseModel):
email: str | None = Field(None, description="Почта для привязки")
tg_id: int | None = Field(None, description="Telegram ID для привязки")
class IdentityResponse(BaseModel):
id: str
email: str | None
tg_id: int | None
is_admin: bool = False
email_verified: bool = False
password_set: bool = False
onboarding_completed: bool = False
onboarding_stage: str | None = None
created_at: datetime | None
updated_at: datetime | None
class Config:
from_attributes = True
class RegisterByEmailRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(..., min_length=8, description="Пароль (минимум 8 символов)")
referral_code: str | None = Field(None, min_length=1)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class RegisterResponse(BaseModel):
identity_id: str
class LoginRequest(BaseModel):
email: str = Field(..., min_length=1)
password: str = Field(...)
class SetPasswordRequest(BaseModel):
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class ChangePasswordRequest(BaseModel):
current_password: str = Field(...)
password: str = Field(..., min_length=8, description="Новый пароль (минимум 8 символов)")
password_confirm: str = Field(..., min_length=8)
class LoginResponse(BaseModel):
identity_id: str
identity: IdentityResponse
class SendLoginCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
allow_register: bool = Field(
default=True,
description="Если true и email новый — создать идентичность и отправить код (passwordless flow)",
)
turnstile_token: str | None = Field(default=None, description="Cloudflare Turnstile CAPTCHA token")
class LoginByCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
class ConfirmPasswordResetRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1)
password: str = Field(..., min_length=8)
password_confirm: str = Field(..., min_length=8)
class LoginTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget (кнопка «Войти через Telegram»)."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class LinkTelegramRequest(BaseModel):
"""Данные от Telegram Login Widget — обязательны для доказательства владения аккаунтом при привязке."""
id: int = Field(..., description="Telegram user id (tg_id)")
first_name: str = Field("")
last_name: str | None = None
username: str | None = None
photo_url: str | None = None
auth_date: int = Field(..., description="Unix timestamp от Telegram")
hash: str = Field(..., description="HMAC подпись для проверки на бэкенде")
class IdentityAttachEmail(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailSendCodeRequest(BaseModel):
email: str = Field(..., min_length=1)
class LinkEmailConfirmRequest(BaseModel):
email: str = Field(..., min_length=1)
code: str = Field(..., min_length=1, max_length=16)
class IdentityAttachTelegram(BaseModel):
tg_id: int = Field(...)
class IdentitySessionItem(BaseModel):
id: str
device_label: str | None = None
ip: str | None = None
created_at: datetime
last_seen_at: datetime
expires_at: datetime | None = None
is_current: bool = False
class IdentitySessionsResponse(BaseModel):
sessions: list[IdentitySessionItem]
+32
View File
@@ -0,0 +1,32 @@
from typing import Any
from pydantic import BaseModel, Field
class PaymentLinkCreateRequest(BaseModel):
tg_id: int | None = Field(None, description="Telegram ID пользователя (если не задан identity_id)")
identity_id: str | None = Field(None, description="ID идентичности; tg_id будет взят из привязки")
amount: int | float = Field(..., gt=0, description="Сумма оплаты")
currency: str = Field(default="RUB", description="Валюта (например RUB)")
provider_id: str | None = Field(
default=None,
description="Идентификатор кассы: ROBOKASSA, FREEKASSA, YOOKASSA, YOOMONEY, KASSAI_CARDS, KASSAI_SBP, HELEKET и др. Если не задан — берётся первый доступный.",
)
success_url: str | None = Field(None, description="URL перенаправления после успешной оплаты")
failure_url: str | None = Field(None, description="URL перенаправления после неуспешной оплаты")
metadata: dict[str, Any] | None = Field(None, description="Дополнительные данные")
class PaymentLinkCreateResponse(BaseModel):
success: bool
payment_id: str | None = None
payment_url: str | None = None
error: str | None = None
class PaymentLinkStatusResponse(BaseModel):
success: bool
payment_id: str
status: str | None = None
completed: bool = False
paid: bool = False
+25
View File
@@ -0,0 +1,25 @@
from pydantic import BaseModel
class TariffGroup(BaseModel):
"""Группа тарифов (group_code) для выбора в лендинге и др."""
group_code: str
class TariffPublic(BaseModel):
"""Публичный список тарифов (без авторизации)."""
id: int
name: str
group_code: str
duration_days: int
price_rub: int
traffic_limit: int | None
device_limit: int | None
subgroup_title: str | None
sort_order: int | None
vless: bool = False
configurable: bool = False
device_options: list[int] | None = None
traffic_options_gb: list[int] | None = None
+125
View File
@@ -0,0 +1,125 @@
import json
from typing import Any
from pydantic import BaseModel, Field, model_validator
_MAX_BLOCK_DATA_SIZE = 4 * 1024 * 1024
class WebBlockBase(BaseModel):
type: str = Field(..., max_length=64)
order: int
data: dict[str, Any]
@model_validator(mode="after")
def _check_data_size(self) -> "WebBlockBase":
if len(json.dumps(self.data, ensure_ascii=False)) > _MAX_BLOCK_DATA_SIZE:
raise ValueError(f"Размер data блока не должен превышать {_MAX_BLOCK_DATA_SIZE // 1024} КБ")
return self
class WebBlockResponse(WebBlockBase):
id: str
class Config:
from_attributes = True
class WebTheme(BaseModel):
tokens: dict[str, Any]
class WebPageThemeResponse(BaseModel):
slug: str
variant_key: str = "default"
tokens: dict[str, Any] = Field(default_factory=dict)
class WebPageThemeUpdate(BaseModel):
tokens: dict[str, Any]
class WebPageVariantSummary(BaseModel):
key: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
is_active: bool = False
class WebPageSaveResponse(BaseModel):
slug: str
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageResponse(BaseModel):
slug: str
blocks: list[WebBlockResponse]
theme: WebTheme | None = None
variant_key: str = "default"
active_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebPageUpdate(BaseModel):
blocks: list[WebBlockBase]
theme: WebTheme | None = None
class WebPageVariantCreate(BaseModel):
key: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, max_length=255)
from_variant_key: str | None = Field(default=None, max_length=64)
class WebPageVariantUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
make_active: bool | None = None
class WebPageVariantsResponse(BaseModel):
slug: str
active_variant_key: str = "default"
current_variant_key: str = "default"
variants: list[WebPageVariantSummary] = Field(default_factory=list)
class WebUploadResponse(BaseModel):
url: str
class FlowStepConfig(BaseModel):
provider_ids: list[str] | None = None
tariff_group_code: str | None = None
tariff_ids: list[int] | None = None
display_mode: str | None = None
skippable: bool = False
auto_advance_if_single: bool = False
class FlowStepSchema(BaseModel):
id: str = Field(..., max_length=64)
type: str = Field(..., max_length=32)
label: str = Field(..., max_length=255)
label_en: str | None = Field(default=None, max_length=255)
enabled: bool = True
page_slug: str | None = Field(default=None, max_length=64)
config: FlowStepConfig = Field(default_factory=FlowStepConfig)
class FlowDefinitionSchema(BaseModel):
id: str = Field(..., max_length=64)
name: str = Field(..., max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
version: int = 1
class FlowDefinitionResponse(FlowDefinitionSchema):
pass
class FlowDefinitionUpdate(BaseModel):
name: str | None = Field(default=None, max_length=255)
steps: list[FlowStepSchema] = Field(default_factory=list)
+479
View File
@@ -0,0 +1,479 @@
from pydantic import BaseModel, Field
class AccountSummaryResponse(BaseModel):
identity_id: str
email: str | None = None
tg_id: int | None = None
linked_telegram: bool = False
created_at: str | None = None
password_set: bool = False
referral_code: str = ""
balance: float = 0.0
trial_status: int = 0
keys_total: int = 0
referrals_total: int = 0
referrals_active: int = 0
referral_bonus_total: float = 0.0
gifts_sent: int = 0
gifts_claimed: int = 0
coupons_used: int = 0
partner_enabled: bool = False
partner_code: str = ""
partner_balance: float = 0.0
partner_percent: float = 0.0
partner_percent_custom: bool = False
partner_referred_total: int = 0
partner_referred_paid: int = 0
partner_payout_method: str | None = None
unread_notifications: int = 0
class AccountKeyActionsAvailability(BaseModel):
can_connect_device: bool = False
can_connect_router: bool = False
can_connect_tv: bool = False
can_renew: bool = False
can_addons: bool = False
can_reset_hwid: bool = False
can_qr: bool = False
can_delete: bool = False
can_change_location: bool = False
class AccountKeyDetailsResponse(BaseModel):
client_id: str
email: str
alias: str | None = None
expiry_time: int = 0
is_frozen: bool = False
tariff_name: str = ""
subgroup_title: str = ""
traffic_limit_gb: int = 0
used_traffic_gb: float | None = None
device_limit: int = 0
connected_devices: int = 0
is_tariff_configurable: bool = False
addons_devices_enabled: bool = False
addons_traffic_enabled: bool = False
class AccountKeyResponse(BaseModel):
email: str
alias: str | None = None
client_id: str
tariff_id: int | None = None
server_id: str
created_at: int = 0
expiry_time: int = 0
key: str | None = None
remnawave_link: str | None = None
is_frozen: bool = False
actions: AccountKeyActionsAvailability | None = None
class AccountKeyAliasUpdateRequest(BaseModel):
alias: str = Field(..., min_length=1, max_length=10)
class AccountKeyActionResponse(BaseModel):
ok: bool = True
message: str = ""
class AccountKeyRenewRequest(BaseModel):
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyRenewResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
charged_rub: int = 0
balance_rub: float = 0.0
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyResetHwidResponse(AccountKeyActionResponse):
total_devices: int = 0
reset_devices: int = 0
class AccountKeyQrResponse(AccountKeyActionResponse):
link: str = ""
image_data_url: str = ""
class AccountKeyLocationOptionResponse(BaseModel):
server_name: str
class AccountKeyLocationsResponse(BaseModel):
client_id: str
current_server: str = ""
locations: list[AccountKeyLocationOptionResponse] = []
class AccountKeyChangeLocationRequest(BaseModel):
server_name: str = Field(..., min_length=1)
class AccountKeyChangeLocationResponse(AccountKeyActionResponse):
client_id: str
server_id: str = ""
link: str = ""
remnawave_link: str | None = None
class AccountKeyAddonOptionResponse(BaseModel):
value: int
label: str = ""
class AccountKeyAddonsPreviewRequest(BaseModel):
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
include_device: bool | None = None
include_traffic: bool | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class AccountKeyAddonsPreviewResponse(BaseModel):
client_id: str
tariff_id: int
addons_mode: str = ""
has_device_option: bool = False
has_traffic_option: bool = False
current_device_limit: int | None = None
current_traffic_gb: int | None = None
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
device_options: list[AccountKeyAddonOptionResponse] = []
traffic_options: list[AccountKeyAddonOptionResponse] = []
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
balance_rub: float = 0.0
class AccountKeyApplyAddonsResponse(AccountKeyActionResponse):
client_id: str
tariff_id: int
total_price_rub: int = 0
extra_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
charged_rub: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class AccountKeyActionsConfigResponse(BaseModel):
renew_enabled: bool = True
delete_enabled: bool = False
qr_enabled: bool = False
hwid_reset_enabled: bool = False
country_change_enabled: bool = False
instructions_enabled: bool = False
addons_enabled: bool = False
addons_mode: str = ""
tv_connect_enabled: bool = False
class AccountKeyConnectionResponse(BaseModel):
client_id: str
online: bool = False
is_frozen: bool = False
expiry_time: int = 0
expires_in_days: int = 0
server_name: str = ""
cluster_name: str = ""
panel_type: str = ""
protocol: str = ""
class AccountSearchHit(BaseModel):
kind: str
label: str
sublabel: str = ""
href: str = ""
meta: str = ""
class AccountSearchResponse(BaseModel):
query: str
hits: list[AccountSearchHit] = []
total: int = 0
class TariffConfigPriceResponse(BaseModel):
price_rub: int
class TariffPurchaseRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
coupon_code: str | None = None
class TariffPurchaseResponse(BaseModel):
ok: bool = True
message: str = ""
key_email: str | None = None
charged_rub: int | None = None
base_price_rub: int = 0
discount_rub: int = 0
final_price_rub: int = 0
applied_coupon_code: str | None = None
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftCreateRequest(BaseModel):
tariff_id: int = Field(..., ge=1)
selected_device_limit: int | None = None
selected_traffic_gb: int | None = None
provider_id: str | None = None
success_url: str | None = None
failure_url: str | None = None
class GiftCreatePreviewResponse(BaseModel):
ok: bool = True
price_rub: int = 0
balance_rub: float = 0.0
sufficient_funds: bool = True
tariff_name: str = ""
duration_days: int = 0
class GiftCreateResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
site_gift_link: str = ""
tariff_name: str = ""
duration_days: int = 0
price_charged: int = 0
balance_rub: float = 0.0
payment_required: bool = False
required_amount_rub: int = 0
payment_id: str | None = None
payment_url: str | None = None
class GiftUsageEntry(BaseModel):
user_id: int
used_at: str | None = None
class MyGiftItem(BaseModel):
gift_id: str
tariff_name: str = ""
duration_days: int = 0
price_rub: int = 0
created_at: str | None = None
expiry_time: str | None = None
is_used: bool = False
is_unlimited: bool = False
max_usages: int | None = None
site_gift_link: str = ""
usages: list[GiftUsageEntry] = []
class MyGiftsResponse(BaseModel):
ok: bool = True
gifts: list[MyGiftItem] = []
total: int = 0
limit: int = 20
offset: int = 0
class GiftRedeemRequest(BaseModel):
gift_code: str = Field(..., min_length=1)
class GiftRedeemResponse(BaseModel):
ok: bool = True
message: str = ""
gift_id: str = ""
tariff_id: int = 0
duration_days: int = 0
class ReferralApplyRequest(BaseModel):
referrer_code: str | None = Field(None, min_length=1)
referrer_tg_id: int | None = Field(None, ge=1)
class ReferralApplyResponse(BaseModel):
ok: bool = True
message: str = ""
referrer_code: str = ""
referrer_user_id: int = 0
referrer_tg_id: int | None = None
referred_user_id: int = 0
referred_tg_id: int | None = None
class ReferralTopEntryResponse(BaseModel):
position: int
referrer_user_id: int
referrals_count: int
display_id: str
class ReferralTopResponse(BaseModel):
user_referrals_count: int = 0
user_position: int | None = None
top: list[ReferralTopEntryResponse] = []
class ReferralListEntry(BaseModel):
referred_user_id: int
referred_tg_id: int | None = None
display_id: str = ""
reward_issued: bool = False
class ReferralListResponse(BaseModel):
total: int = 0
items: list[ReferralListEntry] = []
class ReferralQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class ReferralConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
class PartnerConditionsResponse(BaseModel):
title: str = ""
summary: str = ""
bonus_mode: str = ""
bonus_mode_label: str = ""
level_lines: list[str] = []
rules: list[str] = []
examples: list[str] = []
min_payout_rub: float = 0.0
payout_methods: list[str] = []
custom_amount_enabled: bool = False
class PartnerQrResponse(BaseModel):
ok: bool = True
link: str = ""
image_data_url: str = ""
class PartnerApplyRequest(BaseModel):
partner_code: str | None = Field(None, min_length=1)
partner_tg_id: int | None = Field(None, ge=1)
class PartnerApplyResponse(BaseModel):
ok: bool = True
message: str = ""
partner_code: str = ""
partner_user_id: int = 0
partner_tg_id: int | None = None
joined_user_id: int = 0
joined_tg_id: int | None = None
class PartnerTopEntryResponse(BaseModel):
position: int
partner_user_id: int
referred_count: int
display_id: str
class PartnerTopResponse(BaseModel):
user_referred_count: int = 0
user_position: int | None = None
top: list[PartnerTopEntryResponse] = []
class PartnerInvitedEntry(BaseModel):
tg_id: int
joined_at: str | None = None
balance: float = 0.0
keys_count: int = 0
payments_count: int = 0
class PartnerInvitedResponse(BaseModel):
total: int = 0
items: list[PartnerInvitedEntry] = []
class CouponApplyRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=128)
class CouponApplyResponse(BaseModel):
ok: bool = True
message: str = ""
coupon_code: str = ""
amount: int = 0
balance: float = 0.0
class PartnerPayoutRequestCreate(BaseModel):
amount_rub: float = Field(..., gt=0)
class PartnerPayoutRequestResponse(BaseModel):
ok: bool = True
message: str = ""
request_id: int | None = None
amount_rub: float = 0.0
status: str = "pending"
balance_rub: float = 0.0
class PartnerPayoutEntryResponse(BaseModel):
id: int
amount_rub: float = 0.0
status: str = ""
created_at: str | None = None
method: str | None = None
destination: str | None = None
class PartnerPayoutHistoryResponse(BaseModel):
total: int = 0
items: list[PartnerPayoutEntryResponse] = []
+1156
View File
File diff suppressed because it is too large Load Diff
+450
View File
@@ -0,0 +1,450 @@
from __future__ import annotations
from collections.abc import Iterable
def _get_bot_webhook_path() -> str:
"""Путь вебхука бота из конфига (для исключения из шага «успешная оплата»)."""
try:
from config import WEBHOOK_PATH
return ((WEBHOOK_PATH or "").strip().lower()) or ""
except ImportError:
return ""
def _is_bot_webhook_path(path: str) -> bool:
"""True только если path — именно вебхук бота (точное совпадение сегмента пути), не касса."""
bot_path = _get_bot_webhook_path()
if not bot_path:
return False
p = (path or "").strip().lower()
path_segment = p.split(" ", 1)[1] if " " in p else p
return path_segment == bot_path or path_segment.rstrip("/") == bot_path.rstrip("/")
AUDIT_STEP_LABELS: dict[str, str] = {
"start": "Старт",
"start_coupon": "Старт: купон",
"start_gift": "Старт: подарок",
"start_referral": "Старт: рефералка",
"start_utm": "Старт: UTM",
"profile": "Профиль",
"about": "О VPN",
"instructions": "Инструкции",
"balance": "Баланс / история оплат",
"view_keys": "Мои ключи",
"buy_entry": "Оформление: вход",
"tariff_config": "Оформление: выбор тарифа/конфига",
"key_create": "Подписка оформлена (ключ создан)",
"pay_start": "Оплата: вход / создание ссылки",
"pay": "Успешная оплата (пополнение)",
"key_view": "Ключ (карточка)",
"connect": "Подключение: экран / инструкции / QR",
"key_manage": "Управление подпиской",
"renew": "Продление",
"addons": "Аддоны",
"referral": "Рефералы",
"coupons": "Купоны",
"register": "Регистрация (API)",
"login": "Вход (API)",
"api_other": "API прочее",
"admin": "Админ-панель",
"other": "Прочее",
}
DEFAULT_FUNNEL_STEPS = (
"start",
"profile",
"view_keys",
"buy_entry",
"tariff_config",
"key_create",
"pay_start",
"pay",
"key_view",
"connect",
)
_CALLBACK_EXACT: dict[str, str] = {
"start": "start",
"profile": "profile",
"about_vpn": "about",
"instructions": "instructions",
"view_keys": "view_keys",
"create_key": "buy_entry",
"buy": "buy_entry",
"pay": "pay_start",
"balance": "balance",
"balance_history": "balance",
"activate_coupon": "coupons",
"cancel_coupon_activation": "coupons",
"exit_coupon_input": "coupons",
"invite": "referral",
"top_referrals": "referral",
"check_subscription": "start",
"back_to_tariff_group_list": "tariff_config",
"back_to_subgroup_tariffs": "tariff_config",
"cancel_and_back_to_view_keys": "view_keys",
"fastflow_coupon": "coupons",
"fastflow_coupon_back": "coupons",
"fastflow_back": "pay_start",
"pay_kassai": "pay_start",
"pay_kassai_cards": "pay_start",
"pay_kassai_sbp": "pay_start",
"pay_heleket_crypto": "pay_start",
"pay_freekassa": "pay_start",
"pay_robokassa": "pay_start",
}
_CALLBACK_PREFIX: list[tuple[str, str]] = [
("view_key|", "key_view"),
("view_keys|", "view_keys"),
("show_referral_qr|", "referral"),
("tariff_subgroup_user|", "tariff_config"),
("select_tariff_plan|", "tariff_config"),
("cfg_user_devices|", "tariff_config"),
("cfg_user_traffic|", "tariff_config"),
("rename_key|", "key_manage"),
("reset_hwid|", "key_manage"),
("delete_key|", "key_manage"),
("confirm_delete|", "key_manage"),
("update_subscription|", "key_manage"),
("change_location|", "key_manage"),
("select_country|", "key_manage"),
("connect_device|", "connect"),
("connect_router|", "connect"),
("connect_tv|", "connect"),
("connect_pc|", "connect"),
("connect_ios|", "connect"),
("connect_android|", "connect"),
("show_qr|", "connect"),
("continue_tv|", "connect"),
("pay_currency|", "pay_start"),
("choose_payment_currency|", "pay_start"),
("cfg_user_confirm|", "key_create"),
("choose_payment_provider|", "pay_start"),
("kassai_method|", "pay_start"),
("kassai_cards_amount|", "pay_start"),
("kassai_sbp_amount|", "pay_start"),
("kassai_custom_amount|", "pay_start"),
("heleket_method|", "pay_start"),
("heleket_crypto_amount|", "pay_start"),
("heleket_custom_amount|", "pay_start"),
("freekassa_amount|", "pay_start"),
("robokassa_", "pay_start"),
("cfg_renew", "renew"),
("key_addons", "addons"),
("extend_key", "coupons"),
]
_CALLBACK_CONTAINS: list[tuple[str, str]] = [
("connect_", "connect"),
("renew", "renew"),
("addon", "addons"),
("referral", "referral"),
("invite", "referral"),
("coupon", "coupons"),
("users_audit", "admin"),
("users_editor", "admin"),
("search_user", "admin"),
("admin_panel", "admin"),
]
_MESSAGE_STEP_BY_COMMAND: dict[str, str] = {
"/start": "start",
"start": "start",
"/buy": "buy_entry",
"buy": "buy_entry",
"/subs": "view_keys",
"subs": "view_keys",
"/profile": "profile",
"profile": "profile",
"/invite": "referral",
"invite": "referral",
"/instructions": "instructions",
"instructions": "instructions",
"/activate_coupon": "coupons",
"activate_coupon": "coupons",
}
_START_PAYLOAD_MAX_LEN = 256
_START_PAYLOAD_MAX_PARTS = 20
_IGNORED_CALLBACK_EXACT: set[str] = {
" ",
"back",
"cancel",
"back_to_pay",
"back_to_currency",
"back_to_tariff_group_list",
"back_to_subgroup_tariffs",
"cancel_and_back_to_view_keys",
"cancel_coupon_activation",
"exit_coupon_input",
"fastflow_back",
"fastflow_coupon_back",
"cfg_back_menu",
"cfg_cancel_input",
"cancel_broadcast",
}
_IGNORED_CALLBACK_PREFIX: tuple[str, ...] = (
"back:",
"back_to_",
"cancel_",
"gifts_page|",
)
_IGNORED_CALLBACK_EXACT_RULES: dict[str, str] = dict.fromkeys(_IGNORED_CALLBACK_EXACT, "ignore")
_IGNORED_CALLBACK_PREFIX_RULES: tuple[tuple[str, str], ...] = tuple(
(prefix, "ignore") for prefix in _IGNORED_CALLBACK_PREFIX
)
_HANDLER_CONTAINS: list[tuple[str, str] | tuple[str, str, str]] = [
("process_start", "start"),
("start_entry", "start"),
("show_start_menu", "start"),
("process_callback_view_profile", "profile"),
("handle_about_vpn", "about"),
("send_instructions", "instructions"),
("process_callback_or_message_view_keys", "view_keys"),
("key_view", "key_view", "key_create"),
("handle_user_config_confirm", "key_create"),
("finalize_config_and_purchase", "key_create"),
("proceed_purchase_with_values", "tariff_config"),
("key_create", "buy_entry"),
("handle_key_creation", "buy_entry"),
("complete_key_renewal", "renew"),
("handle_connect_device", "connect"),
("process_connect_", "connect"),
("process_callback_connect", "connect"),
("process_continue_tv", "connect"),
("show_qr_code", "connect"),
("balance_history", "balance"),
("balance_handler", "balance"),
("pay", "pay_start"),
("choose_payment_provider", "pay_start"),
("kassai_", "pay_start"),
("heleket_", "pay_start"),
("freekassa_", "pay_start"),
("robokassa_", "pay_start"),
("rename_key", "key_manage"),
("reset_hwid", "key_manage"),
("delete_key", "key_manage"),
("change_location", "key_manage"),
("select_country", "key_manage"),
("renew", "renew"),
("addon", "addons"),
("referral", "referral"),
("refferal", "referral"),
("coupon", "coupons"),
("admin_panel", "admin"),
("users_audit", "admin"),
("users_editor", "admin"),
("search_user", "admin"),
("auth/register", "register"),
("auth/login", "login"),
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
]
_API_CONTAINS: list[tuple[str, str]] = [
("auth/register", "register"),
("auth/login", "login"),
("auth/send-login", "login"),
("auth/login-by-code", "login"),
("auth/login-telegram", "login"),
("auth/set-password", "login"),
("auth/change-password", "login"),
("auth/request-password-reset", "login"),
("auth/confirm-password-reset", "login"),
("auth/summary", "login"),
("site-config", "api_other"),
("tariffs/purchase", "pay_start"),
("tariffs/config-price", "tariff_config"),
("gifts/redeem", "key_create"),
("referrals/apply", "referral"),
("/keys/create", "key_create"),
]
def _match_step_rules(
value: str,
*,
exact: dict[str, str] | None = None,
prefixes: Iterable[tuple[str, str]] | None = None,
contains: Iterable[tuple[str, str] | tuple[str, str, str]] | None = None,
) -> str | None:
"""Универсальный matcher шага по exact/prefix/contains правилам."""
normalized = (value or "").lower().strip()
if not normalized:
return None
if exact:
step = exact.get(normalized)
if step:
return step
if prefixes:
for prefix, step in prefixes:
if normalized.startswith(prefix):
return step
if contains:
for item in contains:
substr, step = item[0], item[1]
exclude = item[2] if len(item) > 2 else None
if substr in normalized and (exclude is None or exclude not in normalized):
return step
return None
def _is_ignored_analytics_event(path: str) -> bool:
"""Исключает чисто навигационные события из аналитики шагов и воронки."""
p = (path or "").lower().strip()
if not p.startswith("callback:"):
return False
callback_data = p.split(":", 1)[-1]
return (
_match_step_rules(
callback_data,
exact=_IGNORED_CALLBACK_EXACT_RULES,
prefixes=_IGNORED_CALLBACK_PREFIX_RULES,
)
is not None
)
def _message_command_step(path: str) -> str | None:
"""Определяет шаг только по точной команде/первому токену сообщения."""
steps = _message_command_steps(path)
return steps[0] if steps else None
def _message_command_steps(path: str) -> list[str]:
"""Определяет один или несколько шагов из текстового сообщения."""
p = (path or "").strip()
if not p.lower().startswith("message:"):
return []
text = (p.split(":", 1)[-1] or "").strip().lower()
if not text:
return []
parts = text.split(None, 1)
token = parts[0]
if token.startswith("/"):
token = token.split("@", 1)[0]
if token in ("/start", "start"):
steps = ["start"]
payload = parts[1].strip() if len(parts) > 1 else ""
if payload:
if len(payload) > _START_PAYLOAD_MAX_LEN:
payload = payload[:_START_PAYLOAD_MAX_LEN]
payload_parts = payload.split("-")
if len(payload_parts) > _START_PAYLOAD_MAX_PARTS:
payload_parts = payload_parts[:_START_PAYLOAD_MAX_PARTS]
for part in payload_parts:
part = part.strip()
if not part:
continue
if "coupons" in part:
steps.append("start_coupon")
continue
if "gift" in part:
steps.append("start_gift")
continue
if "referral" in part:
steps.append("start_referral")
continue
if "utm" in part:
steps.append("start_utm")
continue
normalized_payload = "-".join(payload_parts).strip().lower()
if normalized_payload in _MESSAGE_STEP_BY_COMMAND:
steps.append(_MESSAGE_STEP_BY_COMMAND[normalized_payload])
return list(dict.fromkeys(steps))
return steps
step = _MESSAGE_STEP_BY_COMMAND.get(token)
return [step] if step else []
def _callback_step(path: str) -> str | None:
callback_data = path.split(":", 1)[-1]
callback_key = callback_data.split("|")[0]
return _match_step_rules(
callback_key,
exact=_CALLBACK_EXACT,
) or _match_step_rules(
callback_data,
prefixes=_CALLBACK_PREFIX,
contains=_CALLBACK_CONTAINS,
)
def _api_step(path: str) -> str:
step = _match_step_rules(path, contains=_API_CONTAINS)
if step:
return step
if "payment-links" in path or ("payment" in path and "webhook" not in path):
return "pay_start"
return "api_other"
def _handler_step(path: str) -> str | None:
return _match_step_rules(path, contains=_HANDLER_CONTAINS)
def _funnel_step_counts(path: str, result: str, step: str) -> bool:
"""Решает, считать ли событие достижением шага воронки."""
if result != "success":
return False
p = (path or "").lower()
if step == "pay_start":
return True
if step == "pay":
return p.startswith("payment_success:")
if step == "key_create":
return "cfg_user_confirm" in p or "/keys/create" in p
return True
def _normalize_path_to_step(path: str) -> str:
"""Сводит path_or_handler к шагу по правилам из маппингов."""
steps = _normalize_path_to_steps(path)
return steps[0] if steps else "other"
def _normalize_path_to_steps(path: str) -> list[str]:
"""Сводит path_or_handler к одному или нескольким шагам по правилам аудита."""
if not path:
return ["other"]
p = path.lower().strip()
if p.startswith("payment_success:"):
return ["pay"]
if p.startswith("callback:"):
return [_callback_step(p) or "other"]
message_steps = _message_command_steps(path)
if message_steps:
return message_steps
if p.startswith("message:"):
return ["other"]
if p.startswith(("post ", "get ")):
return [_api_step(p)]
return [_handler_step(p) or "other"]
-94
View File
@@ -1,94 +0,0 @@
from datetime import datetime
import os
import subprocess
from typing import Union
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: Union[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}")
+56 -63
View File
@@ -1,76 +1,69 @@
import traceback from importlib import import_module
from aiogram import Bot, Dispatcher, Router
version = "0.5.3"
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import ErrorEvent
from config import API_TOKEN, CRYPTO_BOT_ENABLE, FREEKASSA_ENABLE, ROBOKASSA_ENABLE, STARS_ENABLE, YOOKASSA_ENABLE from config import API_TOKEN, REDIS_URL
from logger import logger from database import async_session_maker
from middlewares.admin import AdminMiddleware from filters.private import IsPrivateFilter
from middlewares.database import DatabaseMiddleware from utils.button_icons import apply_button_icons_patch, set_button_icon_config
from middlewares.delete import DeleteMessageMiddleware from utils.custom_emojis import initialize_custom_emojis
from middlewares.logging import LoggingMiddleware from utils.errors import setup_error_handlers
from middlewares.user import UserMiddleware from utils.modules_loader import load_modules_from_folder, modules_hub
apply_button_icons_patch()
bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
storage = MemoryStorage()
RedisStorage = import_module("aiogram.fsm.storage.redis").RedisStorage
redis_from_url = import_module("redis.asyncio").from_url
redis = redis_from_url(
REDIS_URL,
encoding="utf-8",
decode_responses=True,
max_connections=64,
health_check_interval=30,
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True,
)
storage = RedisStorage(redis=redis)
dp = Dispatcher(bot=bot, storage=storage) dp = Dispatcher(bot=bot, storage=storage)
router = Router()
from handlers import coupons, donate, notifications, pay, profile, start dp.include_router(modules_hub)
from handlers.admin import admin_coupons, admin_panel, admin_servers, admin_user_editor
from handlers.instructions import instructions
from handlers.keys import key_management, keys
from handlers.payments import cryprobot_pay, freekassa_pay, robokassa_pay, stars_pay, yookassa_pay
dp.include_router(admin_coupons.router) load_modules_from_folder()
dp.include_router(admin_panel.router)
dp.include_router(admin_user_editor.router)
dp.include_router(admin_servers.router)
dp.include_router(coupons.router)
dp.include_router(start.router)
dp.include_router(profile.router)
dp.include_router(keys.router)
dp.include_router(key_management.router)
dp.include_router(pay.router)
dp.include_router(notifications.router)
dp.include_router(instructions.router)
dp.include_router(donate.router)
if YOOKASSA_ENABLE:
dp.include_router(yookassa_pay.router)
if FREEKASSA_ENABLE:
dp.include_router(freekassa_pay.router)
if CRYPTO_BOT_ENABLE:
dp.include_router(cryprobot_pay.router)
if STARS_ENABLE:
dp.include_router(stars_pay.router)
if ROBOKASSA_ENABLE:
dp.include_router(robokassa_pay.router)
dp.message.middleware(LoggingMiddleware()) from handlers.buttons import BUTTON_ICON_CONFIG
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AdminMiddleware())
dp.callback_query.middleware(AdminMiddleware())
dp.message.middleware(UserMiddleware())
dp.callback_query.middleware(UserMiddleware())
dp.message.middleware(DatabaseMiddleware())
dp.callback_query.middleware(DatabaseMiddleware())
dp.message.outer_middleware(DeleteMessageMiddleware())
dp.callback_query.outer_middleware(DeleteMessageMiddleware())
@dp.error() set_button_icon_config(BUTTON_ICON_CONFIG)
async def error_handler(event: ErrorEvent):
logger.error( dp.message.filter(IsPrivateFilter())
"Ошибка в боте:\n" dp.callback_query.filter(IsPrivateFilter())
f"Исключение: {event.exception}\n"
f"Тип: {type(event.exception)}\n"
f"Update: {event.update}\n" async def _on_dispatcher_startup(*_args, **_kwargs):
f"Трассировка:\n{traceback.format_exc()}" from core.tasks import ensure_periodic_task_manager_started
)
await ensure_periodic_task_manager_started(bot, async_session_maker)
async def _on_dispatcher_shutdown(*_args, **_kwargs):
from core.tasks import ensure_periodic_task_manager_stopped
await ensure_periodic_task_manager_stopped()
dp.startup.register(_on_dispatcher_startup)
dp.shutdown.register(_on_dispatcher_shutdown)
setup_error_handlers(dp)
initialize_custom_emojis()
+2820
View File
File diff suppressed because it is too large Load Diff
-108
View File
@@ -1,108 +0,0 @@
import py3xui
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 = 1
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
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

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