Compare commits

..

421 Commits

Author SHA1 Message Date
Egor f193b0bfcf Update Python version argument to v2.9.1 2025-12-12 06:29:18 +03:00
Egor b961529001 Update version to v2.9.1 in docker-registry.yml 2025-12-12 06:29:09 +03:00
Egor afa172f3cc Update docker-hub.yml 2025-12-12 06:28:52 +03:00
Egor 8be1d7e423 Delete app/handlers/.DS_Store 2025-12-12 06:10:15 +03:00
Egor 8c4db0a0e6 Delete app/.DS_Store 2025-12-12 06:09:46 +03:00
Egor a8da98c35e Update docker-compose.yml 2025-12-12 06:07:54 +03:00
Egor 7ca6942443 Rename vpn_logo (1).png to vpn_logo.png 2025-12-12 06:07:06 +03:00
Egor 3f97d22a76 Add files via upload 2025-12-12 06:06:42 +03:00
Egor e3dccf2c1b Merge pull request #2144 from Gy9vin/main
Обновки
2025-12-12 06:04:37 +03:00
Egor d0e25fff3d Merge pull request #2145 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:03:41 +03:00
Egor 3699d248b1 Add enrich_happ_links parameter to get_all_users 2025-12-12 05:59:58 +03:00
Egor bffbf3ea39 Update remnawave_api.py 2025-12-12 05:59:01 +03:00
gy9vin e869c9b167 Обновление env 2025-12-11 23:21:12 +03:00
gy9vin 0b2b109e86 Минификсы 2025-12-11 23:11:23 +03:00
gy9vin 5dd586e0b2 Новый фильтр Готовы к продлению 2025-12-11 22:42:37 +03:00
gy9vin c9de084efa Фикс корзины 2025-12-11 22:25:42 +03:00
gy9vin 81b3c7ed3f черный список + фиксы 2025-12-11 10:56:14 +03:00
gy9vin 80785f22b0 Черный список, мониторинг суточно графика по регламенту 2025-12-10 19:13:52 +03:00
Egor b1cb368bf6 Merge pull request #2139 from BEDOLAGA-DEV/main
w
2025-12-10 02:21:37 +03:00
Egor 9fc977ace3 Merge pull request #2132 from remnawave-contrib/ivan-nginx-platega-min
Reduce minimum amount for Platega transactions from 10000 to 100 kope…
2025-12-10 02:16:49 +03:00
Egor 47acd37449 Merge pull request #2133 from remnawave-contrib/ivan-nginx-health-check
fix: healthcheck command to include API key in requests
2025-12-10 02:16:08 +03:00
Egor d7fa956f58 Merge pull request #2138 from BEDOLAGA-DEV/dev5
Dev5
2025-12-10 02:14:54 +03:00
Egor 98164c2083 Merge pull request #2137 from BEDOLAGA-DEV/g0wcwm-bedolaga/fix-api-issue-with-faq-status-editing
Fix FAQ status route matching
2025-12-10 02:10:00 +03:00
Egor 81f5ce429e Fix FAQ status route matching 2025-12-10 02:09:46 +03:00
Egor 5a3bded4a8 Merge pull request #2136 from BEDOLAGA-DEV/revert-2134-dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Revert "Handle FAQ status update without request body"
2025-12-10 02:09:00 +03:00
Egor 2743a845bd Revert "Handle FAQ status update without request body" 2025-12-10 02:08:51 +03:00
Egor 7c222c0aed Merge pull request #2135 from BEDOLAGA-DEV/dev5
Dev5
2025-12-09 23:49:34 +03:00
Egor 3222c82b46 Merge pull request #2134 from BEDOLAGA-DEV/dfg5t2-bedolaga/fix-api-issue-with-faq-status-editing
Handle FAQ status update without request body
2025-12-09 23:49:16 +03:00
Egor 9f0f3a367c Handle FAQ status update without request body 2025-12-09 23:48:54 +03:00
Ivan.Nginx f702356f48 fix: healthcheck command to include API key in requests 2025-12-09 03:14:24 +03:00
Ivan.Nginx 48d799e312 Reduce minimum amount for Platega transactions from 10000 to 100 kopeks (from 100 to 1 rub) 2025-12-09 00:59:29 +03:00
Egor 7c05b36b9d Update Python version to 2.9.0 in Dockerfile 2025-12-08 04:35:25 +03:00
Egor b398eecbeb Update versioning scheme to v2.9.0 2025-12-08 04:35:13 +03:00
Egor 5ab80b09a1 Update docker-hub.yml 2025-12-08 04:34:58 +03:00
Egor 535b92d43b Merge pull request #2129 from BEDOLAGA-DEV/dev5
Обновление Api Remnawave под версию 2.3.0 + Возможность установить таги для триалов и платных подписок + Правки Web Api
2025-12-08 04:28:00 +03:00
Egor cbaddd65e0 Merge pull request #2128 from BEDOLAGA-DEV/revert-2127-xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Revert "Support user-specific internal squads"
2025-12-08 04:24:04 +03:00
Egor 136cae68f1 Revert "Support user-specific internal squads" 2025-12-08 04:23:57 +03:00
Egor 38abf49af9 Merge pull request #2127 from BEDOLAGA-DEV/xs3s5d-bedolaga/add-ability-to-assign-internal-squads
Support user-specific internal squads
2025-12-08 04:20:37 +03:00
Egor 799243a988 Support user-specific internal squads 2025-12-08 04:20:21 +03:00
Egor 6d024716c7 Merge pull request #2126 from BEDOLAGA-DEV/revert-2125-8z51jt-bedolaga/add-support-for-assigning-internal-squads
Revert "Add internal squad management for users and trials"
2025-12-08 04:17:14 +03:00
Egor 0892f494d9 Revert "Add internal squad management for users and trials" 2025-12-08 04:17:05 +03:00
Egor 42b67bdc47 Merge pull request #2125 from BEDOLAGA-DEV/8z51jt-bedolaga/add-support-for-assigning-internal-squads
Add internal squad management for users and trials
2025-12-08 04:15:35 +03:00
Egor 6c41263511 Add internal squad management for users and trials 2025-12-08 04:15:18 +03:00
Egor 1aab9550ce Merge pull request #2124 from BEDOLAGA-DEV/42kgo8-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad creation success flag
2025-12-08 04:00:58 +03:00
Egor d596b19d96 Fix RemnaWave squad creation success flag 2025-12-08 04:00:42 +03:00
Egor 0863e3023d Merge pull request #2123 from BEDOLAGA-DEV/revert-2122-mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Revert "Fix RemnaWave squad inbound serialization"
2025-12-08 04:00:31 +03:00
Egor 1ef2e1264f Revert "Fix RemnaWave squad inbound serialization" 2025-12-08 04:00:22 +03:00
Egor c40806a5a3 Merge pull request #2122 from BEDOLAGA-DEV/mbdf7a-bedolaga/fix-squads-functionality-in-bot-api
Fix RemnaWave squad inbound serialization
2025-12-08 03:52:53 +03:00
Egor 841c288313 Fix serialization of squad inbounds 2025-12-08 03:52:38 +03:00
Egor cd1a4a4a6e Merge pull request #2121 from BEDOLAGA-DEV/0w5so9-bedolaga/fix-critical-error-in-show_node_details
Bind refreshed callbacks to bot
2025-12-08 03:34:10 +03:00
Egor e9e61f4892 Bind refreshed callbacks to bot 2025-12-08 03:33:56 +03:00
Egor 167cddcd2f Merge pull request #2120 from BEDOLAGA-DEV/revert-2119-hcism8-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix node details refresh callback usage"
2025-12-08 03:33:07 +03:00
Egor 5b4c597e9b Revert "Fix node details refresh callback usage" 2025-12-08 03:32:58 +03:00
Egor 0cb1c9b580 Merge pull request #2119 from BEDOLAGA-DEV/hcism8-bedolaga/fix-critical-error-in-show_node_details
Fix node details refresh callback usage
2025-12-08 03:26:46 +03:00
Egor ff4471a22a Fix node details refresh callback usage 2025-12-08 03:26:32 +03:00
Egor 93d7bd74a3 Merge pull request #2118 from BEDOLAGA-DEV/revert-2117-sifkp4-bedolaga/fix-critical-error-in-show_node_details
Revert "Fix callback reuse after node actions"
2025-12-08 03:26:22 +03:00
Egor c770948c1f Revert "Fix callback reuse after node actions" 2025-12-08 03:26:14 +03:00
Egor 2e1a6faec1 Merge pull request #2117 from BEDOLAGA-DEV/sifkp4-bedolaga/fix-critical-error-in-show_node_details
Fix callback reuse after node actions
2025-12-08 03:24:26 +03:00
Egor 0031c9e2e0 Fix callback reuse after node actions 2025-12-08 03:24:13 +03:00
Egor 637d2a07e0 Merge pull request #2116 from BEDOLAGA-DEV/udkyiq-bedolaga/expand-remnawave-node-statistics
Expand Remnawave node statistics
2025-12-08 03:19:36 +03:00
Egor 988ffbebdb Expand Remnawave node statistics 2025-12-08 03:19:23 +03:00
Egor 5a795a2ae2 Update remnawave_api.py 2025-12-08 03:13:16 +03:00
Egor 9b6cd74dbf Update remnawave_api.py 2025-12-08 03:01:21 +03:00
Egor daa2f13ca3 Merge pull request #2114 from BEDOLAGA-DEV/qhbf7v-bedolaga/add-custom-tag-feature-for-trial-users
Add configurable RemnaWave user tags for trial and paid subscriptions
2025-12-08 02:46:32 +03:00
Egor 386b9ae998 Add configurable user tags for trial and paid subscriptions 2025-12-08 02:44:53 +03:00
Egor e64854dc48 Update remnawave_api.py 2025-12-08 01:42:03 +03:00
Egor 23eed94009 Add functions to extract traffic bytes from user data 2025-12-08 01:32:23 +03:00
Egor f04ffa58e4 Update remnawave_api.py 2025-12-08 01:31:42 +03:00
Egor fc45db6f3c Merge pull request #2112 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 17:31:03 +03:00
Egor b8874772be Merge pull request #2111 from BEDOLAGA-DEV/8ioop5-bedolaga/add-api-endpoint-for-statistics
Add full statistics API endpoint
2025-12-06 16:14:52 +03:00
Egor d8c7793a26 Merge pull request #2110 from BEDOLAGA-DEV/dev5
Dev5
2025-12-06 15:34:35 +03:00
Egor 10d08d5b40 Add full statistics API endpoint 2025-12-06 15:32:01 +03:00
Egor b07a889826 Merge pull request #2109 from BEDOLAGA-DEV/ffowe2-bedolaga/-api
Add promo offer broadcast endpoint
2025-12-06 15:28:12 +03:00
Egor a24b4c72e9 Add promo offer broadcast endpoint 2025-12-06 15:21:54 +03:00
Egor 5e1d00ad0d Merge pull request #2108 from belousotroll/fix-calculating-tg-starts-amount
Fix calculating tg starts amount
2025-12-06 06:24:52 +03:00
belousotroll d33ebb1453 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-12-05 21:54:46 +07:00
Egor 7a2bcf3d2f Merge pull request #2107 from BEDOLAGA-DEV/dev5
Dev5
2025-12-05 10:22:44 +03:00
Egor dbb68582e4 Merge pull request #2106 from BEDOLAGA-DEV/gpower-bedolaga/add-telegram-id-support-to-promo-offers-api
Add Telegram ID support to promo offers API
2025-12-05 10:18:34 +03:00
Egor 479b9bc384 Add telegram id support for promo offers 2025-12-05 09:56:03 +03:00
Egor 73f3987481 Merge pull request #2105 from BEDOLAGA-DEV/dev5
Dev5
2025-12-03 07:30:06 +03:00
Egor 8b38f1e37d Merge pull request #2104 from BEDOLAGA-DEV/axy4i1-bedolaga/fix-telegram-api-error-in-show_balance_menu
Handle balance menu for YooKassa SBP QR and add Heleket localization keys
2025-12-03 07:29:43 +03:00
Egor 6976129972 Handle balance menu edits for SBP invoices and add Heleket locales 2025-12-03 07:29:01 +03:00
belousotroll 207e4673c0 Merge branch 'BEDOLAGA-DEV:main' into fix-calculating-tg-starts-amount 2025-11-30 20:30:14 +07:00
belousotroll 160ff7ff9f fix: calculating telegram starts amount 2025-11-30 19:29:29 +07:00
Egor 6c7c57138d Merge pull request #2099 from BEDOLAGA-DEV/dev5
Dev5
2025-11-29 09:52:18 +03:00
Egor 12b5a39194 Update subscription_purchase_service.py 2025-11-29 09:50:35 +03:00
Egor 7678150e6a Update subscription_auto_purchase_service.py 2025-11-29 09:50:05 +03:00
Egor 99e35cae4c Merge pull request #2098 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 23:02:44 +03:00
Egor af313a12ed Add files via upload 2025-11-28 23:00:08 +03:00
Egor 71d18287fe Add files via upload 2025-11-28 22:59:27 +03:00
Egor 6b24b69b53 Merge pull request #2097 from BEDOLAGA-DEV/dev5
Update menu.py
2025-11-28 21:28:54 +03:00
Egor 9528457b89 Update menu.py 2025-11-28 21:27:11 +03:00
Egor 69322640c5 Merge pull request #2096 from BEDOLAGA-DEV/dev5
Update purchase.py
2025-11-28 21:17:51 +03:00
Egor 3c0703b599 Update purchase.py 2025-11-28 21:13:49 +03:00
Egor d14aa5bd8a Update README.md 2025-11-28 06:42:43 +03:00
Egor baa5da243e Merge pull request #2095 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 06:24:37 +03:00
Egor fff01d1ce3 Update validators.py 2025-11-28 06:23:55 +03:00
Egor 52c8442423 Merge pull request #2094 from BEDOLAGA-DEV/revert-2093-acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Remove blockquote markup to prevent Telegram parse errors"
2025-11-28 06:02:16 +03:00
Egor 150f9e741a Revert "Remove blockquote markup to prevent Telegram parse errors" 2025-11-28 06:02:08 +03:00
Egor 4ae2234b7f Merge pull request #2093 from BEDOLAGA-DEV/acgzuy-bedolaga/fix-unsupported-html-tag-parsing-error
Remove blockquote markup to prevent Telegram parse errors
2025-11-28 06:00:48 +03:00
Egor 71366a8133 Remove blockquote markup to prevent Telegram parse errors 2025-11-28 06:00:32 +03:00
Egor fb6cda2c63 Merge pull request #2092 from BEDOLAGA-DEV/revert-2091-ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Harden message send fallback for invalid HTML"
2025-11-28 06:00:25 +03:00
Egor 2d4f9da9a7 Revert "Harden message send fallback for invalid HTML" 2025-11-28 06:00:16 +03:00
Egor 7a39abe05c Merge pull request #2091 from BEDOLAGA-DEV/ndvrn0-bedolaga/fix-unsupported-html-tag-parsing-error
Harden message send fallback for invalid HTML
2025-11-28 05:51:20 +03:00
Egor 79659ec5fe Harden message send fallback for invalid HTML 2025-11-28 05:51:05 +03:00
Egor a3457d5853 Merge pull request #2090 from BEDOLAGA-DEV/revert-2089-pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Improve fallback on Telegram parse errors"
2025-11-28 05:50:59 +03:00
Egor 0392aa5b45 Revert "Improve fallback on Telegram parse errors" 2025-11-28 05:50:50 +03:00
Egor 2c68d9ded9 Merge pull request #2089 from BEDOLAGA-DEV/pmhwx5-bedolaga/fix-unsupported-html-tag-parsing-error
Improve fallback on Telegram parse errors
2025-11-28 05:49:24 +03:00
Egor a437ebd65b Improve fallback on Telegram parse errors 2025-11-28 05:49:04 +03:00
Egor e2323755de Merge pull request #2088 from BEDOLAGA-DEV/revert-2087-lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Handle Telegram parse errors gracefully"
2025-11-28 05:48:57 +03:00
Egor 5e23920723 Revert "Handle Telegram parse errors gracefully" 2025-11-28 05:48:48 +03:00
Egor 5e45fc1285 Merge pull request #2087 from BEDOLAGA-DEV/lyaqkf-bedolaga/fix-unsupported-html-tag-parsing-error
Handle Telegram parse errors gracefully
2025-11-28 05:43:41 +03:00
Egor ff1556ffcd Handle parse errors with plain text fallback 2025-11-28 05:43:23 +03:00
Egor 549e8fa332 Merge pull request #2086 from BEDOLAGA-DEV/revert-2085-aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Revert "Ensure photo replies default to HTML parse mode"
2025-11-28 05:43:15 +03:00
Egor 8a7fb598fa Revert "Ensure photo replies default to HTML parse mode" 2025-11-28 05:43:06 +03:00
Egor 521c364fbb Merge pull request #2085 from BEDOLAGA-DEV/aoflna-bedolaga/fix-unsupported-html-tag-parsing-error
Ensure photo replies default to HTML parse mode
2025-11-28 05:41:40 +03:00
Egor db60f5c5ba Ensure photo replies default to HTML parse mode 2025-11-28 05:41:24 +03:00
Egor affc07985e Merge pull request #2084 from BEDOLAGA-DEV/4drat9-bedolaga/fix-empty-page-on-/redoc
Ensure ReDoc loads reliably
2025-11-28 04:00:35 +03:00
Egor 2761255e65 Ensure ReDoc loads reliably 2025-11-28 03:58:59 +03:00
Egor 53988c319b Update Dockerfile 2025-11-28 03:21:48 +03:00
Egor 777ea3b99b Update docker-registry.yml 2025-11-28 03:21:34 +03:00
Egor 672ce18f38 Update docker-hub.yml 2025-11-28 03:21:20 +03:00
Egor aafcec2716 Merge pull request #2083 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 03:03:03 +03:00
Egor 4e5b607288 Merge pull request #2082 from BEDOLAGA-DEV/ycxgll-bedolaga/restore-reverse-sync-button-in-remnawave
Recover missing RemnaWave users during panel sync
2025-11-28 03:00:24 +03:00
Egor aae85683a0 Recover missing RemnaWave users during panel sync 2025-11-28 03:00:09 +03:00
Egor 9971c43218 Merge pull request #2081 from BEDOLAGA-DEV/revert-2080-jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Handle RemnaWave status validation changes"
2025-11-28 02:59:49 +03:00
Egor b4370e9a34 Revert "Handle RemnaWave status validation changes" 2025-11-28 02:59:41 +03:00
Egor 101e27acd6 Merge pull request #2080 from BEDOLAGA-DEV/jq66gt-bedolaga/restore-reverse-sync-button-in-remnawave
Handle RemnaWave status validation changes
2025-11-28 02:53:30 +03:00
Egor 5b9002896e Handle RemnaWave status validation changes 2025-11-28 02:53:13 +03:00
Egor e8ac14b0b7 Merge pull request #2079 from BEDOLAGA-DEV/revert-2078-lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Align RemnaWave sync with new status and expiration rules"
2025-11-28 02:52:57 +03:00
Egor 7cd6c3acb7 Revert "Align RemnaWave sync with new status and expiration rules" 2025-11-28 02:52:47 +03:00
Egor 7e7795fc4e Merge pull request #2078 from BEDOLAGA-DEV/lw4oi2-bedolaga/restore-reverse-sync-button-in-remnawave
Align RemnaWave sync with new status and expiration rules
2025-11-28 02:49:23 +03:00
Egor 1f75413abd Handle RemnaWave API status and expire constraints 2025-11-28 02:48:49 +03:00
Egor 3c6d674ce5 Merge pull request #2077 from BEDOLAGA-DEV/revert-2076-7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Revert "Add reverse remnawave sync with batched upload"
2025-11-28 02:48:37 +03:00
Egor 92efc52f7e Revert "Add reverse remnawave sync with batched upload" 2025-11-28 02:48:28 +03:00
Egor c558f69d62 Merge pull request #2076 from BEDOLAGA-DEV/7uxban-bedolaga/restore-reverse-sync-button-in-remnawave
Add reverse remnawave sync with batched upload
2025-11-28 02:39:55 +03:00
Egor 168cb5ea38 Add reverse remnawave sync with batched upload 2025-11-28 02:34:50 +03:00
Egor 6a48cc687c Merge pull request #2075 from BEDOLAGA-DEV/r3w3lo-bedolaga/fix-bot-crash-due-to-html-tags
Avoid unescaping attribute entities in sanitizer
2025-11-28 02:30:54 +03:00
Egor 9a5b0553c6 Avoid unescaping attribute entities in sanitizer 2025-11-28 02:22:39 +03:00
Egor 7515964599 Merge pull request #2074 from BEDOLAGA-DEV/revert-2072-ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Improve HTML sanitization for menu messages"
2025-11-28 02:12:41 +03:00
Egor bf5e0ecd4f Revert "Improve HTML sanitization for menu messages" 2025-11-28 02:12:32 +03:00
Egor 6d058beade Merge pull request #2072 from BEDOLAGA-DEV/ywwhae-bedolaga/fix-bot-crash-due-to-html-tags
Improve HTML sanitization for menu messages
2025-11-28 01:35:35 +03:00
Egor 91f557e357 Improve HTML sanitization for menu messages 2025-11-28 01:31:53 +03:00
Egor 8e6c594a7b Merge pull request #2071 from BEDOLAGA-DEV/revert-2069-k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Revert "Validate HTML in user messages"
2025-11-28 01:31:47 +03:00
Egor 94cc04703b Revert "Validate HTML in user messages" 2025-11-28 01:31:39 +03:00
Egor 9ac621cebd Merge pull request #2069 from BEDOLAGA-DEV/k72cch-bedolaga/fix-bot-crash-due-to-html-tags
Validate HTML in user messages
2025-11-28 01:06:50 +03:00
Egor 5d9f6064d6 Validate user message HTML 2025-11-28 01:00:15 +03:00
Egor 010be14379 Merge pull request #2068 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:18:54 +03:00
Egor d864c80457 Merge pull request #2067 from BEDOLAGA-DEV/80cp2x-bedolaga/fix-cryptobot-notification-error
Handle missing greenlet when building topup keyboard
2025-11-28 00:11:37 +03:00
Egor ac04284d85 Handle lazy subscription access in checkout resume 2025-11-28 00:11:15 +03:00
Egor e1377ad7a0 Merge pull request #2066 from BEDOLAGA-DEV/dev5
Dev5
2025-11-28 00:03:34 +03:00
Egor 6c7bbba7ed Merge pull request #2065 from BEDOLAGA-DEV/78k0ik-bedolaga/filter-out-pricing-logs-from-bot
Reduce noisy price calculation logging
2025-11-27 23:57:00 +03:00
Egor 7a70a90d63 Reduce noisy price calculation logging 2025-11-27 23:55:54 +03:00
Egor 58187cecdb Merge pull request #2064 from BEDOLAGA-DEV/dev5
Dev5
2025-11-27 23:46:46 +03:00
Egor 649a358560 Merge pull request #2063 from BEDOLAGA-DEV/a812lu-bedolaga/fix-manual-status-check-error-in-payment-service
Refresh promo groups before platega finalize
2025-11-27 23:42:38 +03:00
Egor e0f1f221df Refresh promo groups before platega finalize 2025-11-27 23:38:16 +03:00
Egor 1342236b23 Merge pull request #2062 from BEDOLAGA-DEV/rq2kdr-bedolaga/fix-attributeerror-in-cryptobotpayment
Fix CryptoBot saved cart notifications
2025-11-27 23:33:02 +03:00
Egor 61681b393e Fix CryptoBot saved cart notifications 2025-11-27 23:28:53 +03:00
Egor 20a36fe869 Update README.md 2025-11-27 21:30:29 +03:00
Egor a4fe68bf2a Merge pull request #2061 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 10:09:29 +03:00
Egor 827cdd7805 Merge pull request #2060 from BEDOLAGA-DEV/9h699c-bedolaga/fix-telegram-flood-control-issue
Add retry when persisting broadcast results after DB disconnect
2025-11-25 10:02:44 +03:00
Egor 2479167073 Merge pull request #2059 from BEDOLAGA-DEV/y8nxh1-bedolaga/fix-payment-verification-service-error
Avoid lazy loading during Platega payment finalization
2025-11-25 10:00:13 +03:00
Egor 5cc8f7869f Add retry when persisting broadcast results after DB disconnect 2025-11-25 09:59:07 +03:00
Egor 330ace039b Avoid lazy loading during Platega payment finalization 2025-11-25 09:57:11 +03:00
Egor 36ad95dfef Merge pull request #2058 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 06:41:03 +03:00
Egor a7d564b7d1 Update README.md 2025-11-25 06:40:42 +03:00
Egor 4582daa1d1 Update docker-compose.local.yml 2025-11-25 06:38:14 +03:00
Egor c440274c32 Merge pull request #2057 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:59:59 +03:00
Egor a02b2f163b Merge pull request #2056 from BEDOLAGA-DEV/bedolaga-5s9q2y
Serve miniapp app-config and fix maintenance schema
2025-11-25 04:32:34 +03:00
Egor f5a6dbdeec Serve miniapp app-config and fix maintenance schema 2025-11-25 04:26:54 +03:00
Egor 282bf3aa11 Merge pull request #2055 from BEDOLAGA-DEV/revert-2053-bedolaga-1xcsyw
Revert "Block miniapp during maintenance mode"
2025-11-25 04:26:22 +03:00
Egor d302e02b0a Revert "Block miniapp during maintenance mode" 2025-11-25 04:26:12 +03:00
Egor da554736ba Merge pull request #2054 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 04:10:40 +03:00
Egor aefbc21033 Merge pull request #2053 from BEDOLAGA-DEV/bedolaga-1xcsyw
Block miniapp during maintenance mode
2025-11-25 04:03:15 +03:00
Egor add0fb4fbe Block miniapp during maintenance mode 2025-11-25 03:58:10 +03:00
Egor f1bec83a6f Merge pull request #2052 from BEDOLAGA-DEV/revert-2051-bedolaga-612jbk
Revert "Improve miniapp app-config loading fallback"
2025-11-25 03:58:02 +03:00
Egor a9e15a7cc1 Revert "Improve miniapp app-config loading fallback" 2025-11-25 03:57:52 +03:00
Egor 53c0f17200 Merge pull request #2051 from BEDOLAGA-DEV/bedolaga-612jbk
Improve miniapp app-config loading fallback
2025-11-25 03:46:46 +03:00
Egor 0c5a28d1c3 Improve miniapp app-config loading fallback 2025-11-25 03:46:23 +03:00
Egor cedb6b4cc4 Merge pull request #2050 from BEDOLAGA-DEV/revert-2048-bedolaga-myk79e
Revert "Show maintenance notice in miniapp"
2025-11-25 03:45:55 +03:00
Egor 5c7ec8c4a2 Revert "Show maintenance notice in miniapp" 2025-11-25 03:45:46 +03:00
Egor 3bb2a055e7 Merge pull request #2049 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 03:29:19 +03:00
Egor b117df126a Merge pull request #2048 from BEDOLAGA-DEV/bedolaga-myk79e
Show maintenance notice in miniapp
2025-11-25 03:13:40 +03:00
Egor 610a39e322 Show maintenance notice in miniapp 2025-11-25 03:12:30 +03:00
Egor 71fca0ccf8 Merge pull request #2046 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:49:53 +03:00
Egor 2568cf086a Merge pull request #2045 from BEDOLAGA-DEV/0dm6a3-bedolaga/fix-broadcast-message-failure-on-large-lists
Handle large broadcast status updates and cancellations
2025-11-25 02:42:34 +03:00
Egor ae453279bf Handle late cancellation finalization 2025-11-25 02:38:17 +03:00
Egor bdb04ab91b Merge pull request #2042 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 02:16:49 +03:00
Egor 3636cd27b6 Merge pull request #2041 from BEDOLAGA-DEV/q8dwo9-bedolaga/add-user-balance-to-promocode-notifications
Ensure balance fields exposed in subscription events API
2025-11-25 02:13:47 +03:00
Egor 04279542c5 Ensure balance fields exposed in subscription events API 2025-11-25 02:10:33 +03:00
Egor c7666274b8 Merge pull request #2040 from BEDOLAGA-DEV/revert-2039-uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Revert "Add balance details to promocode notifications"
2025-11-25 02:06:33 +03:00
Egor 5b27b8834d Revert "Add balance details to promocode notifications" 2025-11-25 02:06:23 +03:00
Egor 4b506cde0c Merge pull request #2039 from BEDOLAGA-DEV/uqw8rl-bedolaga/add-user-balance-to-promocode-notifications
Add balance details to promocode notifications
2025-11-25 02:05:23 +03:00
Egor 121d254fc0 Add balance details to promocode notifications 2025-11-25 02:03:16 +03:00
Egor 515c9dc8b1 Merge pull request #2038 from BEDOLAGA-DEV/dev5
Апи для сообщений в меню и приветственного текста и доп уведомления
2025-11-25 02:00:46 +03:00
Egor 4e03fa1b71 Merge pull request #2037 from BEDOLAGA-DEV/7z2yaf-bedolaga/fix-text-field-key-mismatch-in-updater
Fix welcome text patch text field mapping
2025-11-25 01:53:20 +03:00
Egor 80752c3004 Fix welcome text update payload mapping 2025-11-25 01:53:01 +03:00
Egor a11d37ef96 Merge pull request #2036 from BEDOLAGA-DEV/1docjs-bedolaga/add-notifications-to-api
Add notification events for balances and promotions
2025-11-25 01:51:45 +03:00
Egor 14c89c7aab Expand notification events 2025-11-25 01:43:07 +03:00
Egor b09b47fa49 Merge pull request #2034 from BEDOLAGA-DEV/2westv-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on welcome text creation
2025-11-25 01:25:57 +03:00
Egor 49b8a96a84 Handle missing creator on welcome text creation 2025-11-25 01:25:41 +03:00
Egor 6f800ac60b Merge pull request #2033 from BEDOLAGA-DEV/revert-2031-vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Revert "Handle missing creator on user message creation"
2025-11-25 01:24:02 +03:00
Egor 46dfb59f3d Revert "Handle missing creator on user message creation" 2025-11-25 01:23:54 +03:00
Egor d5bbb9f14a Merge pull request #2031 from BEDOLAGA-DEV/vb0a8m-bedolaga/expand-bot-api-for-welcome-text
Handle missing creator on user message creation
2025-11-25 01:19:15 +03:00
Egor 7c52811750 Merge pull request #2032 from BEDOLAGA-DEV/revert-2030-j1ummj-bedolaga/expand-bot-api-for-welcome-text
Revert "Expose admin APIs for welcome texts and menu messages"
2025-11-25 01:19:01 +03:00
Egor 2d4b46afbd Revert "Expose admin APIs for welcome texts and menu messages" 2025-11-25 01:18:45 +03:00
Egor 83093236fc Handle missing creator on user message creation 2025-11-25 01:18:03 +03:00
Egor 3881e7d1d5 Merge pull request #2030 from BEDOLAGA-DEV/j1ummj-bedolaga/expand-bot-api-for-welcome-text
Expose admin APIs for welcome texts and menu messages
2025-11-25 01:08:09 +03:00
Egor 1c3dbd57c5 Add API endpoints for welcome texts and menu messages 2025-11-25 01:06:05 +03:00
Egor 6f1275f291 Merge pull request #2029 from BEDOLAGA-DEV/dev5
Апи для партнерки
2025-11-25 00:58:14 +03:00
Egor cfc48ea061 Merge pull request #2028 from BEDOLAGA-DEV/vinuia-bedolaga/expand-bot-api-for-partner-section
Fix referrer listing filter for partners API
2025-11-25 00:54:20 +03:00
Egor 2679172ae4 Fix referrer query predicate 2025-11-25 00:54:05 +03:00
Egor 6a0c276454 Merge pull request #2027 from BEDOLAGA-DEV/revert-2026-fvbx51-bedolaga/expand-bot-api-for-partner-section
Revert "Fix partner routes referral imports"
2025-11-25 00:52:21 +03:00
Egor 5872e3bd3c Revert "Fix partner routes referral imports" 2025-11-25 00:52:09 +03:00
Egor f6041ab055 Merge pull request #2026 from BEDOLAGA-DEV/fvbx51-bedolaga/expand-bot-api-for-partner-section
Fix partner routes referral imports
2025-11-25 00:45:37 +03:00
Egor b5c5da552d Fix partner routes referral imports 2025-11-25 00:45:23 +03:00
Egor c093ef95ed Merge pull request #2025 from BEDOLAGA-DEV/revert-2024-f0fpo3-bedolaga/expand-bot-api-for-partner-section
Revert "Add API endpoint to update user referral commission"
2025-11-25 00:45:07 +03:00
Egor d376a25e64 Revert "Add API endpoint to update user referral commission" 2025-11-25 00:44:56 +03:00
Egor 4ec926d072 Merge pull request #2024 from BEDOLAGA-DEV/f0fpo3-bedolaga/expand-bot-api-for-partner-section
Add API endpoint to update user referral commission
2025-11-25 00:42:20 +03:00
Egor 65a7df4549 Allow updating referral commission per user via API 2025-11-25 00:42:05 +03:00
Egor 57c252dcbf Merge pull request #2023 from BEDOLAGA-DEV/revert-2022-76qz7l-bedolaga/expand-bot-api-for-partner-section
Revert "Add API to update referrer commission percent"
2025-11-25 00:41:28 +03:00
Egor 9b74b40794 Revert "Add API to update referrer commission percent" 2025-11-25 00:41:19 +03:00
Egor 0b028e1f13 Merge pull request #2022 from BEDOLAGA-DEV/76qz7l-bedolaga/expand-bot-api-for-partner-section
Add API to update referrer commission percent
2025-11-25 00:40:08 +03:00
Egor 337b948fda Add API to update referrer commission percent 2025-11-25 00:39:50 +03:00
Egor 5de187c6cd Merge pull request #2021 from BEDOLAGA-DEV/dev5
Dev5
2025-11-25 00:27:50 +03:00
Egor 7150204c71 Merge pull request #2020 from BEDOLAGA-DEV/0i4s7o-bedolaga/update-bot-api-for-notifications
Add user details to subscription event API responses
2025-11-25 00:24:58 +03:00
Egor 3d44403318 Add user info to subscription event API 2025-11-25 00:24:32 +03:00
Egor 812f3e983c Merge pull request #2018 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:27:34 +03:00
Egor 7549e571fe Merge pull request #2017 from BEDOLAGA-DEV/7pp9bw-bedolaga/add-setting-to-disable-support-balance-top-up
Add setting to disable support balance top-ups
2025-11-24 08:25:55 +03:00
Egor 9d3f096be0 Add toggle for support top-ups 2025-11-24 08:17:23 +03:00
Egor fd40cd3ba6 Merge pull request #2016 from BEDOLAGA-DEV/dev5
Dev5
2025-11-24 08:12:05 +03:00
Egor 1ba566f30f Merge pull request #2015 from BEDOLAGA-DEV/mz6u7a-bedolaga/fix-subscription-notification-api
Rollback DB session on subscription event logging errors
2025-11-24 07:59:39 +03:00
Egor 9b9d17b8e7 Rollback session after subscription event logging failure 2025-11-24 07:55:52 +03:00
Egor 3fee600db0 Merge pull request #2012 from BEDOLAGA-DEV/revert-2011-rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Revert "Debit balance when closing referral withdrawals"
2025-11-24 07:30:05 +03:00
Egor fbb1091f8b Revert "Debit balance when closing referral withdrawals" 2025-11-24 07:29:57 +03:00
Egor 8513d67a78 Merge pull request #2011 from BEDOLAGA-DEV/rrpx8s-bedolaga/add-referral-income-withdrawal-feature
Debit balance when closing referral withdrawals
2025-11-24 07:22:18 +03:00
Egor 45ce615fbe Debit balance when closing referral withdrawal requests 2025-11-24 07:21:39 +03:00
Egor 2ab4ca5b09 Merge pull request #2010 from BEDOLAGA-DEV/revert-2009-so9oqv-bedolaga/add-referral-income-withdrawal-feature
Revert "Harden referral withdrawal template formatting"
2025-11-24 07:21:04 +03:00
Egor b229dc22ba Revert "Harden referral withdrawal template formatting" 2025-11-24 07:20:56 +03:00
Egor bf49a5c1d5 Merge pull request #2009 from BEDOLAGA-DEV/so9oqv-bedolaga/add-referral-income-withdrawal-feature
Harden referral withdrawal template formatting
2025-11-24 07:11:04 +03:00
Egor 27b6e974ab Initialize texts in admin referral stats handler 2025-11-24 07:06:14 +03:00
Egor 9f00b56be0 Merge pull request #2002 from BEDOLAGA-DEV/j33on7-bedolaga/add-individual-referral-percentage-in-user-edit
Add universal migration for referral commission column
2025-11-24 05:35:46 +03:00
Egor 05177bff92 Chain referral commission migration to latest head 2025-11-24 05:35:37 +03:00
Egor f40f233562 Add universal migration for referral commission column 2025-11-24 05:27:04 +03:00
Egor d3a351aeba Merge pull request #1992 from BEDOLAGA-DEV/dd57ir-bedolaga/fix-validation-error-in-system-stats-response
Handle fractional uptime values in system stats
2025-11-24 05:23:15 +03:00
Egor fb76d30457 Merge pull request #2001 from BEDOLAGA-DEV/revert-2000-main
Revert "Обратная синхронизация remnawave"
2025-11-24 01:20:25 +03:00
Egor afba2137d1 Revert "Обратная синхронизация remnawave" 2025-11-24 01:20:17 +03:00
Egor 9ef3ca1831 Merge pull request #2000 from Gy9vin/main
Обратная синхронизация remnawave
2025-11-24 01:14:20 +03:00
gy9vin 65ae2f5c48 Обратная синхронизация 2025-11-24 00:09:58 +03:00
Egor 2686dcd30a Merge pull request #1998 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:40:08 +03:00
Egor 6bc125a2f4 Merge pull request #1997 from BEDOLAGA-DEV/w16jb9-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:37:00 +03:00
Egor 29c6e296df Escape promo discount format hint for HTML 2025-11-23 06:36:44 +03:00
Egor 3d1d064af7 Merge pull request #1996 from BEDOLAGA-DEV/revert-1995-efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Revert "Fix admin pricing callbacks for base discount settings"
2025-11-23 06:36:02 +03:00
Egor ccf1f8a71d Revert "Fix admin pricing callbacks for base discount settings" 2025-11-23 06:35:53 +03:00
Egor b83a8c9df0 Merge pull request #1995 from BEDOLAGA-DEV/efy4mi-bedolaga/add-admin-panel-for-pricing-limits
Fix admin pricing callbacks for base discount settings
2025-11-23 06:29:56 +03:00
Egor a7bd94f394 Shorten admin pricing callback payloads 2025-11-23 06:29:33 +03:00
Egor 96787c27d3 Merge pull request #1994 from BEDOLAGA-DEV/revert-1993-arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Revert "Add base promo group discount controls to admin pricing"
2025-11-23 06:29:11 +03:00
Egor e99c731e27 Revert "Add base promo group discount controls to admin pricing" 2025-11-23 06:29:03 +03:00
Egor 4a4b5bd8ef Merge pull request #1993 from BEDOLAGA-DEV/arxkfd-bedolaga/add-admin-panel-for-pricing-limits
Add base promo group discount controls to admin pricing
2025-11-23 06:18:05 +03:00
Egor 9d27f1aa6b Add base promo group discount controls to admin pricing 2025-11-23 06:17:45 +03:00
Egor fb010038f1 Ensure uptime seconds parsed as integer 2025-11-23 06:08:33 +03:00
Egor 700f86fe38 Merge pull request #1991 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 06:00:33 +03:00
Egor 582a8b9615 Merge pull request #1990 from BEDOLAGA-DEV/bedolaga
Apply base promo discounts to all order components
2025-11-23 05:55:48 +03:00
Egor 7c9dfc352c Apply base promo discounts to all order components 2025-11-23 05:51:30 +03:00
Egor b86d549695 Merge pull request #1989 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:46:13 +03:00
Egor 577f6dfd8e Merge pull request #1988 from BEDOLAGA-DEV/bedolaga-dtalt2
Add subscription events table to universal migration
2025-11-23 04:37:53 +03:00
Egor eab4cce251 Add subscription events universal migration 2025-11-23 04:33:44 +03:00
Egor 7a099ab255 Merge pull request #1987 from BEDOLAGA-DEV/dev5
Dev5
2025-11-23 04:19:34 +03:00
Egor 9fa67f61f3 Merge pull request #1986 from BEDOLAGA-DEV/bedolaga/add-/upload-endpoint-for-file-uploads-lowg6q
Proxy media downloads without exposing bot token
2025-11-23 04:19:03 +03:00
Egor 86ebff4948 Serve proxy media with detected content type 2025-11-23 04:09:04 +03:00
Egor 821ac4668f Merge pull request #1983 from BEDOLAGA-DEV/main
w
2025-11-23 03:23:33 +03:00
Egor d0280f0ab9 Merge pull request #1980 from belousotroll/add-policy-page
Add policy page at registration stage
2025-11-23 03:22:49 +03:00
belousotroll ceb45b4c0b add policy page at registration stage 2025-11-22 21:30:11 +07:00
Egor 85ea3d97ef Update README.md 2025-11-22 02:36:04 +03:00
Egor 5640041e45 Update README.md 2025-11-22 02:35:17 +03:00
Egor 1357ddc1fe Update README.md 2025-11-22 02:34:29 +03:00
Egor 0765dae178 Update README.md 2025-11-22 02:31:48 +03:00
Egor 81f2e20c08 Update README.md 2025-11-22 02:29:35 +03:00
Egor b27d34ec92 Update README.md 2025-11-22 02:24:59 +03:00
Egor e7fbbd579c Merge pull request #1977 from remnawave-contrib/ivan-nginx-manual-update
docs: update manual update instructions
2025-11-22 00:11:22 +03:00
Ivan.Nginx 7a2a82fa87 docs: update manual update instructions 2025-11-21 10:25:02 +03:00
Egor c3c105619b Update Dockerfile 2025-11-21 07:02:11 +03:00
Egor 01e575676f Update docker-registry.yml 2025-11-21 07:01:57 +03:00
Egor b53a43fcc6 Update docker-hub.yml 2025-11-21 07:01:41 +03:00
Egor 6a8ff1c1b4 Merge pull request #1976 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:36:35 +03:00
Egor 451ab514cc Merge pull request #1975 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-d1pv7w
Clean up CryptoBot prompt messages
2025-11-21 06:33:34 +03:00
Egor c8b6830704 Clean up CryptoBot amount prompts 2025-11-21 06:32:34 +03:00
Egor 74ee7149c5 Merge pull request #1974 from BEDOLAGA-DEV/revert-1972-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Revert "Fix Tribute payment prompt formatting"
2025-11-21 06:32:23 +03:00
Egor 506b96dec4 Revert "Fix Tribute payment prompt formatting" 2025-11-21 06:32:13 +03:00
Egor 927d4230a2 Merge pull request #1973 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 06:08:36 +03:00
Egor 69ff737b72 Merge pull request #1972 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-675m3k
Fix Tribute payment prompt formatting
2025-11-21 06:05:50 +03:00
Egor d0114a1f29 Fix Tribute payment prompt editing 2025-11-21 06:03:35 +03:00
Egor f92a0fd88b Merge pull request #1971 from BEDOLAGA-DEV/revert-1969-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Revert "Reject YooKassa webhooks lacking payment id"
2025-11-21 06:03:14 +03:00
Egor e7478cc1e1 Revert "Reject YooKassa webhooks lacking payment id" 2025-11-21 06:03:04 +03:00
Egor 5814f9757d Merge pull request #1970 from BEDOLAGA-DEV/dev5
Dev5
2025-11-21 05:40:12 +03:00
Egor cb7b9a9e22 Merge pull request #1969 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-xitbxf
Reject YooKassa webhooks lacking payment id
2025-11-21 05:34:23 +03:00
Egor 6dc525dd72 Handle missing YooKassa payment ids gracefully 2025-11-21 05:26:42 +03:00
Egor 409c8c161b Merge pull request #1968 from BEDOLAGA-DEV/revert-1967-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Revert "Handle YooKassa webhook payloads without payment ids"
2025-11-21 05:26:31 +03:00
Egor 78db3a1e77 Revert "Handle YooKassa webhook payloads without payment ids" 2025-11-21 05:26:21 +03:00
Egor d0c5ff19e1 Merge pull request #1967 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-skti1r
Handle YooKassa webhook payloads without payment ids
2025-11-21 05:17:43 +03:00
Egor 6a29765ddf Handle YooKassa webhooks without payment ids 2025-11-21 05:17:09 +03:00
Egor 95147fbe03 Merge pull request #1964 from BEDOLAGA-DEV/revert-1963-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Revert "Clean up Platega and YooKassa invoices after payment"
2025-11-21 04:49:30 +03:00
Egor 0137321d24 Revert "Clean up Platega and YooKassa invoices after payment" 2025-11-21 04:49:17 +03:00
Egor 0521306699 Merge pull request #1963 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-fbz95w
Clean up Platega and YooKassa invoices after payment
2025-11-21 04:18:23 +03:00
Egor 2a10e7d2c7 Clean up Platega and YooKassa invoices after payment 2025-11-21 04:18:03 +03:00
Egor e78e9e1a4d Merge pull request #1962 from BEDOLAGA-DEV/revert-1960-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Revert "Clean up Platega and YooKassa prompts"
2025-11-21 04:17:22 +03:00
Egor 29d0b54b04 Revert "Clean up Platega and YooKassa prompts" 2025-11-21 04:17:13 +03:00
Egor 64fe3fdbb7 Merge pull request #1960 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-v2ldtf
Clean up Platega and YooKassa prompts
2025-11-21 04:02:54 +03:00
Egor 04edca175e Merge pull request #1961 from BEDOLAGA-DEV/revert-1959-bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Revert "Clean up Telegram Stars payment flow"
2025-11-21 04:02:38 +03:00
Egor 840091cb05 Revert "Clean up Telegram Stars payment flow" 2025-11-21 04:02:28 +03:00
Egor 473c3704cf Clean up Platega and YooKassa prompts 2025-11-21 04:02:22 +03:00
Egor af630b22c9 Merge pull request #1959 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill-s863sv
Clean up Telegram Stars payment flow
2025-11-21 03:27:06 +03:00
Egor 29fe2f2016 Clean up Telegram Stars payment flow 2025-11-21 03:26:34 +03:00
Egor cd5691640e Merge pull request #1958 from BEDOLAGA-DEV/revert-1957-bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:26:03 +03:00
Egor 77c217f9ad Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:25:55 +03:00
Egor 9e963569bc Merge pull request #1957 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-chat-messages-after-balance-refill
Clean up Telegram Stars top-up messages
2025-11-21 03:24:32 +03:00
Egor c7114ec359 Clean up Telegram Stars top-up messages 2025-11-21 03:24:17 +03:00
Egor b9586ad72a Merge pull request #1956 from BEDOLAGA-DEV/revert-1955-bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Revert "Clean up Telegram Stars top-up messages"
2025-11-21 03:18:19 +03:00
Egor b6e333127c Revert "Clean up Telegram Stars top-up messages" 2025-11-21 03:18:10 +03:00
Egor 6ea2c2cceb Merge pull request #1955 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update-vvw9nt
Clean up Telegram Stars top-up messages
2025-11-21 03:16:40 +03:00
Egor af42377c3b Clean up Telegram Stars top-up messages 2025-11-21 03:16:24 +03:00
Egor 82881a4c45 Merge pull request #1954 from BEDOLAGA-DEV/revert-1953-bedolaga/remove-unnecessary-messages-after-balance-update
Revert "Clean up Telegram Stars payment messages"
2025-11-21 03:16:12 +03:00
Egor 70d50e9c47 Revert "Clean up Telegram Stars payment messages" 2025-11-21 03:16:04 +03:00
Egor ddb5c01026 Merge pull request #1953 from BEDOLAGA-DEV/bedolaga/remove-unnecessary-messages-after-balance-update
Clean up Telegram Stars payment messages
2025-11-21 03:14:41 +03:00
Egor e287adcb84 Clean up Telegram Stars payment messages 2025-11-21 03:14:25 +03:00
Egor 83d041c13b Merge pull request #1952 from BEDOLAGA-DEV/bedolaga/fix-auto-renewal-calculation-error
Fix autopay renewal pricing for promo groups
2025-11-20 23:13:12 +03:00
Egor 545c5fd749 Eager load promo groups for autopay renewals 2025-11-20 23:12:49 +03:00
Egor 7f0e2de7c4 Merge pull request #1951 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-service-errors
Handle missing channel link in subscription check
2025-11-20 23:06:53 +03:00
Egor 1fdf1e49a3 Handle missing channel link in subscription check 2025-11-20 23:06:34 +03:00
Egor 08f82ee511 Merge pull request #1950 from BEDOLAGA-DEV/bedolaga-q76r4i
Fix trial reset cleanup
2025-11-20 22:58:04 +03:00
Egor fba217b87f Fix trial reset by clearing server links 2025-11-20 22:49:57 +03:00
Egor 6668a18ad3 Merge pull request #1949 from BEDOLAGA-DEV/revert-1948-bedolaga-uhav33
Revert "Add admin trial reset controls"
2025-11-20 22:46:40 +03:00
Egor 11c239f46c Revert "Add admin trial reset controls" 2025-11-20 22:46:30 +03:00
Egor ac62dee279 Merge pull request #1948 from BEDOLAGA-DEV/bedolaga-uhav33
Add admin trial reset controls
2025-11-20 22:34:27 +03:00
Egor 29a17a7876 Add admin trial reset controls 2025-11-20 22:33:43 +03:00
Egor 694c934612 Merge pull request #1947 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 22:11:23 +03:00
Egor c01d2bfcd7 Update README.md 2025-11-20 22:09:07 +03:00
Egor b3708f99c0 Merge pull request #1943 from BEDOLAGA-DEV/bedolaga/fix-subscription-check-error-on-double-click
Handle repeat device selection without redundant updates
2025-11-20 18:39:46 +03:00
Egor f651f921aa Handle repeat device selection without redundant updates 2025-11-20 18:39:25 +03:00
Egor ada6845036 Merge pull request #1938 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 17:58:46 +03:00
Egor d33b259537 Merge pull request #1936 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users-yvuqli
Throttle broadcast sending for large campaigns
2025-11-20 17:31:23 +03:00
Egor 3c2ed147ca Merge pull request #1937 from BEDOLAGA-DEV/revert-1934-bedolaga/fix-email-dispatch-limit-to-100000-users
Revert "Remove broadcast recipient limit"
2025-11-20 17:31:07 +03:00
Egor d46e88c2b5 Revert "Remove broadcast recipient limit" 2025-11-20 17:30:56 +03:00
Egor 1477491e6e Throttle broadcast speed for large campaigns 2025-11-20 17:30:30 +03:00
Egor 647dc0bd7c Merge pull request #1935 from BEDOLAGA-DEV/dev5
Dev5
2025-11-20 15:47:45 +03:00
Egor f6a45993e9 Merge pull request #1934 from BEDOLAGA-DEV/bedolaga/fix-email-dispatch-limit-to-100000-users
Remove broadcast recipient limit
2025-11-20 15:47:24 +03:00
Egor 0766320411 Fetch all broadcast users in batches 2025-11-20 15:36:38 +03:00
Egor 5ebf113fd6 Merge pull request #1915 from BEDOLAGA-DEV/dev4
Dev4
2025-11-20 01:30:14 +03:00
Egor 2005d103f8 Merge pull request #1914 from BEDOLAGA-DEV/bedolaga/fix-balance-top-up-through-method-10
Handle Platega description byte limit
2025-11-20 01:20:05 +03:00
Egor 3935813336 Trim Platega descriptions by byte length 2025-11-20 01:16:25 +03:00
Egor ec71fa78c0 Merge pull request #1913 from BEDOLAGA-DEV/bedolaga/fix-subscription-channel-error
Handle duplicate channel subscription prompts
2025-11-20 01:12:44 +03:00
Egor cd2b0a2d67 Handle duplicate channel subscription messages 2025-11-20 01:10:28 +03:00
Egor 68d502804f Merge pull request #1911 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 01:17:55 +03:00
Egor afbd343442 Merge pull request #1910 from BEDOLAGA-DEV/bedolaga/fix-subscription-creation-api-error-uklgqk
Preserve autopay and device limits during subscription replacement
2025-11-18 01:17:37 +03:00
Egor 991e5a3112 Preserve zero device limit when replacing trials 2025-11-18 01:14:43 +03:00
Egor 7d9de6ca5d Merge pull request #1906 from BEDOLAGA-DEV/dev4
Dev4
2025-11-18 00:28:35 +03:00
Egor 562bb69082 Merge pull request #1905 from BEDOLAGA-DEV/bedolaga/expand-bot-api-functionality
Expand admin API for polls, logs, and tickets
2025-11-18 00:27:49 +03:00
Egor a02416c78b Add poll sending and ticket API enhancements 2025-11-18 00:22:46 +03:00
Egor 81f56008d4 Delete install_bot.sh 2025-11-16 12:04:21 +03:00
Egor 43a7039479 Merge pull request #1904 from Fr1ngg/dev4
Dev4
2025-11-15 01:46:45 +03:00
Egor a9f3904524 Merge pull request #1903 from Fr1ngg/bedolaga/fix-ru-cards-payment-processing-error
Limit Platega description length
2025-11-15 00:59:45 +03:00
Egor 9136c7cfe3 Limit Platega description length 2025-11-15 00:56:11 +03:00
Egor 5595846929 Merge pull request #1902 from Fr1ngg/dev4
Dev4
2025-11-14 01:32:49 +03:00
Egor 9ebe2971bf Merge pull request #1901 from Fr1ngg/bedolaga/fix-402-error-in-subscription-renewal-endpoint
Fix renewal mixed payment logic
2025-11-14 01:31:06 +03:00
Egor fadea75314 Fix renewal mixed payment logic 2025-11-14 01:30:31 +03:00
Egor a2009cae39 Merge pull request #1899 from Fr1ngg/dev4
Allow trial users to renew from miniapp
2025-11-14 01:17:04 +03:00
Egor 0187adb6f9 Merge pull request #1898 from Fr1ngg/bedolaga/fix-trial-subscription-error-in-miniapp
Enable trial subscriptions to renew via miniapp
2025-11-14 01:16:00 +03:00
Egor 302a028873 Allow trial users to renew from miniapp 2025-11-14 00:15:43 +03:00
Egor 77c9c19806 Merge pull request #1897 from Fr1ngg/dev4
Dev4
2025-11-13 23:37:04 +03:00
Egor 724da2545e Merge pull request #1896 from Fr1ngg/main
w
2025-11-13 23:36:46 +03:00
Egor 079af66d9f Merge pull request #1895 from Fr1ngg/bedolaga/fix-subscription-purchase-failure-with-platega
Fix Platega quick amount selection flow
2025-11-13 23:36:23 +03:00
Egor e701d234f0 Fix Platega quick amount selection flow 2025-11-13 23:33:07 +03:00
Egor 1b111975bb Update config.py 2025-11-12 16:41:37 +03:00
Egor c874425e0e Merge pull request #1890 from DOFER998/c0mrade/refactor-subtract-balance-logging
refactor: improve logging level for balance subtraction debug info
2025-11-12 16:28:09 +03:00
c0mrade c6c112fd17 refactor: improve logging level for balance subtraction debug info 2025-11-12 10:21:28 +03:00
Egor 57d924e3ba Merge pull request #1889 from Fr1ngg/dev4
Доп языки + фиксы
2025-11-12 05:38:34 +03:00
Egor 9d5b9ebf82 Add files via upload 2025-11-12 05:28:39 +03:00
Egor 354358e860 Create fail.html 2025-11-12 05:28:25 +03:00
Egor 327c9d52d2 Create succes.html 2025-11-12 05:27:59 +03:00
Egor c03051614d Merge pull request #1888 from Fr1ngg/revert-1887-bedolaga/activate-trial-automatically-after-payment-le7ksb
Revert "Enable automatic trial activation after balance top-up"
2025-11-12 05:24:24 +03:00
Egor ac9e5cd908 Revert "Enable automatic trial activation after balance top-up" 2025-11-12 05:24:14 +03:00
Egor 585bb56a99 Merge pull request #1887 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment-le7ksb
Enable automatic trial activation after balance top-up
2025-11-12 04:45:20 +03:00
Egor 8f33eb0cc6 Enable automatic trial activation after balance top-up 2025-11-12 04:45:03 +03:00
Egor 71515a87de Merge pull request #1886 from Fr1ngg/revert-1885-bedolaga/activate-trial-automatically-after-payment
Revert "feat: auto activate paid trials after balance top-up"
2025-11-12 04:44:54 +03:00
Egor 0a4ede8f79 Revert "feat: auto activate paid trials after balance top-up" 2025-11-12 04:44:46 +03:00
Egor 040b33b541 Merge pull request #1885 from Fr1ngg/bedolaga/activate-trial-automatically-after-payment
feat: auto activate paid trials after balance top-up
2025-11-12 04:34:06 +03:00
Egor 5c3484ee28 feat: auto activate paid trials after balance top-up 2025-11-12 04:27:33 +03:00
Egor ef309c3101 Merge pull request #1884 from Fr1ngg/bedolaga/fix-trial-subscription-display-issue
Fix trial subscription status display in menu
2025-11-12 03:09:46 +03:00
Egor 51dad7936b Fix trial subscription status display in menu 2025-11-12 03:03:34 +03:00
Egor 097734b31d Merge pull request #1883 from Fr1ngg/bedolaga/fix-user-deletion-foreign-key-violation
Handle Platega payments when deleting users
2025-11-12 02:47:54 +03:00
Egor 7185fde9bc Handle Platega payments when deleting users 2025-11-12 02:47:37 +03:00
Egor 75f2cdb04b Update remnawave.py 2025-11-12 02:41:44 +03:00
Egor d3c445ed2d Merge pull request #1882 from Fr1ngg/bedolaga/fix-webhook-issue-with-heleket
Fix Heleket webhook signature verification
2025-11-12 02:40:34 +03:00
Egor 804f088435 Fix Heleket webhook signature verification 2025-11-11 16:41:58 +03:00
Egor 6c1b70e796 Merge pull request #1881 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint-n1nsij
Handle CryptoBot renewal payload fallbacks
2025-11-11 13:13:14 +03:00
Egor a3532e5878 Handle CryptoBot renewal payload fallbacks 2025-11-11 13:06:10 +03:00
Egor a8c1b32912 Merge pull request #1880 from Fr1ngg/revert-1877-bedolaga/fix-403-error-on-subscription-renewal-endpoint
Revert "Allow expired miniapp subscriptions to load renewal options"
2025-11-11 12:22:29 +03:00
Egor 20b0c8a0e2 Revert "Allow expired miniapp subscriptions to load renewal options" 2025-11-11 12:22:15 +03:00
Egor bc18692d75 Merge pull request #1877 from Fr1ngg/bedolaga/fix-403-error-on-subscription-renewal-endpoint
Allow expired miniapp subscriptions to load renewal options
2025-11-11 10:46:42 +03:00
Egor c05ecf1ecd Allow expired subscriptions to access renewal options 2025-11-11 10:42:33 +03:00
Egor 8381fc52f1 Merge pull request #1876 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-cen8qi
Fix dynamic traffic price recalculation in localization
2025-11-11 10:33:33 +03:00
Egor a697ffa8f1 Fix dynamic traffic localization prices 2025-11-11 10:28:09 +03:00
Egor 4eb36fab6d Merge pull request #1875 from Fr1ngg/revert-1873-bedolaga/add-default-localization-support-for-flags-ocds8i
Revert "Add dynamic localization support for UA and ZH languages"
2025-11-11 10:27:19 +03:00
Egor 4b0780b549 Revert "Add dynamic localization support for UA and ZH languages" 2025-11-11 10:27:10 +03:00
Egor 1befc2982a Merge pull request #1873 from Fr1ngg/bedolaga/add-default-localization-support-for-flags-ocds8i
Add dynamic localization support for UA and ZH languages
2025-11-11 09:10:15 +03:00
Egor df1f225405 Populate dynamic prices and support text for new locales 2025-11-11 09:07:02 +03:00
Egor df6feca53c Add files via upload 2025-11-11 08:37:03 +03:00
Egor bd41efb974 Merge pull request #1869 from Fr1ngg/main
w
2025-11-11 08:34:18 +03:00
Egor a1216bface Update README.md 2025-11-11 08:32:07 +03:00
Egor e0969b315a Merge pull request #1868 from remnawave-contrib/ivan-nginx-readme-make
Added «make» installation command to Readme
2025-11-11 08:30:06 +03:00
Ivan.Nginx 395bccf4e5 Merge branch 'Fr1ngg:main' into main 2025-11-11 08:23:02 +03:00
Ivan.Nginx f6e5cfce54 Merge pull request #2 from remnawave-contrib/ivan-nginx-readme-add-make-installation
Update installation instructions for 'make' command
2025-11-11 08:22:16 +03:00
Ivan.Nginx a26e89147b Update installation instructions for 'make' command 2025-11-11 08:21:34 +03:00
Egor 4396020177 Merge pull request #1867 from Fr1ngg/dev4
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:16:20 +03:00
Egor e2c89ec19e Merge pull request #1866 from Fr1ngg/bedolaga/extend-api-for-payment-method-integration-types
[FEAT] Add integration metadata for miniapp payment methods
2025-11-11 08:15:06 +03:00
Egor 68c125ba14 feat: expose integration metadata for miniapp payments 2025-11-11 08:11:42 +03:00
Egor 8c794fc91c Update README.md 2025-11-11 07:42:15 +03:00
Egor efbe8475df Update README.md 2025-11-11 07:41:43 +03:00
Egor 7835e02d5c Merge pull request #1860 from Gy9vin/main
Апи поиск в боте по тгID
2025-11-11 02:08:32 +03:00
Mikhail 21a5499af8 Merge branch 'Fr1ngg:main' into main 2025-11-10 13:54:03 +03:00
gy9vin dd85db071a Апи поиск в боте по тгID 2025-11-10 13:53:21 +03:00
140 changed files with 15768 additions and 2941 deletions
+18 -2
View File
@@ -19,6 +19,20 @@ ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Мониторинг трафика
TRAFFIC_MONITORING_ENABLED=false # Включить мониторинг трафика пользователей
TRAFFIC_THRESHOLD_GB_PER_DAY=10.0 # Порог трафика в ГБ за сутки (превышение вызывает уведомление)
TRAFFIC_MONITORING_INTERVAL_HOURS=24 # Интервал проверки трафика в часах (например: 1, 6, 12, 24)
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности (0 для отправки в основной чат)
# Черный список
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
@@ -253,6 +267,8 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
@@ -341,7 +357,7 @@ PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
@@ -428,7 +444,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.1-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+2
View File
@@ -12,6 +12,8 @@ docker-compose.override.yml
!requirements.txt
!docs/
!docs/**
!migrations/
!migrations/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.14-slim AS builder
FROM python:3.13-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.14-slim
FROM python:3.13-slim
ARG VERSION="v2.6.2"
ARG VERSION="v2.9.1"
ARG BUILD_DATE
ARG VCS_REF
+658 -185
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -31,6 +31,8 @@ from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
main as admin_main,
blacklist as admin_blacklist,
bulk_ban as admin_bulk_ban,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
@@ -59,6 +61,7 @@ from app.handlers.admin import (
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
trials as admin_trials,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -174,6 +177,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_polls.register_handlers(dp)
+116 -19
View File
@@ -1,12 +1,14 @@
import hashlib
import hmac
import logging
import math
import os
import re
import html
from collections import defaultdict
from datetime import time
from typing import List, Optional, Union, Dict
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
@@ -18,6 +20,8 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"joingroup",
]
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
logger = logging.getLogger(__name__)
@@ -87,6 +91,7 @@ class Settings(BaseSettings):
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
TRIAL_USER_TAG: Optional[str] = None
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
@@ -118,6 +123,7 @@ class Settings(BaseSettings):
PRICE_90_DAYS: int = 269000
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PAID_SUBSCRIPTION_USER_TAG: Optional[str] = None
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
@@ -149,12 +155,24 @@ class Settings(BaseSettings):
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
BLACKLIST_CHECK_ENABLED: bool = False
BLACKLIST_GITHUB_URL: Optional[str] = None
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
BLACKLIST_IGNORE_ADMINS: bool = True
# Настройки мониторинга трафика
TRAFFIC_MONITORING_ENABLED: bool = False
TRAFFIC_THRESHOLD_GB_PER_DAY: float = 10.0 # Порог трафика в ГБ за сутки
TRAFFIC_MONITORING_INTERVAL_HOURS: int = 24 # Интервал проверки в часах (по умолчанию - раз в сутки)
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
AUTOPAY_WARNING_DAYS: str = "3,1"
DEFAULT_AUTOPAY_ENABLED: bool = False
DEFAULT_AUTOPAY_DAYS_BEFORE: int = 3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS: int = 10000
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: int = 20000
MONITORING_INTERVAL: int = 60
INACTIVE_USER_DELETE_MONTHS: int = 3
@@ -193,6 +211,7 @@ class Settings(BaseSettings):
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
@@ -254,6 +273,7 @@ class Settings(BaseSettings):
MULENPAY_PAYMENT_MODE: int = 4
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
MULENPAY_IFRAME_EXPECTED_ORIGIN: Optional[str] = None
PAL24_ENABLED: bool = False
PAL24_API_TOKEN: Optional[str] = None
@@ -697,15 +717,40 @@ class Settings(BaseSettings):
return bool(value)
def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
try:
langs = self.AVAILABLE_LANGUAGES
if isinstance(langs, str):
if not langs.strip():
return ["ru", "en"]
return [x.strip() for x in langs.split(',') if x.strip()]
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
return defaults
candidates: List[str]
if isinstance(langs, str):
if not langs.strip():
return defaults
candidates = [chunk.strip() for chunk in langs.split(',')]
elif isinstance(langs, (list, tuple, set)):
candidates = [str(item).strip() for item in langs]
else:
return defaults
cleaned: List[str] = []
seen: set[str] = set()
for code in candidates:
if not code:
continue
normalized = code.lower()
if normalized in seen:
continue
seen.add(normalized)
cleaned.append(code)
return cleaned or defaults
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
@@ -748,13 +793,48 @@ class Settings(BaseSettings):
def kopeks_to_rubles(self, kopeks: int) -> float:
return kopeks / 100
def rubles_to_kopeks(self, rubles: float) -> int:
return int(rubles * 100)
@staticmethod
def _normalize_user_tag(value: Optional[str], setting_name: str) -> Optional[str]:
if value is None:
return None
cleaned = str(value).strip().upper()
if not cleaned:
return None
if len(cleaned) > 16:
logger.warning(
"Некорректная длина %s: максимум 16 символов, получено %s",
setting_name,
len(cleaned),
)
return None
if not USER_TAG_PATTERN.fullmatch(cleaned):
logger.warning(
"Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание",
setting_name,
)
return None
return cleaned
def get_trial_warning_hours(self) -> int:
return self.TRIAL_WARNING_HOURS
def get_trial_user_tag(self) -> Optional[str]:
return self._normalize_user_tag(self.TRIAL_USER_TAG, "TRIAL_USER_TAG")
def get_paid_subscription_user_tag(self) -> Optional[str]:
return self._normalize_user_tag(
self.PAID_SUBSCRIPTION_USER_TAG,
"PAID_SUBSCRIPTION_USER_TAG",
)
def get_bot_username(self) -> Optional[str]:
username = getattr(self, "BOT_USERNAME", None)
if not username:
@@ -904,9 +984,12 @@ class Settings(BaseSettings):
return value
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def is_support_topup_enabled(self) -> bool:
return bool(self.SUPPORT_TOPUP_ENABLED)
def get_yookassa_return_url(self) -> str:
if self.YOOKASSA_RETURN_URL:
@@ -943,6 +1026,20 @@ class Settings(BaseSettings):
def get_mulenpay_display_name_html(self) -> str:
return html.escape(self.get_mulenpay_display_name())
def get_mulenpay_expected_origin(self) -> Optional[str]:
override = (self.MULENPAY_IFRAME_EXPECTED_ORIGIN or "").strip()
if override:
return override
base_url = (self.MULENPAY_BASE_URL or "").strip()
if not base_url:
return None
parsed = urlparse(base_url)
if parsed.scheme and parsed.netloc:
return f"{parsed.scheme}://{parsed.netloc}"
return None
def is_pal24_enabled(self) -> bool:
return (
self.PAL24_ENABLED
@@ -1239,7 +1336,7 @@ class Settings(BaseSettings):
return stars * self.get_stars_rate()
def rubles_to_stars(self, rubles: float) -> int:
return max(1, int(rubles / self.get_stars_rate()))
return max(1, math.ceil(rubles / self.get_stars_rate()))
def get_admin_notifications_chat_id(self) -> Optional[int]:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
@@ -1291,13 +1388,13 @@ class Settings(BaseSettings):
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.info(f"CONFIG STRING: '{config_str}'")
logger.debug(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.info("CONFIG EMPTY, USING FALLBACK")
logger.debug("CONFIG EMPTY, USING FALLBACK")
return self._get_fallback_traffic_packages()
logger.info("PARSING CONFIG...")
logger.debug("PARSING CONFIG...")
for package_config in config_str.split(','):
package_config = package_config.strip()
@@ -1321,7 +1418,7 @@ class Settings(BaseSettings):
except ValueError:
continue
logger.info(f"PARSED {len(packages)} packages from config")
logger.debug(f"PARSED {len(packages)} packages from config")
return packages if packages else self._get_fallback_traffic_packages()
except Exception as e:
+17
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from typing import Optional
from sqlalchemy import select
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -90,6 +91,7 @@ async def update_mulenpay_payment_status(
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
@@ -100,6 +102,8 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
@@ -107,6 +111,19 @@ async def update_mulenpay_payment_status(
return payment
async def update_mulenpay_payment_metadata(
db: AsyncSession,
*,
payment: MulenPayPayment,
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
+3
View File
@@ -96,6 +96,7 @@ async def update_pal24_payment_status(
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: Dict[str, Any] = {
"status": status,
@@ -121,6 +122,8 @@ async def update_pal24_payment_status(
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values["callback_payload"] = callback_payload
if metadata is not None:
update_values["metadata_json"] = metadata
update_values["last_status"] = status
+198 -10
View File
@@ -1,7 +1,7 @@
import logging
from datetime import datetime, timedelta
from typing import Iterable, Optional, List, Tuple
from sqlalchemy import select, and_, func
from sqlalchemy import select, and_, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -129,7 +129,7 @@ async def create_paid_subscription(
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
@@ -186,6 +186,91 @@ async def create_paid_subscription(
return subscription
async def replace_subscription(
db: AsyncSession,
subscription: Subscription,
*,
duration_days: int,
traffic_limit_gb: int,
device_limit: int,
connected_squads: List[str],
is_trial: bool,
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
update_server_counters: bool = False,
) -> Subscription:
"""Перезаписывает параметры существующей подписки пользователя."""
current_time = datetime.utcnow()
old_squads = set(subscription.connected_squads or [])
new_squads = set(connected_squads or [])
new_autopay_enabled = (
subscription.autopay_enabled
if autopay_enabled is None
else autopay_enabled
)
new_autopay_days_before = (
subscription.autopay_days_before
if autopay_days_before is None
else autopay_days_before
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.is_trial = is_trial
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.autopay_enabled = new_autopay_enabled
subscription.autopay_days_before = new_autopay_days_before
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
if update_server_counters:
try:
from app.database.crud.server_squad import (
add_user_to_servers,
get_server_ids_by_uuids,
remove_user_from_servers,
)
squads_to_remove = old_squads - new_squads
squads_to_add = new_squads - old_squads
if squads_to_remove:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
if server_ids:
await remove_user_from_servers(db, sorted(server_ids))
if squads_to_add:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
if server_ids:
await add_user_to_servers(db, sorted(server_ids))
logger.info(
"♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s",
subscription.id,
len(squads_to_remove),
len(squads_to_add),
)
except Exception as error:
logger.error(
"⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s",
subscription.id,
error,
)
return subscription
async def extend_subscription(
db: AsyncSession,
subscription: Subscription,
@@ -591,10 +676,113 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
}
async def get_trial_statistics(db: AsyncSession) -> dict:
now = datetime.utcnow()
total_trials_result = await db.execute(
select(func.count(Subscription.id)).where(Subscription.is_trial.is_(True))
)
total_trials = total_trials_result.scalar() or 0
active_trials_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial.is_(True),
Subscription.end_date > now,
Subscription.status.in_(
[SubscriptionStatus.TRIAL.value, SubscriptionStatus.ACTIVE.value]
),
)
)
active_trials = active_trials_result.scalar() or 0
resettable_trials_result = await db.execute(
select(func.count(Subscription.id))
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
resettable_trials = resettable_trials_result.scalar() or 0
return {
"used_trials": total_trials,
"active_trials": active_trials,
"resettable_trials": resettable_trials,
}
async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user),
selectinload(Subscription.subscription_servers),
)
.join(User, Subscription.user_id == User.id)
.where(
Subscription.is_trial.is_(True),
Subscription.end_date <= now,
User.has_had_paid_subscription.is_(False),
)
)
subscriptions = result.scalars().unique().all()
if not subscriptions:
return 0
reset_count = len(subscriptions)
for subscription in subscriptions:
try:
await decrement_subscription_server_counts(
db,
subscription,
subscription_servers=subscription.subscription_servers,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Не удалось обновить счётчики серверов при сбросе триала %s: %s",
subscription.id,
error,
)
subscription_ids = [subscription.id for subscription in subscriptions]
if subscription_ids:
try:
await db.execute(
delete(SubscriptionServer).where(
SubscriptionServer.subscription_id.in_(subscription_ids)
)
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Ошибка удаления серверных связей триалов %s: %s",
subscription_ids,
error,
)
raise
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
try:
await db.commit()
except Exception as error: # pragma: no cover - defensive logging
await db.rollback()
logger.error("Ошибка сохранения сброса триалов: %s", error)
raise
logger.info("♻️ Сброшено триальных подписок: %s", reset_count)
return reset_count
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
@@ -800,8 +988,8 @@ async def calculate_subscription_total_cost(
]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.debug(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.debug(f" Базовый период: {base_price/100}")
if total_traffic_price > 0:
message = (
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}"
@@ -810,7 +998,7 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = (
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}"
@@ -819,7 +1007,7 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}"
@@ -828,8 +1016,8 @@ async def calculate_subscription_total_cost(
message += (
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_cost/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_cost/100}")
return total_cost, details
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, Iterable, Optional, Tuple
from sqlalchemy import and_, func, select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionEvent
async def create_subscription_event(
db: AsyncSession,
*,
user_id: int,
event_type: str,
subscription_id: Optional[int] = None,
transaction_id: Optional[int] = None,
amount_kopeks: Optional[int] = None,
currency: Optional[str] = None,
message: Optional[str] = None,
occurred_at: Optional[datetime] = None,
extra: Optional[Dict[str, Any]] = None,
) -> SubscriptionEvent:
event = SubscriptionEvent(
user_id=user_id,
event_type=event_type,
subscription_id=subscription_id,
transaction_id=transaction_id,
amount_kopeks=amount_kopeks,
currency=currency,
message=message,
occurred_at=occurred_at or datetime.utcnow(),
extra=extra or None,
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
async def list_subscription_events(
db: AsyncSession,
*,
limit: int,
offset: int,
event_types: Optional[Iterable[str]] = None,
user_id: Optional[int] = None,
) -> Tuple[list[SubscriptionEvent], int]:
base_query = select(SubscriptionEvent)
filters = []
if event_types:
filters.append(SubscriptionEvent.event_type.in_(set(event_types)))
if user_id:
filters.append(SubscriptionEvent.user_id == user_id)
if filters:
base_query = base_query.where(and_(*filters))
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.options(selectinload(SubscriptionEvent.user))
.order_by(SubscriptionEvent.occurred_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all(), int(total)
+24 -5
View File
@@ -119,6 +119,25 @@ async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Opt
return user
async def get_user_by_remnawave_uuid(db: AsyncSession, remnawave_uuid: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.remnawave_uuid == remnawave_uuid)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def create_unique_referral_code(db: AsyncSession) -> str:
max_attempts = 10
@@ -379,11 +398,11 @@ async def subtract_user_balance(
*,
consume_promo_offer: bool = False,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.error(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.error(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.error(f" 📝 Описание: {description}")
logger.info(f"💸 ОТЛАДКА subtract_user_balance:")
logger.info(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
logger.info(f" 💰 Баланс до списания: {user.balance_kopeks} копеек")
logger.info(f" 💸 Сумма к списанию: {amount_kopeks} копеек")
logger.info(f" 📝 Описание: {description}")
log_context: Optional[Dict[str, object]] = None
if consume_promo_offer:
+32 -10
View File
@@ -5,7 +5,8 @@ from typing import Optional, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserMessage
from app.database.models import User, UserMessage
from app.utils.validators import sanitize_html, validate_html_tags
logger = logging.getLogger(__name__)
@@ -13,15 +14,25 @@ logger = logging.getLogger(__name__)
async def create_user_message(
db: AsyncSession,
message_text: str,
created_by: int,
created_by: Optional[int] = None,
is_active: bool = True,
sort_order: int = 0
) -> UserMessage:
is_valid, error_message = validate_html_tags(message_text)
if not is_valid:
raise ValueError(error_message)
resolved_creator = created_by
if created_by is not None:
result = await db.execute(select(User.id).where(User.id == created_by))
resolved_creator = result.scalar_one_or_none()
message = UserMessage(
message_text=message_text,
is_active=is_active,
sort_order=sort_order,
created_by=created_by
created_by=resolved_creator,
)
db.add(message)
@@ -55,25 +66,33 @@ async def get_random_active_message(db: AsyncSession) -> Optional[str]:
return None
random_message = random.choice(active_messages)
return random_message.message_text
return sanitize_html(random_message.message_text)
async def get_all_user_messages(
db: AsyncSession,
offset: int = 0,
limit: int = 50
limit: int = 50,
include_inactive: bool = True,
) -> List[UserMessage]:
query = select(UserMessage).order_by(UserMessage.created_at.desc())
if not include_inactive:
query = query.where(UserMessage.is_active == True)
result = await db.execute(
select(UserMessage)
.order_by(UserMessage.created_at.desc())
query
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_messages_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(UserMessage.id)))
async def get_user_messages_count(db: AsyncSession, include_inactive: bool = True) -> int:
query = select(func.count(UserMessage.id))
if not include_inactive:
query = query.where(UserMessage.is_active == True)
result = await db.execute(query)
return result.scalar()
@@ -88,8 +107,11 @@ async def update_user_message(
if not message:
return None
if message_text is not None:
is_valid, error_message = validate_html_tags(message_text)
if not is_valid:
raise ValueError(error_message)
message.message_text = message_text
if is_active is not None:
+114 -2
View File
@@ -1,10 +1,10 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WelcomeText
from app.database.models import User, WelcomeText
logger = logging.getLogger(__name__)
@@ -45,6 +45,37 @@ async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
'id': None
}
async def get_welcome_text_by_id(db: AsyncSession, welcome_text_id: int) -> Optional[WelcomeText]:
result = await db.execute(
select(WelcomeText).where(WelcomeText.id == welcome_text_id)
)
return result.scalar_one_or_none()
async def list_welcome_texts(
db: AsyncSession,
*,
include_inactive: bool = True,
limit: int = 50,
offset: int = 0,
):
query = select(WelcomeText).order_by(WelcomeText.updated_at.desc())
if not include_inactive:
query = query.where(WelcomeText.is_active == True)
result = await db.execute(query.limit(limit).offset(offset))
return result.scalars().all()
async def count_welcome_texts(db: AsyncSession, *, include_inactive: bool = True) -> int:
query = select(func.count(WelcomeText.id))
if not include_inactive:
query = query.where(WelcomeText.is_active == True)
result = await db.execute(query)
return result.scalar()
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
try:
result = await db.execute(
@@ -113,6 +144,87 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
await db.rollback()
return False
async def create_welcome_text(
db: AsyncSession,
*,
text_content: str,
created_by: Optional[int] = None,
is_enabled: bool = True,
is_active: bool = True,
) -> WelcomeText:
resolved_creator = created_by
if created_by is not None:
result = await db.execute(select(User.id).where(User.id == created_by))
resolved_creator = result.scalar_one_or_none()
if is_active:
await db.execute(update(WelcomeText).values(is_active=False))
welcome_text = WelcomeText(
text_content=text_content,
is_active=is_active,
is_enabled=is_enabled,
created_by=resolved_creator,
)
db.add(welcome_text)
await db.commit()
await db.refresh(welcome_text)
logger.info(
"✅ Создан приветственный текст ID %s (активный=%s, включен=%s)",
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
)
return welcome_text
async def update_welcome_text(
db: AsyncSession,
welcome_text: WelcomeText,
*,
text_content: Optional[str] = None,
is_enabled: Optional[bool] = None,
is_active: Optional[bool] = None,
) -> WelcomeText:
if is_active:
await db.execute(
update(WelcomeText)
.where(WelcomeText.id != welcome_text.id)
.values(is_active=False)
)
if text_content is not None:
welcome_text.text_content = text_content
if is_enabled is not None:
welcome_text.is_enabled = is_enabled
if is_active is not None:
welcome_text.is_active = is_active
welcome_text.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(welcome_text)
logger.info(
"📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)",
welcome_text.id,
welcome_text.is_active,
welcome_text.is_enabled,
)
return welcome_text
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
await db.delete(welcome_text)
await db.commit()
logger.info("🗑️ Удален приветственный текст ID %s", welcome_text.id)
async def get_current_welcome_text_or_default() -> str:
return (
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
+30 -1
View File
@@ -541,7 +541,11 @@ class PromoGroup(Base):
"traffic": self.traffic_discount_percent,
"devices": self.device_discount_percent,
}
percent = mapping.get(category, 0)
percent = mapping.get(category) or 0
if percent == 0 and self.is_default:
base_period_discount = self._get_period_discount(period_days)
percent = max(percent, base_period_discount)
return max(0, min(100, percent))
@@ -591,6 +595,7 @@ class User(Base):
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
referral_commission_percent = Column(Integer, nullable=True)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
promo_offer_discount_expires_at = Column(DateTime, nullable=True)
@@ -1078,6 +1083,30 @@ class SentNotification(Base):
subscription = relationship("Subscription", backref="sent_notifications")
class SubscriptionEvent(Base):
__tablename__ = "subscription_events"
id = Column(Integer, primary_key=True, index=True)
event_type = Column(String(50), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
subscription_id = Column(
Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True
)
transaction_id = Column(
Integer, ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
)
amount_kopeks = Column(Integer, nullable=True)
currency = Column(String(16), nullable=True)
message = Column(Text, nullable=True)
occurred_at = Column(DateTime, nullable=False, default=func.now())
extra = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="subscription_events")
subscription = relationship("Subscription", backref="subscription_events")
transaction = relationship("Transaction", backref="subscription_events")
class DiscountOffer(Base):
__tablename__ = "discount_offers"
__table_args__ = (
+138 -1
View File
@@ -2742,6 +2742,35 @@ async def fix_foreign_keys_for_user_deletion():
logger.error(f"Ошибка обновления внешних ключей: {e}")
return False
async def add_referral_commission_percent_column() -> bool:
column_exists = await check_column_exists('users', 'referral_commission_percent')
if column_exists:
logger.info("️ Колонка referral_commission_percent уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
elif db_type == 'postgresql':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INTEGER NULL"
elif db_type == 'mysql':
alter_sql = "ALTER TABLE users ADD COLUMN referral_commission_percent INT NULL"
else:
logger.error(f"Неподдерживаемый тип БД для добавления referral_commission_percent: {db_type}")
return False
await conn.execute(text(alter_sql))
logger.info("✅ Добавлена колонка referral_commission_percent в таблицу users")
return True
except Exception as error:
logger.error(f"Ошибка добавления referral_commission_percent: {error}")
return False
async def add_referral_system_columns():
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
@@ -2867,6 +2896,94 @@ async def create_subscription_conversions_table():
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
return False
async def create_subscription_events_table():
table_exists = await check_table_exists("subscription_events")
if table_exists:
logger.info("Таблица subscription_events уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE subscription_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL,
subscription_id INTEGER NULL,
transaction_id INTEGER NULL,
amount_kopeks INTEGER NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE subscription_events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL,
transaction_id INTEGER NULL REFERENCES transactions(id) ON DELETE SET NULL,
amount_kopeks INTEGER NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
elif db_type == "mysql":
create_sql = """
CREATE TABLE subscription_events (
id INT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
user_id INT NOT NULL,
subscription_id INT NULL,
transaction_id INT NULL,
amount_kopeks INT NULL,
currency VARCHAR(16) NULL,
message TEXT NULL,
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
extra JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE SET NULL
);
CREATE INDEX ix_subscription_events_event_type ON subscription_events(event_type);
CREATE INDEX ix_subscription_events_user_id ON subscription_events(user_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы subscription_events: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица subscription_events успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы subscription_events: {e}")
return False
async def fix_subscription_duplicates_universal():
async with engine.begin() as conn:
db_type = await get_database_type()
@@ -3721,6 +3838,12 @@ async def run_universal_migration():
if not referral_migration_success:
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
commission_column_ready = await add_referral_commission_percent_column()
if commission_column_ready:
logger.info("✅ Колонка referral_commission_percent готова")
else:
logger.warning("⚠️ Проблемы с колонкой referral_commission_percent")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
system_settings_ready = await create_system_settings_table()
if system_settings_ready:
@@ -4052,7 +4175,14 @@ async def run_universal_migration():
logger.info("✅ Таблица subscription_conversions готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SUBSCRIPTION_EVENTS ===")
events_created = await create_subscription_events_table()
if events_created:
logger.info("✅ Таблица subscription_events готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_events")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -4089,6 +4219,7 @@ async def run_universal_migration():
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Heleket таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Таблица событий подписок создана")
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
logger.info("✅ Медиа поля в broadcast_history добавлены")
logger.info("✅ Дубликаты подписок исправлены")
@@ -4112,6 +4243,7 @@ async def check_migration_status():
"broadcast_history_media_fields": False,
"subscription_duplicates": False,
"subscription_conversions_table": False,
"subscription_events_table": False,
"promo_groups_table": False,
"server_promo_groups_table": False,
"server_squads_trial_column": False,
@@ -4126,6 +4258,7 @@ async def check_migration_status():
"users_promo_offer_discount_percent_column": False,
"users_promo_offer_discount_source_column": False,
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"subscription_crypto_link_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
@@ -4145,6 +4278,7 @@ async def check_migration_status():
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
status["public_offers_table"] = await check_table_exists('public_offers')
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
status["subscription_events_table"] = await check_table_exists('subscription_events')
status["promo_groups_table"] = await check_table_exists('promo_groups')
status["server_promo_groups_table"] = await check_table_exists('server_squad_promo_groups')
status["server_squads_trial_column"] = await check_column_exists('server_squads', 'is_trial_eligible')
@@ -4167,6 +4301,7 @@ async def check_migration_status():
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
media_fields_exist = (
@@ -4200,6 +4335,7 @@ async def check_migration_status():
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_events_table": "Таблица событий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок",
"promo_groups_table": "Таблица промо-групп",
"server_promo_groups_table": "Связи серверов и промогрупп",
@@ -4213,6 +4349,7 @@ async def check_migration_status():
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
+24 -6
View File
@@ -28,11 +28,28 @@ class HeleketService:
def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key)
def _prepare_body(self, payload: Dict[str, Any]) -> str:
cleaned = {key: value for key, value in payload.items() if value is not None}
serialized = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _prepare_body(
self,
payload: Dict[str, Any],
*,
ignore_none: bool,
sort_keys: bool,
) -> str:
if ignore_none:
cleaned = {key: value for key, value in payload.items() if value is not None}
else:
cleaned = dict(payload)
serialized = json.dumps(
cleaned,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=sort_keys,
)
if "/" in serialized:
serialized = serialized.replace("/", "\\/")
return serialized
def _generate_signature(self, body: str) -> str:
@@ -52,7 +69,7 @@ class HeleketService:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
body = self._prepare_body(payload)
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
@@ -145,8 +162,9 @@ class HeleketService:
logger.error("Heleket webhook без подписи")
return False
data = {key: value for key, value in payload.items() if key != "sign"}
body = self._prepare_body(data)
data = dict(payload)
data.pop("sign", None)
body = self._prepare_body(data, ignore_none=False, sort_keys=False)
expected = self._generate_signature(body)
is_valid = hmac.compare_digest(expected, str(signature))
+325 -49
View File
@@ -27,14 +27,22 @@ class TrafficLimitStrategy(Enum):
MONTH = "MONTH"
@dataclass
class UserTraffic:
"""Данные о трафике пользователя (новая структура API)"""
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
online_at: Optional[datetime] = None
first_connected_at: Optional[datetime] = None
last_connected_node_uuid: Optional[str] = None
@dataclass
class RemnaWaveUser:
uuid: str
short_uuid: str
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
@@ -47,18 +55,60 @@ class RemnaWaveUser:
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
user_traffic: Optional[UserTraffic] = None
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
external_squad_uuid: Optional[str] = None
id: Optional[int] = None
@property
def used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.used_traffic_bytes
return 0
@property
def lifetime_used_traffic_bytes(self) -> int:
"""Обратная совместимость: получение lifetime_used_traffic_bytes из user_traffic"""
if self.user_traffic:
return self.user_traffic.lifetime_used_traffic_bytes
return 0
@property
def online_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение online_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.online_at
return None
@property
def first_connected_at(self) -> Optional[datetime]:
"""Обратная совместимость: получение first_connected_at из user_traffic"""
if self.user_traffic:
return self.user_traffic.first_connected_at
return None
@dataclass
class RemnaWaveInbound:
"""Структура inbound для Internal Squad"""
uuid: str
profile_uuid: str
tag: str
type: str
network: Optional[str] = None
security: Optional[str] = None
port: Optional[int] = None
raw_inbound: Optional[Any] = None
@dataclass
@@ -67,7 +117,21 @@ class RemnaWaveInternalSquad:
name: str
members_count: int
inbounds_count: int
inbounds: List[Dict[str, Any]]
inbounds: List[RemnaWaveInbound]
view_position: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@dataclass
class RemnaWaveAccessibleNode:
"""Доступная нода для Internal Squad"""
uuid: str
node_name: str
country_code: str
config_profile_uuid: str
config_profile_name: str
active_inbounds: List[str]
@dataclass
@@ -78,11 +142,39 @@ class RemnaWaveNode:
country_code: str
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int]
traffic_used_bytes: Optional[int]
traffic_limit_bytes: Optional[int]
port: Optional[int] = None
is_connecting: bool = False
xray_version: Optional[str] = None
node_version: Optional[str] = None
view_position: int = 0
tags: Optional[List[str]] = None
# Новые поля API
last_status_change: Optional[datetime] = None
last_status_message: Optional[str] = None
xray_uptime: Optional[str] = None
is_traffic_tracking_active: bool = False
traffic_reset_day: Optional[int] = None
notify_percent: Optional[int] = None
consumption_multiplier: float = 1.0
cpu_count: Optional[int] = None
cpu_model: Optional[str] = None
total_ram: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
provider_uuid: Optional[str] = None
@property
def is_node_online(self) -> bool:
"""Обратная совместимость: is_node_online = is_connected"""
return self.is_connected
@property
def is_xray_running(self) -> bool:
"""Обратная совместимость: xray работает если нода подключена"""
return self.is_connected
@dataclass
@@ -299,12 +391,14 @@ class RemnaWaveAPI:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('POST', '/api/users', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_user_by_uuid(self, uuid: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/{uuid}')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
@@ -316,7 +410,8 @@ class RemnaWaveAPI:
users_data = response.get('response', [])
if not users_data:
return []
return [self._parse_user(user) for user in users_data]
users = [self._parse_user(user) for user in users_data]
return [await self.enrich_user_with_happ_link(u) for u in users]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
@@ -325,7 +420,8 @@ class RemnaWaveAPI:
async def get_user_by_username(self, username: str) -> Optional[RemnaWaveUser]:
try:
response = await self._make_request('GET', f'/api/users/by-username/{username}')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
@@ -369,38 +465,57 @@ class RemnaWaveAPI:
data['activeInternalSquads'] = active_internal_squads
response = await self._make_request('PATCH', '/api/users', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def delete_user(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/users/{uuid}')
return response['response']['isDeleted']
async def enable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/enable')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def disable_user(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/disable')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def reset_user_traffic(self, uuid: str) -> RemnaWaveUser:
response = await self._make_request('POST', f'/api/users/{uuid}/actions/reset-traffic')
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def revoke_user_subscription(self, uuid: str, new_short_uuid: Optional[str] = None) -> RemnaWaveUser:
data = {}
if new_short_uuid:
data['shortUuid'] = new_short_uuid
response = await self._make_request('POST', f'/api/users/{uuid}/actions/revoke', data)
return self._parse_user(response['response'])
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_all_users(self, start: int = 0, size: int = 100) -> Dict[str, Any]:
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> Dict[str, Any]:
"""
Получает список всех пользователей.
Args:
start: Смещение для пагинации
size: Размер страницы
enrich_happ_links: Если True, для каждого пользователя будет запрошена
зашифрованная happ ссылка (медленно для больших списков)
"""
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
users = [self._parse_user(user) for user in response['response']['users']]
if enrich_happ_links:
users = [await self.enrich_user_with_happ_link(u) for u in users]
return {
'users': [self._parse_user(user) for user in response['response']['users']],
'users': users,
'total': response['response']['total']
}
@@ -444,8 +559,38 @@ class RemnaWaveAPI:
async def delete_internal_squad(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/internal-squads/{uuid}')
return response['response']['isDeleted']
async def get_internal_squad_accessible_nodes(self, uuid: str) -> List[RemnaWaveAccessibleNode]:
"""Получает список доступных нод для Internal Squad"""
try:
response = await self._make_request('GET', f'/api/internal-squads/{uuid}/accessible-nodes')
return [self._parse_accessible_node(node) for node in response['response']['accessibleNodes']]
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def add_users_to_internal_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_internal_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из Internal Squad (bulk action)"""
response = await self._make_request('POST', f'/api/internal-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_internal_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveInternalSquad]:
"""
Изменяет порядок Internal Squads
items: список словарей с uuid и viewPosition
Пример: [{'uuid': '...', 'viewPosition': 0}, {'uuid': '...', 'viewPosition': 1}]
"""
data = {'items': items}
response = await self._make_request('POST', '/api/internal-squads/actions/reorder', data)
return [self._parse_internal_squad(squad) for squad in response['response']['internalSquads']]
async def get_all_nodes(self) -> List[RemnaWaveNode]:
response = await self._make_request('GET', '/api/nodes')
return [self._parse_node(node) for node in response['response']]
@@ -478,7 +623,13 @@ class RemnaWaveAPI:
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
return self._parse_subscription_info(response['response'])
info = self._parse_subscription_info(response['response'])
# Обогащаем happ_crypto_link если его нет но есть subscription_url
if not info.happ_crypto_link and info.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(info.subscription_url)
if encrypted:
info.happ_crypto_link = encrypted
return info
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
@@ -534,6 +685,25 @@ class RemnaWaveAPI:
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return response['response']
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
"""
Получает статистику использования трафика пользователем за указанный период
Args:
user_uuid: UUID пользователя
start_date: Начальная дата в формате ISO (например, "2025-12-09T00:00:00.000Z")
end_date: Конечная дата в формате ISO (например, "2025-12-09T23:59:59.999Z")
Returns:
Словарь с информацией о трафике пользователя за указанный период
"""
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/users/stats/usage/{user_uuid}/range', params=params)
return response
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
@@ -584,44 +754,102 @@ class RemnaWaveAPI:
except Exception as e:
logger.error(f"Ошибка удаления устройства {device_hwid}: {e}")
return False
async def encrypt_happ_crypto_link(self, link_to_encrypt: str) -> Optional[str]:
"""
Шифрует ссылку подписки через API Remnawave.
Возвращает зашифрованную happ:// ссылку или None при ошибке.
"""
try:
data = {"linkToEncrypt": link_to_encrypt}
response = await self._make_request('POST', '/api/system/tools/happ/encrypt', data)
return response.get('response', {}).get('encryptedLink')
except RemnaWaveAPIError as e:
logger.warning(f"Не удалось зашифровать happ ссылку: {e.message}")
return None
except Exception as e:
logger.warning(f"Ошибка при шифровании happ ссылки: {e}")
return None
async def enrich_user_with_happ_link(self, user: RemnaWaveUser) -> RemnaWaveUser:
"""
Обогащает объект пользователя зашифрованной happ ссылкой,
если она отсутствует но есть subscription_url.
"""
if not user.happ_crypto_link and user.subscription_url:
encrypted = await self.encrypt_happ_crypto_link(user.subscription_url)
if encrypted:
user.happ_crypto_link = encrypted
return user
def _parse_user_traffic(self, traffic_data: Optional[Dict]) -> Optional[UserTraffic]:
"""Парсит данные трафика из нового формата API"""
if not traffic_data:
return None
return UserTraffic(
used_traffic_bytes=int(traffic_data.get('usedTrafficBytes', 0)),
lifetime_used_traffic_bytes=int(traffic_data.get('lifetimeUsedTrafficBytes', 0)),
online_at=self._parse_optional_datetime(traffic_data.get('onlineAt')),
first_connected_at=self._parse_optional_datetime(traffic_data.get('firstConnectedAt')),
last_connected_node_uuid=traffic_data.get('lastConnectedNodeUuid')
)
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
# Парсим userTraffic из нового формата API
user_traffic = self._parse_user_traffic(user_data.get('userTraffic'))
# Получаем status с fallback на ACTIVE
status_str = user_data.get('status') or 'ACTIVE'
try:
status = UserStatus(status_str)
except ValueError:
logger.warning(f"Неизвестный статус пользователя: {status_str}, используем ACTIVE")
status = UserStatus.ACTIVE
# Получаем trafficLimitStrategy с fallback
strategy_str = user_data.get('trafficLimitStrategy') or 'NO_RESET'
try:
traffic_strategy = TrafficLimitStrategy(strategy_str)
except ValueError:
logger.warning(f"Неизвестная стратегия трафика: {strategy_str}, используем NO_RESET")
traffic_strategy = TrafficLimitStrategy.NO_RESET
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
username=user_data['username'],
status=UserStatus(user_data['status']),
used_traffic_bytes=int(user_data['usedTrafficBytes']),
lifetime_used_traffic_bytes=int(user_data['lifetimeUsedTrafficBytes']),
traffic_limit_bytes=user_data['trafficLimitBytes'],
traffic_limit_strategy=TrafficLimitStrategy(user_data['trafficLimitStrategy']),
status=status,
traffic_limit_bytes=user_data.get('trafficLimitBytes', 0),
traffic_limit_strategy=traffic_strategy,
expire_at=datetime.fromisoformat(user_data['expireAt'].replace('Z', '+00:00')),
telegram_id=user_data.get('telegramId'),
email=user_data.get('email'),
hwid_device_limit=user_data.get('hwidDeviceLimit'),
description=user_data.get('description'),
tag=user_data.get('tag'),
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
subscription_url=user_data.get('subscriptionUrl', ''),
active_internal_squads=user_data.get('activeInternalSquads', []),
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
user_traffic=user_traffic,
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
happ_crypto_link=happ_crypto_link,
external_squad_uuid=user_data.get('externalSquadUuid'),
id=user_data.get('id')
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
@@ -629,28 +857,76 @@ class RemnaWaveAPI:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_inbound(self, inbound_data: Dict) -> RemnaWaveInbound:
"""Парсит данные inbound"""
return RemnaWaveInbound(
uuid=inbound_data['uuid'],
profile_uuid=inbound_data['profileUuid'],
tag=inbound_data['tag'],
type=inbound_data['type'],
network=inbound_data.get('network'),
security=inbound_data.get('security'),
port=inbound_data.get('port'),
raw_inbound=inbound_data.get('rawInbound')
)
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
info = squad_data.get('info', {})
inbounds_raw = squad_data.get('inbounds', [])
inbounds = [self._parse_inbound(ib) for ib in inbounds_raw] if inbounds_raw else []
return RemnaWaveInternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
members_count=squad_data['info']['membersCount'],
inbounds_count=squad_data['info']['inboundsCount'],
inbounds=squad_data['inbounds']
members_count=info.get('membersCount', 0),
inbounds_count=info.get('inboundsCount', 0),
inbounds=inbounds,
view_position=squad_data.get('viewPosition', 0),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
def _parse_accessible_node(self, node_data: Dict) -> RemnaWaveAccessibleNode:
"""Парсит данные доступной ноды для Internal Squad"""
return RemnaWaveAccessibleNode(
uuid=node_data['uuid'],
node_name=node_data['nodeName'],
country_code=node_data['countryCode'],
config_profile_uuid=node_data['configProfileUuid'],
config_profile_name=node_data['configProfileName'],
active_inbounds=node_data.get('activeInbounds', [])
)
def _parse_node(self, node_data: Dict) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data['uuid'],
name=node_data['name'],
address=node_data['address'],
country_code=node_data['countryCode'],
is_connected=node_data['isConnected'],
is_disabled=node_data['isDisabled'],
is_node_online=node_data['isNodeOnline'],
is_xray_running=node_data['isXrayRunning'],
country_code=node_data.get('countryCode', ''),
is_connected=node_data.get('isConnected', False),
is_disabled=node_data.get('isDisabled', False),
users_online=node_data.get('usersOnline'),
traffic_used_bytes=node_data.get('trafficUsedBytes'),
traffic_limit_bytes=node_data.get('trafficLimitBytes')
traffic_limit_bytes=node_data.get('trafficLimitBytes'),
port=node_data.get('port'),
is_connecting=node_data.get('isConnecting', False),
xray_version=node_data.get('xrayVersion'),
node_version=node_data.get('nodeVersion'),
view_position=node_data.get('viewPosition', 0),
tags=node_data.get('tags', []),
# Новые поля API
last_status_change=self._parse_optional_datetime(node_data.get('lastStatusChange')),
last_status_message=node_data.get('lastStatusMessage'),
xray_uptime=node_data.get('xrayUptime'),
is_traffic_tracking_active=node_data.get('isTrafficTrackingActive', False),
traffic_reset_day=node_data.get('trafficResetDay'),
notify_percent=node_data.get('notifyPercent'),
consumption_multiplier=node_data.get('consumptionMultiplier', 1.0),
cpu_count=node_data.get('cpuCount'),
cpu_model=node_data.get('cpuModel'),
total_ram=node_data.get('totalRam'),
created_at=self._parse_optional_datetime(node_data.get('createdAt')),
updated_at=self._parse_optional_datetime(node_data.get('updatedAt')),
provider_uuid=node_data.get('providerUuid')
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
+10 -8
View File
@@ -254,17 +254,17 @@ class YooKassaWebhookHandler:
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment ID")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
# Извлекаем ID платежа из вебхука для предотвращения дублирования
yookassa_payment_id = webhook_data.get("object", {}).get("id")
if not yookassa_payment_id:
logger.warning("⚠️ Webhook YooKassa без ID платежа")
return web.Response(status=400, text="No payment id")
if event_type not in YOOKASSA_ALLOWED_EVENTS:
logger.info(f"ℹ️ Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
@@ -274,8 +274,10 @@ class YooKassaWebhookHandler:
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
existing_transaction = None
if yookassa_payment_id and hasattr(db, "execute"):
existing_transaction = await get_transaction_by_external_id(db, yookassa_payment_id, PaymentMethod.YOOKASSA)
if existing_transaction and event_type == "payment.succeeded":
logger.info(f"️ Платеж YooKassa {yookassa_payment_id} уже был обработан. Пропускаем дублирующий вебхук.")
return web.Response(status=200, text="OK")
+36 -1
View File
@@ -1 +1,36 @@
# Инициализация админских обработчиков
# Инициализация админских обработчиков
from . import (
backup,
blacklist,
bot_configuration,
bulk_ban,
campaigns,
faq,
main,
maintenance,
messages,
monitoring,
payments,
polls,
pricing,
privacy_policy,
promo_groups,
promo_offers,
promocodes,
public_offer,
referrals,
remnawave,
reports,
rules,
servers,
statistics,
subscriptions,
support_settings,
system_logs,
tickets,
trials,
updates,
user_messages,
users,
welcome_text,
)
+365
View File
@@ -0,0 +1,365 @@
"""
Обработчики админ-панели для управления черным списком
"""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import get_admin_users_keyboard
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_blacklist_settings(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Показывает настройки черного списка
"""
logger.info(f"Вызван обработчик show_blacklist_settings для пользователя {callback.from_user.id}")
is_enabled = blacklist_service.is_blacklist_check_enabled()
github_url = blacklist_service.get_blacklist_github_url()
blacklist_count = len(await blacklist_service.get_all_blacklisted_users())
status_text = "✅ Включена" if is_enabled else "❌ Отключена"
url_text = github_url if github_url else "Не задан"
text = f"""
🔐 <b>Настройки черного списка</b>
Статус: {status_text}
URL к черному списку: <code>{url_text}</code>
Количество записей: {blacklist_count}
Действия:
"""
keyboard = [
[
types.InlineKeyboardButton(
text="🔄 Обновить список" if is_enabled else "🔄 Обновить (откл.)",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="📋 Просмотреть список" if is_enabled else "📋 Просмотр (откл.)",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="✏️ URL к GitHub" if not github_url else "✏️ Изменить URL",
callback_data="admin_blacklist_set_url"
)
],
[
types.InlineKeyboardButton(
text="✅ Включить" if not is_enabled else "❌ Отключить",
callback_data="admin_blacklist_toggle"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад к пользователям",
callback_data="admin_users"
)
]
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
@admin_required
@error_handler
async def toggle_blacklist(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Переключает статус проверки черного списка
"""
# Текущая реализация использует настройки из .env
# Для полной реализации нужно будет создать сервис настроек
is_enabled = blacklist_service.is_blacklist_check_enabled()
# В реальной реализации нужно будет изменить настройку в базе данных
# или в системе настроек, но сейчас просто покажем статус
new_status = not is_enabled
status_text = "включена" if new_status else "отключена"
await callback.message.edit_text(
f"Статус проверки черного списка: {status_text}\n\n"
f"Для изменения статуса проверки черного списка измените значение\n"
f"<code>BLACKLIST_CHECK_ENABLED</code> в файле <code>.env</code>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить статус",
callback_data="admin_blacklist_settings"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def update_blacklist(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Обновляет черный список из GitHub
"""
success, message = await blacklist_service.force_update_blacklist()
if success:
await callback.message.edit_text(
f"{message}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="📋 Просмотреть список",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="🔄 Ручное обновление",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
else:
await callback.message.edit_text(
f"❌ Ошибка обновления: {message}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Повторить",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def show_blacklist_users(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Показывает список пользователей в черном списке
"""
blacklist_users = await blacklist_service.get_all_blacklisted_users()
if not blacklist_users:
text = "Черный список пуст"
else:
text = f"🔐 <b>Черный список ({len(blacklist_users)} записей)</b>\n\n"
# Показываем первые 20 записей
for i, (tg_id, username, reason) in enumerate(blacklist_users[:20], 1):
text += f"{i}. <code>{tg_id}</code> {username or ''}{reason}\n"
if len(blacklist_users) > 20:
text += f"\n... и еще {len(blacklist_users) - 20} записей"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить",
callback_data="admin_blacklist_view"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await callback.answer()
@admin_required
@error_handler
async def start_set_blacklist_url(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Начинает процесс установки URL к черному списку
"""
current_url = blacklist_service.get_blacklist_github_url() or "не задан"
await callback.message.edit_text(
f"Введите новый URL к файлу черного списка на GitHub\n\n"
f"Текущий URL: {current_url}\n\n"
f"Пример: https://raw.githubusercontent.com/username/repository/main/blacklist.txt\n\n"
f"Для отмены используйте команду /cancel",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.set_state("waiting_for_blacklist_url")
await callback.answer()
@admin_required
@error_handler
async def process_blacklist_url(
message: types.Message,
db_user: User,
state: FSMContext
):
"""
Обрабатывает введенный URL к черному списку
"""
url = message.text.strip()
# В реальной реализации нужно сохранить URL в систему настроек
# В текущей реализации просто выводим сообщение
if url.lower() in ['/cancel', 'отмена', 'cancel']:
await message.answer(
"Настройка URL отменена",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.clear()
return
# Проверяем, что URL выглядит корректно
if not url.startswith(('http://', 'https://')):
await message.answer(
"❌ Некорректный URL. URL должен начинаться с http:// или https://",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
return
# В реальной системе здесь нужно сохранить URL в базу данных настроек
# или в систему конфигурации
await message.answer(
f"✅ URL к черному списку установлен:\n<code>{url}</code>\n\n"
f"Для применения изменений перезапустите бота или измените значение\n"
f"<code>BLACKLIST_GITHUB_URL</code> в файле <code>.env</code>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text="🔄 Обновить список",
callback_data="admin_blacklist_update"
)
],
[
types.InlineKeyboardButton(
text="🔐 Настройки черного списка",
callback_data="admin_blacklist_settings"
)
]
])
)
await state.clear()
def register_blacklist_handlers(dp):
"""
Регистрация обработчиков черного списка
"""
# Обработчик показа настроек черного списка
# Этот обработчик нужно будет вызывать из меню пользователей или отдельно
dp.callback_query.register(
show_blacklist_settings,
lambda c: c.data == "admin_blacklist_settings"
)
# Обработчики для взаимодействия с черным списком
dp.callback_query.register(
toggle_blacklist,
lambda c: c.data == "admin_blacklist_toggle"
)
dp.callback_query.register(
update_blacklist,
lambda c: c.data == "admin_blacklist_update"
)
dp.callback_query.register(
show_blacklist_users,
lambda c: c.data == "admin_blacklist_view"
)
dp.callback_query.register(
start_set_blacklist_url,
lambda c: c.data == "admin_blacklist_set_url"
)
# Обработчик сообщений для установки URL (работает только в нужном состоянии)
dp.message.register(
process_blacklist_url,
lambda m: True # Фильтр будет внутри функции
)
+178
View File
@@ -0,0 +1,178 @@
"""
Обработчики команд для массовой блокировки пользователей
"""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.bulk_ban_service import bulk_ban_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.keyboards.admin import get_admin_users_keyboard
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def start_bulk_ban_process(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
"""
Начало процесса массовой блокировки пользователей
"""
await callback.message.edit_text(
"🛑 <b>Массовая блокировка пользователей</b>\n\n"
"Введите список Telegram ID для блокировки.\n\n"
"<b>Форматы ввода:</b>\n"
"• По одному ID на строку\n"
"• Через запятую\n"
"• Через пробел\n\n"
"Пример:\n"
"<code>123456789\n"
"987654321\n"
"111222333</code>\n\n"
"Или:\n"
"<code>123456789, 987654321, 111222333</code>\n\n"
"Для отмены используйте команду /cancel",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_users")]
])
)
await state.set_state(AdminStates.waiting_for_bulk_ban_list)
await callback.answer()
@admin_required
@error_handler
async def process_bulk_ban_list(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""
Обработка списка Telegram ID и выполнение массовой блокировки
"""
if not message.text:
await message.answer(
"❌ Отправьте текстовое сообщение со списком Telegram ID",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
input_text = message.text.strip()
if not input_text:
await message.answer(
"❌ Введите корректный список Telegram ID",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
# Парсим ID из текста
try:
telegram_ids = await bulk_ban_service.parse_telegram_ids_from_text(input_text)
except Exception as e:
logger.error(f"Ошибка парсинга Telegram ID: {e}")
await message.answer(
"❌ Ошибка при обработке списка ID. Проверьте формат ввода.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
if not telegram_ids:
await message.answer(
"❌ Не найдено корректных Telegram ID в списке",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
if len(telegram_ids) > 1000: # Ограничение на количество ID за раз
await message.answer(
f"❌ Слишком много ID в списке ({len(telegram_ids)}). Максимум: 1000",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
return
# Выполняем массовую блокировку
try:
successfully_banned, not_found, error_ids = await bulk_ban_service.ban_users_by_telegram_ids(
db=db,
admin_user_id=db_user.id,
telegram_ids=telegram_ids,
reason="Массовая блокировка администратором",
bot=message.bot,
notify_admin=True,
admin_name=db_user.full_name
)
# Подготавливаем сообщение с результатами
result_text = f"✅ <b>Массовая блокировка завершена</b>\n\n"
result_text += f"📊 <b>Результаты:</b>\n"
result_text += f"✅ Успешно заблокировано: {successfully_banned}\n"
result_text += f"❌ Не найдено: {not_found}\n"
result_text += f"💥 Ошибок: {len(error_ids)}\n\n"
result_text += f"📈 Всего обработано: {len(telegram_ids)}"
if successfully_banned > 0:
result_text += f"\n🎯 Процент успеха: {round((successfully_banned/len(telegram_ids))*100, 1)}%"
# Добавляем информацию об ошибках, если есть
if error_ids:
result_text += f"\n\n⚠️ <b>Telegram ID с ошибками:</b>\n"
result_text += f"<code>{', '.join(map(str, error_ids[:10]))}</code>" # Показываем первые 10
if len(error_ids) > 10:
result_text += f" и еще {len(error_ids) - 10}..."
await message.answer(
result_text,
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="👥 К пользователям", callback_data="admin_users")]
])
)
except Exception as e:
logger.error(f"Ошибка при выполнении массовой блокировки: {e}")
await message.answer(
"❌ Произошла ошибка при выполнении массовой блокировки",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔙 Назад", callback_data="admin_users")]
])
)
await state.clear()
def register_bulk_ban_handlers(dp):
"""
Регистрация обработчиков команд для массовой блокировки
"""
# Обработчик команды начала массовой блокировки
dp.callback_query.register(
start_bulk_ban_process,
lambda c: c.data == "admin_bulk_ban_start"
)
# Обработчик текстового сообщения с ID для блокировки
dp.message.register(
process_bulk_ban_list,
AdminStates.waiting_for_bulk_ban_list
)
+140 -42
View File
@@ -3,8 +3,10 @@ import asyncio
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import InterfaceError
from sqlalchemy import select, func, and_, or_
from app.config import settings
@@ -16,6 +18,7 @@ from app.database.models import (
SubscriptionStatus,
BroadcastHistory,
)
from app.database.database import AsyncSessionLocal
from app.keyboards.admin import (
get_admin_messages_keyboard, get_broadcast_target_keyboard,
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
@@ -86,6 +89,56 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = "ru") -> O
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _persist_broadcast_result(
db: AsyncSession,
broadcast_history: BroadcastHistory,
sent_count: int,
failed_count: int,
status: str,
) -> None:
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = datetime.utcnow()
try:
await db.commit()
return
except InterfaceError as error:
logger.warning(
"Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз",
exc_info=error,
)
await db.rollback()
try:
async with AsyncSessionLocal() as retry_session:
retry_history = await retry_session.get(BroadcastHistory, broadcast_history.id)
if not retry_history:
logger.critical(
"Не удалось найти запись BroadcastHistory #%s для повторной записи результатов",
broadcast_history.id,
)
return
retry_history.sent_count = sent_count
retry_history.failed_count = failed_count
retry_history.status = status
retry_history.completed_at = broadcast_history.completed_at
await retry_session.commit()
logger.info(
"Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)",
broadcast_history.id,
)
except Exception as retry_error:
logger.critical(
"Не удалось сохранить результаты рассылки после восстановления подключения",
exc_info=retry_error,
)
@admin_required
@error_handler
async def show_messages_menu(
@@ -764,52 +817,77 @@ async def confirm_broadcast(
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
async with semaphore:
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
for attempt in range(3):
try:
if has_media and media_file_id:
if media_type == "photo":
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "video":
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
elif media_type == "document":
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
await asyncio.sleep(per_message_delay)
return True, user.telegram_id
except TelegramRetryAfter as e:
retry_delay = min(e.retry_after + 1, 30)
logger.warning(
f"Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек."
)
return True, user.telegram_id
except Exception as e:
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
return False, user.telegram_id
await asyncio.sleep(retry_delay)
except TelegramForbiddenError:
# Пользователь мог удалить бота или запретить сообщения
logger.info(f"Рассылка недоступна для пользователя {user.telegram_id}: Forbidden")
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(
f"Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}"
)
return False, user.telegram_id
except Exception as e:
logger.error(
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
)
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
batch_size = 100
batch_size = 50
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
@@ -826,13 +904,16 @@ async def confirm_broadcast(
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
await asyncio.sleep(0.25)
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = "completed" if failed_count == 0 else "partial"
broadcast_history.completed_at = datetime.utcnow()
await db.commit()
status = "completed" if failed_count == 0 else "partial"
await _persist_broadcast_result(
db=db,
broadcast_history=broadcast_history,
sent_count=sent_count,
failed_count=failed_count,
status=status,
)
media_info = ""
if has_media:
@@ -868,7 +949,24 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
async def get_target_users(db: AsyncSession, target: str) -> list:
users = await get_users_list(db, offset=0, limit=10000, status=UserStatus.ACTIVE)
# Загружаем всех активных пользователей батчами, чтобы не ограничиваться 10к
users: list[User] = []
offset = 0
batch_size = 5000
while True:
batch = await get_users_list(
db,
offset=offset,
limit=batch_size,
status=UserStatus.ACTIVE,
)
if not batch:
break
users.extend(batch)
offset += batch_size
if target == "all":
return users
+51 -6
View File
@@ -134,6 +134,24 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="💳 Base subscription price",
action="price",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED",
section="core",
label_ru="🎟️ Базовые скидки для групп",
label_en="🎟️ Base group discounts",
action="toggle",
description_ru="Включает применение базовых скидок для групповых промо-периодов.",
description_en="Enables base discounts for promo group periods.",
),
SettingEntry(
key="BASE_PROMO_GROUP_PERIOD_DISCOUNTS",
section="core",
label_ru="🔖 Скидки по периодам",
label_en="🔖 Period discounts",
action="input",
description_ru="Формат: список пар дней и скидки через запятую (например 30:10,60:20).",
description_en="Format: comma-separated day/discount pairs (e.g. 30:10,60:20).",
),
SettingEntry(
key="DEFAULT_DEVICE_LIMIT",
section="core",
@@ -205,6 +223,26 @@ SETTING_ENTRY_BY_KEY: Dict[str, SettingEntry] = {
entry.key: entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
}
SETTING_ENTRIES: Tuple[SettingEntry, ...] = tuple(
entry for entries in SETTING_ENTRIES_BY_SECTION.values() for entry in entries
)
SETTING_KEY_TO_TOKEN: Dict[str, str] = {
entry.key: f"s{index}" for index, entry in enumerate(SETTING_ENTRIES)
}
SETTING_TOKEN_TO_KEY: Dict[str, str] = {
token: key for key, token in SETTING_KEY_TO_TOKEN.items()
}
def _encode_setting_callback_key(key: str) -> str:
return SETTING_KEY_TO_TOKEN.get(key, key)
def _decode_setting_callback_key(raw: str) -> str:
return SETTING_TOKEN_TO_KEY.get(raw, raw)
def _traffic_package_sort_key(package: Dict[str, Any]) -> Tuple[int, int]:
order_index = TRAFFIC_PACKAGE_ORDER_INDEX.get(package["gb"])
@@ -447,7 +485,9 @@ def _build_settings_section(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_pricing_toggle:{section}:{entry.key}",
callback_data=(
f"admin_pricing_toggle:{section}:{_encode_setting_callback_key(entry.key)}"
),
)
]
)
@@ -461,7 +501,7 @@ def _build_settings_section(
types.InlineKeyboardButton(
text=f"{icon} {option.label(lang_code)}",
callback_data=(
f"admin_pricing_choice:{section}:{entry.key}:{option.value}"
f"admin_pricing_choice:{section}:{_encode_setting_callback_key(entry.key)}:{option.value}"
),
)
)
@@ -477,7 +517,9 @@ def _build_settings_section(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_pricing_setting:{section}:{entry.key}",
callback_data=(
f"admin_pricing_setting:{section}:{_encode_setting_callback_key(entry.key)}"
),
)
]
)
@@ -939,11 +981,12 @@ async def start_setting_edit(
state: FSMContext,
) -> None:
try:
_, section, key = callback.data.split(":", 2)
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
texts = get_texts(db_user.language)
lang_code = _language_code(db_user.language)
@@ -1140,11 +1183,12 @@ async def toggle_setting(
state: FSMContext,
) -> None:
try:
_, section, key = callback.data.split(":", 2)
_, section, raw_key = callback.data.split(":", 2)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
if not entry or entry.action != "toggle":
await callback.answer()
@@ -1171,11 +1215,12 @@ async def select_setting_choice(
state: FSMContext,
) -> None:
try:
_, section, key, value_raw = callback.data.split(":", 3)
_, section, raw_key, value_raw = callback.data.split(":", 3)
except ValueError:
await callback.answer()
return
key = _decode_setting_callback_key(raw_key)
entry = SETTING_ENTRY_BY_KEY.get(key)
if not entry or entry.action != "choice" or not entry.choices:
await callback.answer()
+168 -57
View File
@@ -45,7 +45,7 @@ MIGRATION_PAGE_SIZE = 8
def _format_duration(seconds: float) -> str:
if seconds < 1:
return "<1с"
return "менее 1с"
minutes, sec = divmod(int(seconds), 60)
if minutes:
@@ -1316,7 +1316,29 @@ async def show_node_details(
status_emoji = "🟢" if node["is_node_online"] else "🔴"
xray_emoji = "" if node["is_xray_running"] else ""
status_change = (
format_datetime(node["last_status_change"])
if node.get("last_status_change")
else ""
)
created_at = (
format_datetime(node["created_at"])
if node.get("created_at")
else ""
)
updated_at = (
format_datetime(node["updated_at"])
if node.get("updated_at")
else ""
)
notify_percent = (
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else ""
)
cpu_info = node.get("cpu_model") or ""
if node.get("cpu_count"):
cpu_info = f"{node['cpu_count']}x {cpu_info}"
text = f"""
🖥 <b>Нода: {node['name']}</b>
@@ -1325,15 +1347,29 @@ async def show_node_details(
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Подключена: {'📡 Да' if node['is_connected'] else '📵 Нет'}
- Отключена: {'❌ Да' if node['is_disabled'] else '✅ Нет'}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Информация:</b>
- Адрес: {node['address']}
- Страна: {node['country_code']}
- Пользователей онлайн: {node['users_online']}
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'])}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {notify_percent}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Метаданные:</b>
- Создана: {created_at}
- Обновлена: {updated_at}
"""
await callback.message.edit_text(
@@ -1350,28 +1386,18 @@ async def manage_node(
db_user: User,
db: AsyncSession
):
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
remnawave_service = RemnaWaveService()
success = await remnawave_service.manage_node(node_uuid, action)
if success:
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
else:
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
await show_node_details(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"admin_node_manage_{node_uuid}",
message=callback.message
),
db_user,
db
)
action, node_uuid = callback.data.split('_')[1], callback.data.split('_')[-1]
remnawave_service = RemnaWaveService()
success = await remnawave_service.manage_node(node_uuid, action)
if success:
action_text = {"enable": "включена", "disable": "отключена", "restart": "перезагружена"}
await callback.answer(f"✅ Нода {action_text.get(action, 'обработана')}")
else:
await callback.answer("❌ Ошибка выполнения действия", show_alert=True)
await show_node_details(callback, db_user, db)
@admin_required
@error_handler
@@ -1407,10 +1433,32 @@ async def show_node_statistics(
if stats.get('nodeUuid') == node_uuid:
node_realtime = stats
break
status_change = (
format_datetime(node["last_status_change"])
if node.get("last_status_change")
else ""
)
created_at = (
format_datetime(node["created_at"])
if node.get("created_at")
else ""
)
updated_at = (
format_datetime(node["updated_at"])
if node.get("updated_at")
else ""
)
notify_percent = (
f"{node['notify_percent']}%" if node.get("notify_percent") is not None else ""
)
cpu_info = node.get("cpu_model") or ""
if node.get("cpu_count"):
cpu_info = f"{node['cpu_count']}x {cpu_info}"
status_emoji = "🟢" if node["is_node_online"] else "🔴"
xray_emoji = "" if node["is_xray_running"] else ""
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
@@ -1418,10 +1466,26 @@ async def show_node_statistics(
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Изменение статуса: {status_change}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Ресурсы:</b>
- CPU: {cpu_info}
- RAM: {node.get('total_ram') or ''}
- Провайдер: {node.get('provider_uuid') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {notify_percent}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Метаданные:</b>
- Создана: {created_at}
- Обновлена: {updated_at}
"""
if node_realtime:
@@ -1456,18 +1520,25 @@ async def show_node_statistics(
except Exception as e:
logger.error(f"Ошибка получения статистики ноды {node_uuid}: {e}")
text = f"""
📊 <b>Статистика ноды: {node['name']}</b>
<b>Статус:</b>
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Онлайн: {status_emoji} {'Да' if node['is_node_online'] else 'Нет'}
- Xray: {xray_emoji} {'Запущен' if node['is_xray_running'] else 'Остановлен'}
- Пользователей онлайн: {node['users_online'] or 0}
- Изменение статуса: {format_datetime(node.get('last_status_change')) if node.get('last_status_change') else ''}
- Сообщение: {node.get('last_status_message') or ''}
- Uptime Xray: {node.get('xray_uptime') or ''}
<b>Трафик:</b>
- Использовано: {format_bytes(node['traffic_used_bytes'] or 0)}
- Лимит: {format_bytes(node['traffic_limit_bytes']) if node['traffic_limit_bytes'] else 'Без лимита'}
- Трекинг: {'✅ Активен' if node.get('is_traffic_tracking_active') else '❌ Отключен'}
- День сброса: {node.get('traffic_reset_day') or ''}
- Уведомления: {node.get('notify_percent') or ''}
- Множитель: {node.get('consumption_multiplier') or 1}
<b>Детальная статистика временно недоступна</b>
Возможные причины:
@@ -1566,17 +1637,11 @@ async def manage_squad_action(
await callback.answer("❌ Ошибка удаления сквада", show_alert=True)
return
await show_squad_details(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"admin_squad_manage_{squad_uuid}",
message=callback.message
),
db_user,
db
)
refreshed_callback = callback.model_copy(
update={"data": f"admin_squad_manage_{squad_uuid}"}
).as_(callback.bot)
await show_squad_details(refreshed_callback, db_user, db)
@admin_required
@error_handler
@@ -1734,15 +1799,11 @@ async def cancel_squad_rename(
await state.clear()
new_callback = types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"squad_edit_{squad_uuid}",
message=callback.message
)
await show_squad_edit_menu(new_callback, db_user, db)
refreshed_callback = callback.model_copy(
update={"data": f"squad_edit_{squad_uuid}"}
).as_(callback.bot)
await show_squad_edit_menu(refreshed_callback, db_user, db)
@admin_required
@error_handler
@@ -1953,15 +2014,11 @@ async def show_squad_edit_menu_short(
await callback.answer("❌ Сквад не найден", show_alert=True)
return
new_callback = types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data=f"squad_edit_{full_squad_uuid}",
message=callback.message
)
await show_squad_edit_menu(new_callback, db_user, db)
refreshed_callback = callback.model_copy(
update={"data": f"squad_edit_{full_squad_uuid}"}
).as_(callback.bot)
await show_squad_edit_menu(refreshed_callback, db_user, db)
@admin_required
@error_handler
@@ -2285,6 +2342,9 @@ async def show_sync_options(
"• При полной синхронизации подписки пользователей, отсутствующих в панели, будут деактивированы\n"
"• Рекомендуется делать полную синхронизацию ежедневно\n"
"• Баланс пользователей НЕ удаляется\n\n"
"⬆️ <b>Обратная синхронизация:</b>\n"
"• Отправляет активных пользователей из бота в панель\n"
"• Используйте при сбоях панели или для восстановления данных\n\n"
+ "\n".join(status_lines)
)
@@ -2295,6 +2355,12 @@ async def show_sync_options(
callback_data="sync_all_users",
)
],
[
types.InlineKeyboardButton(
text="⬆️ Синхронизация в панель",
callback_data="sync_to_panel",
)
],
[
types.InlineKeyboardButton(
text="⚙️ Настройки автосинхронизации",
@@ -2654,6 +2720,50 @@ async def sync_all_users(
)
await callback.answer()
@admin_required
@error_handler
async def sync_users_to_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
await callback.message.edit_text(
"⬆️ Выполняется синхронизация данных бота в панель Remnawave...\n\n"
"Это может занять несколько минут.",
reply_markup=None,
)
remnawave_service = RemnaWaveService()
stats = await remnawave_service.sync_users_to_panel(db)
if stats["errors"] == 0:
status_emoji = ""
status_text = "успешно завершена"
else:
status_emoji = "⚠️" if (stats["created"] + stats["updated"]) > 0 else ""
status_text = "завершена с предупреждениями" if status_emoji == "⚠️" else "завершена с ошибками"
text = (
f"{status_emoji} <b>Синхронизация в панель {status_text}</b>\n\n"
"📊 <b>Результаты:</b>\n"
f"• 🆕 Создано: {stats['created']}\n"
f"• 🔄 Обновлено: {stats['updated']}\n"
f"• ❌ Ошибок: {stats['errors']}"
)
keyboard = [
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="sync_to_panel")],
[types.InlineKeyboardButton(text="🔄 Полная синхронизация", callback_data="sync_all_users")],
[types.InlineKeyboardButton(text="⬅️ К синхронизации", callback_data="admin_rw_sync")],
]
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_sync_recommendations(
@@ -3126,6 +3236,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(cancel_auto_sync_schedule, F.data == "remnawave_auto_sync_cancel")
dp.callback_query.register(run_auto_sync_now, F.data == "remnawave_auto_sync_run")
dp.callback_query.register(sync_all_users, F.data == "sync_all_users")
dp.callback_query.register(sync_users_to_panel, F.data == "sync_to_panel")
dp.callback_query.register(show_squad_migration_menu, F.data == "admin_rw_migration")
dp.callback_query.register(paginate_migration_source, F.data.startswith("admin_migration_source_page_"))
dp.callback_query.register(handle_migration_source_selection, F.data.startswith("admin_migration_source_"))
+86
View File
@@ -0,0 +1,86 @@
import logging
from aiogram import Dispatcher, F, types
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.subscription import (
get_trial_statistics,
reset_trials_for_users_without_paid_subscription,
)
from app.database.models import User
from app.keyboards.admin import get_admin_trials_keyboard
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
@admin_required
@error_handler
async def show_trials_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
stats = await get_trial_statistics(db)
message = texts.t("ADMIN_TRIALS_TITLE", "🧪 Управление триалами") + "\n\n" + texts.t(
"ADMIN_TRIALS_STATS",
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def reset_trials(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
reset_count = await reset_trials_for_users_without_paid_subscription(db)
stats = await get_trial_statistics(db)
message = texts.t(
"ADMIN_TRIALS_RESET_RESULT",
"♻️ Сбросили {reset_count} триалов.\n\n"
"• Использовано всего: {used}\n"
"• Активно сейчас: {active}\n"
"• Доступно к сбросу: {resettable}",
).format(
reset_count=reset_count,
used=stats.get("used_trials", 0),
active=stats.get("active_trials", 0),
resettable=stats.get("resettable_trials", 0),
)
await callback.message.edit_text(
message,
reply_markup=get_admin_trials_keyboard(db_user.language),
)
await callback.answer(texts.t("ADMIN_TRIALS_RESET_TOAST", "✅ Сброс завершен"))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(
show_trials_panel,
F.data == "admin_trials",
)
dp.callback_query.register(
reset_trials,
F.data == "admin_trials_reset",
)
+24 -12
View File
@@ -12,6 +12,11 @@ from app.database.crud.user_message import (
)
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.utils.validators import (
get_html_help_text,
sanitize_html,
validate_html_tags,
)
from app.utils.decorators import admin_required, error_handler
from app.localization.texts import get_texts
@@ -122,8 +127,6 @@ async def add_user_message_start(
db_user: User,
db: AsyncSession
):
from app.utils.validators import get_html_help_text
await callback.message.edit_text(
f"📝 <b>Добавление нового сообщения</b>\n\n"
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
@@ -161,8 +164,6 @@ async def process_new_message_text(
)
return
from app.utils.validators import validate_html_tags, get_html_help_text
is_valid, error_msg = validate_html_tags(message_text)
if not is_valid:
await message.answer(
@@ -312,20 +313,22 @@ async def view_user_message(
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
safe_content = sanitize_html(message.message_text)
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
text = (
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
f"<b>Статус:</b> {status_text}\n"
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Содержимое:</b>\n"
f"<blockquote>{message.message_text}</blockquote>"
f"<blockquote>{safe_content}</blockquote>"
)
await callback.message.edit_text(
@@ -455,7 +458,7 @@ async def edit_user_message_start(
await callback.message.edit_text(
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
f"<b>Текущий текст:</b>\n"
f"<blockquote>{message.message_text}</blockquote>\n\n"
f"<blockquote>{sanitize_html(message.message_text)}</blockquote>\n\n"
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
parse_mode="HTML"
)
@@ -489,14 +492,23 @@ async def process_edit_message_text(
return
new_text = message.text.strip()
if len(new_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
parse_mode=None
)
return
try:
updated_message = await update_user_message(
db=db,
@@ -511,7 +523,7 @@ async def process_edit_message_text(
f"<b>ID:</b> {updated_message.id}\n"
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Новый текст:</b>\n"
f"<blockquote>{new_text}</blockquote>",
f"<blockquote>{sanitize_html(new_text)}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
+465 -4
View File
@@ -32,6 +32,7 @@ from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.utils.user_utils import get_effective_referral_commission_percent
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import TrafficLimitStrategy
from app.database.crud.server_squad import (
@@ -298,6 +299,137 @@ async def show_users_list_by_balance(
await callback.answer()
@admin_required
@error_handler
async def show_users_ready_to_renew(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
page: int = 1
):
"""Показывает пользователей с истекшей подпиской и балансом >= порога."""
await state.set_state(AdminStates.viewing_user_from_ready_to_renew_list)
texts = get_texts(db_user.language)
threshold = getattr(
settings,
"SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS",
20000,
)
user_service = UserService()
users_data = await user_service.get_users_ready_to_renew(
db,
min_balance_kopeks=threshold,
page=page,
limit=10,
)
amount_text = settings.format_price(threshold)
header = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_TITLE",
"♻️ Пользователи готовы к продлению",
)
description = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_DESC",
"Подписка истекла, а на балансе осталось {amount} или больше.",
).format(amount=amount_text)
if not users_data["users"]:
empty_text = texts.t(
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY",
"Сейчас нет пользователей, которые подходят под этот фильтр.",
)
await callback.message.edit_text(
f"{header}\n\n{description}\n\n{empty_text}",
reply_markup=get_admin_users_keyboard(db_user.language),
)
await callback.answer()
return
text = f"{header}\n\n{description}\n\n"
text += "Нажмите на пользователя для управления:"
keyboard = []
current_time = datetime.utcnow()
for user in users_data["users"]:
subscription = user.subscription
status_emoji = "" if user.status == UserStatus.ACTIVE.value else "🚫"
subscription_emoji = ""
expired_days = "?"
if subscription:
if subscription.is_trial:
subscription_emoji = "🎁"
elif subscription.is_active:
subscription_emoji = "💎"
else:
subscription_emoji = ""
if subscription.end_date:
delta = current_time - subscription.end_date
expired_days = delta.days
button_text = (
f"{status_emoji} {subscription_emoji} {user.full_name}"
f" | 💰 {settings.format_price(user.balance_kopeks)}"
f" | ⏰ {expired_days}д ист."
)
if len(button_text) > 60:
short_name = user.full_name
if len(short_name) > 20:
short_name = short_name[:17] + "..."
button_text = (
f"{status_emoji} {subscription_emoji} {short_name}"
f" | 💰 {settings.format_price(user.balance_kopeks)}"
)
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if users_data["total_pages"] > 1:
pagination_row = get_admin_pagination_keyboard(
users_data["current_page"],
users_data["total_pages"],
"admin_users_ready_to_renew_list",
"admin_users_ready_to_renew_filter",
db_user.language,
).inline_keyboard[0]
keyboard.append(pagination_row)
keyboard.extend([
[
types.InlineKeyboardButton(
text="🔍 Поиск",
callback_data="admin_users_search",
),
types.InlineKeyboardButton(
text="📊 Статистика",
callback_data="admin_users_stats",
),
],
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data="admin_users",
)
],
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@error_handler
async def show_users_list_by_traffic(
@@ -858,6 +990,22 @@ async def handle_users_purchases_list_pagination(
await show_users_list_by_purchases(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_ready_to_renew_pagination(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
try:
page = int(callback.data.split('_')[-1])
await show_users_ready_to_renew(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f"Ошибка парсинга номера страницы: {e}")
await show_users_ready_to_renew(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_campaign_list_pagination(
@@ -1501,6 +1649,8 @@ async def show_user_management(
back_callback = "admin_users_purchases_filter"
elif current_state == AdminStates.viewing_user_from_campaign_list:
back_callback = "admin_users_campaign_filter"
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
back_callback = "admin_users_ready_to_renew_filter"
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -1536,6 +1686,9 @@ async def _build_user_referrals_view(
referrals = await get_referrals(db, user_id)
effective_percent = get_effective_referral_commission_percent(user)
default_percent = settings.REFERRAL_COMMISSION_PERCENT
header = texts.t(
"ADMIN_USER_REFERRALS_TITLE",
"🤝 <b>Рефералы пользователя</b>",
@@ -1551,6 +1704,24 @@ async def _build_user_referrals_view(
lines: List[str] = [header, summary]
if user.referral_commission_percent is None:
lines.append(
texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT",
"• Процент комиссии: {percent}% (стандартное значение)",
).format(percent=effective_percent)
)
else:
lines.append(
texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM",
"• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
).format(
percent=user.referral_commission_percent,
default_percent=default_percent,
)
)
if referrals:
lines.append(
texts.t(
@@ -1604,6 +1775,15 @@ async def _build_user_referrals_view(
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON",
"📈 Изменить процент",
),
callback_data=f"admin_user_referral_percent_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.t(
@@ -1636,12 +1816,12 @@ async def show_user_referrals(
user_id = int(callback.data.split('_')[-1])
current_state = await state.get_state()
if current_state == AdminStates.editing_user_referrals:
if current_state in {AdminStates.editing_user_referrals, AdminStates.editing_user_referral_percent}:
data = await state.get_data()
preserved_data = {
key: value
for key, value in data.items()
if key not in {"editing_referrals_user_id", "referrals_message_id"}
if key not in {"editing_referrals_user_id", "referrals_message_id", "editing_referral_percent_user_id"}
}
await state.clear()
if preserved_data:
@@ -1661,6 +1841,256 @@ async def show_user_referrals(
await callback.answer()
@admin_required
@error_handler
async def start_edit_referral_percent(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
texts = get_texts(db_user.language)
effective_percent = get_effective_referral_commission_percent(user)
default_percent = settings.REFERRAL_COMMISSION_PERCENT
prompt = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT",
(
"📈 <b>Индивидуальный процент реферальной комиссии</b>\n\n"
"Текущее значение: {current}%\n"
"Стандартное значение: {default}%\n\n"
"Отправьте новое значение от 0 до 100 или слово 'стандарт' для сброса."
),
).format(current=effective_percent, default=default_percent)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="5%",
callback_data=f"admin_user_referral_percent_set_{user_id}_5",
),
InlineKeyboardButton(
text="10%",
callback_data=f"admin_user_referral_percent_set_{user_id}_10",
),
],
[
InlineKeyboardButton(
text="15%",
callback_data=f"admin_user_referral_percent_set_{user_id}_15",
),
InlineKeyboardButton(
text="20%",
callback_data=f"admin_user_referral_percent_set_{user_id}_20",
),
],
[
InlineKeyboardButton(
text=texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON",
"♻️ Сбросить на стандартный",
),
callback_data=f"admin_user_referral_percent_reset_{user_id}",
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_referrals_{user_id}",
)
],
]
)
await state.update_data(editing_referral_percent_user_id=user_id)
await state.set_state(AdminStates.editing_user_referral_percent)
await callback.message.edit_text(
prompt,
reply_markup=keyboard,
)
await callback.answer()
async def _update_referral_commission_percent(
db: AsyncSession,
user_id: int,
percent: Optional[int],
admin_id: int,
) -> Tuple[bool, Optional[int]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None
user.referral_commission_percent = percent
user.updated_at = datetime.utcnow()
await db.commit()
effective = get_effective_referral_commission_percent(user)
logger.info(
"Админ %s обновил реферальный процент пользователя %s: %s",
admin_id,
user_id,
percent,
)
return True, effective
except Exception as e:
logger.error(
"Ошибка обновления реферального процента пользователя %s: %s",
user_id,
e,
)
try:
await db.rollback()
except Exception as rollback_error:
logger.error("Ошибка отката транзакции: %s", rollback_error)
return False, None
async def _render_referrals_after_update(
callback: types.CallbackQuery,
db: AsyncSession,
db_user: User,
user_id: int,
success_message: str,
):
view = await _build_user_referrals_view(db, db_user.language, user_id)
if view:
text, keyboard = view
text = f"{success_message}\n\n" + text
await callback.message.edit_text(text, reply_markup=keyboard)
else:
await callback.message.edit_text(success_message)
@admin_required
@error_handler
async def set_referral_percent_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split('_')
if "reset" in parts:
user_id = int(parts[-1])
percent_value: Optional[int] = None
else:
user_id = int(parts[-2])
percent_value = int(parts[-1])
texts = get_texts(db_user.language)
success, effective_percent = await _update_referral_commission_percent(
db,
user_id,
percent_value,
db_user.id,
)
if not success:
await callback.answer("❌ Не удалось обновить процент", show_alert=True)
return
await state.clear()
success_message = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
"✅ Процент обновлён: {percent}%",
).format(percent=effective_percent)
await _render_referrals_after_update(callback, db, db_user, user_id, success_message)
await callback.answer()
@admin_required
@error_handler
async def process_referral_percent_input(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
user_id = data.get("editing_referral_percent_user_id")
if not user_id:
await message.answer("❌ Не удалось определить пользователя")
return
raw_text = message.text.strip()
normalized = raw_text.lower()
percent_value: Optional[int]
if normalized in {"стандарт", "standard", "default"}:
percent_value = None
else:
normalized_number = raw_text.replace(',', '.').strip()
try:
percent_float = float(normalized_number)
except (TypeError, ValueError):
await message.answer(
get_texts(db_user.language).t(
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
"❌ Введите число от 0 до 100 или слово 'стандарт'",
)
)
return
percent_value = int(round(percent_float))
if percent_value < 0 or percent_value > 100:
await message.answer(
get_texts(db_user.language).t(
"ADMIN_USER_REFERRAL_COMMISSION_INVALID",
"❌ Введите число от 0 до 100 или слово 'стандарт'",
)
)
return
texts = get_texts(db_user.language)
success, effective_percent = await _update_referral_commission_percent(
db,
int(user_id),
percent_value,
db_user.id,
)
if not success:
await message.answer("❌ Не удалось обновить процент")
return
await state.clear()
success_message = texts.t(
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED",
"✅ Процент обновлён: {percent}%",
).format(percent=effective_percent)
view = await _build_user_referrals_view(db, db_user.language, int(user_id))
if view:
text, keyboard = view
await message.answer(f"{success_message}\n\n{text}", reply_markup=keyboard)
else:
await message.answer(success_message)
@admin_required
@error_handler
async def start_edit_user_referrals(
@@ -4278,10 +4708,13 @@ async def admin_buy_subscription_execute(
target_user.telegram_id,
)
if subscription.end_date <= current_time:
extension_base_date = current_time
if subscription.end_date and subscription.end_date > current_time:
extension_base_date = subscription.end_date
else:
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
@@ -4576,6 +5009,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_users_purchases_list_page_")
)
dp.callback_query.register(
handle_users_ready_to_renew_pagination,
F.data.startswith("admin_users_ready_to_renew_list_page_")
)
dp.callback_query.register(
handle_users_campaign_list_pagination,
F.data.startswith("admin_users_campaign_list_page_")
@@ -4621,6 +5059,24 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_referrals_") & ~F.data.contains("_edit")
)
dp.callback_query.register(
start_edit_referral_percent,
F.data.startswith("admin_user_referral_percent_")
& ~F.data.contains("_set_")
& ~F.data.contains("_reset")
)
dp.callback_query.register(
set_referral_percent_button,
F.data.startswith("admin_user_referral_percent_set_")
| F.data.startswith("admin_user_referral_percent_reset_")
)
dp.message.register(
process_referral_percent_input,
AdminStates.editing_user_referral_percent,
)
dp.callback_query.register(
start_edit_user_referrals,
F.data.startswith("admin_user_referrals_edit_")
@@ -4829,6 +5285,11 @@ def register_handlers(dp: Dispatcher):
show_users_list_by_purchases,
F.data == "admin_users_purchases_filter"
)
dp.callback_query.register(
show_users_ready_to_renew,
F.data == "admin_users_ready_to_renew_filter"
)
dp.callback_query.register(
show_users_list_by_campaign,
+55 -5
View File
@@ -7,6 +7,7 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
@@ -79,7 +80,12 @@ async def start_cryptobot_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
await state.update_data(
payment_method="cryptobot",
current_rate=current_rate,
cryptobot_prompt_message_id=callback.message.message_id,
cryptobot_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -91,8 +97,26 @@ async def process_cryptobot_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
await message.answer("❌ Оплата криптовалютой временно недоступна")
return
@@ -158,8 +182,29 @@ async def process_cryptobot_payment_amount(
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("cryptobot_prompt_message_id")
prompt_chat_id = state_data.get("cryptobot_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить сообщение с суммой CryptoBot: %s",
delete_error,
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось удалить сообщение с запросом суммы CryptoBot: %s",
delete_error,
)
invoice_message = await message.answer(
f"🪙 <b>Оплата криптовалютой</b>\n\n"
f"💰 Сумма к зачислению: {amount_rubles:.0f}\n"
f"💵 К оплате: {amount_usd:.2f} USD\n"
@@ -177,7 +222,12 @@ async def process_cryptobot_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.update_data(
cryptobot_invoice_message_id=invoice_message.message_id,
cryptobot_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
+53 -2
View File
@@ -1,8 +1,10 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -66,7 +68,11 @@ async def start_heleket_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="heleket")
await state.update_data(
payment_method="heleket",
heleket_prompt_message_id=callback.message.message_id,
heleket_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -181,7 +187,52 @@ async def process_heleket_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
await message.answer("\n".join(details), parse_mode="HTML", reply_markup=keyboard)
state_data = await state.get_data()
prompt_message_id = state_data.get("heleket_prompt_message_id")
prompt_chat_id = state_data.get("heleket_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой Heleket: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы Heleket: %s",
delete_error,
)
invoice_message = await message.answer(
"\n".join(details), parse_mode="HTML", reply_markup=keyboard
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_heleket_payment_by_id(db, result["local_payment_id"])
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение Heleket: %s", error)
await state.update_data(
heleket_invoice_message_id=invoice_message.message_id,
heleket_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
+53 -12
View File
@@ -94,15 +94,38 @@ async def show_balance_menu(
db: AsyncSession
):
texts = get_texts(db_user.language)
balance_text = texts.BALANCE_INFO.format(
balance=texts.format_price(db_user.balance_kopeks)
)
await callback.message.edit_text(
balance_text,
reply_markup=get_balance_keyboard(db_user.language)
)
reply_markup = get_balance_keyboard(db_user.language)
try:
if callback.message and callback.message.text:
await callback.message.edit_text(
balance_text,
reply_markup=reply_markup
)
elif callback.message and callback.message.caption:
await callback.message.edit_caption(
balance_text,
reply_markup=reply_markup
)
else:
await callback.message.answer(
balance_text,
reply_markup=reply_markup
)
except TelegramBadRequest as error:
logger.warning(
"Failed to edit balance message, sending a new one instead: %s",
error,
)
await callback.message.answer(
balance_text,
reply_markup=reply_markup
)
await callback.answer()
@@ -421,7 +444,17 @@ async def request_support_topup(
db_user: User
):
texts = get_texts(db_user.language)
if not settings.is_support_topup_enabled():
await callback.answer(
texts.t(
"SUPPORT_TOPUP_DISABLED",
"Пополнение через поддержку отключено. Попробуйте другой способ оплаты.",
),
show_alert=True,
)
return
support_text = f"""
🛠 <b>Пополнение через поддержку</b>
@@ -748,11 +781,19 @@ async def handle_topup_amount_callback(
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
from .platega import process_platega_payment_amount
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
from .platega import process_platega_payment_amount, start_platega_payment
data = await state.get_data()
method_code = int(data.get("platega_method", 0)) if data else 0
if method_code > 0:
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await state.update_data(platega_pending_amount=amount_kopeks)
await start_platega_payment(callback, db_user, state)
elif method == "pal24":
from app.database.database import AsyncSessionLocal
from .pal24 import process_pal24_payment_amount
+53 -2
View File
@@ -59,7 +59,11 @@ async def start_mulenpay_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="mulenpay")
await state.update_data(
payment_method="mulenpay",
mulenpay_prompt_message_id=callback.message.message_id,
mulenpay_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -93,6 +97,26 @@ async def process_mulenpay_payment_amount(
amount_rubles = amount_kopeks / 100
state_data = await state.get_data()
prompt_message_id = state_data.get("mulenpay_prompt_message_id")
prompt_chat_id = state_data.get("mulenpay_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot permissions
logger.warning(
"Не удалось удалить сообщение с суммой MulenPay: %s", delete_error
)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы MulenPay: %s",
delete_error,
)
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_mulenpay_payment(
@@ -163,12 +187,39 @@ async def process_mulenpay_payment_amount(
mulenpay_name_html=mulenpay_name_html,
)
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_mulenpay_payment_by_local_id(
db, local_payment_id
)
if payment:
payment_metadata = dict(
getattr(payment, "metadata_json", {}) or {}
)
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - diagnostic logging only
logger.warning("Не удалось сохранить данные сообщения MulenPay: %s", error)
await state.update_data(
mulenpay_invoice_message_id=invoice_message.message_id,
mulenpay_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
+50 -2
View File
@@ -1,10 +1,12 @@
import html
import logging
from datetime import datetime
from typing import Any, Optional
from aiogram import types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -204,12 +206,36 @@ async def _send_pal24_payment_message(
support=settings.get_support_contact_display_html(),
)
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_pal24_payment_by_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение PayPalych: %s", error)
await state.update_data(
pal24_invoice_message_id=invoice_message.message_id,
pal24_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
@@ -277,7 +303,11 @@ async def start_pal24_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="pal24")
await state.update_data(
payment_method="pal24",
pal24_prompt_message_id=callback.message.message_id,
pal24_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -309,6 +339,24 @@ async def process_pal24_payment_amount(
available_methods = _get_available_pal24_methods()
state_data = await state.get_data()
prompt_message_id = state_data.get("pal24_prompt_message_id")
prompt_chat_id = state_data.get("pal24_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой PayPalych: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы PayPalych: %s",
delete_error,
)
if len(available_methods) == 1:
await _send_pal24_payment_message(
message,
+73 -3
View File
@@ -32,6 +32,29 @@ async def _prompt_amount(
texts = get_texts(db_user.language)
method_name = settings.get_platega_method_display_title(method_code)
# Всегда фиксируем выбранный метод для последующей обработки
await state.update_data(payment_method="platega", platega_method=method_code)
data = await state.get_data()
pending_amount = int(data.get("platega_pending_amount") or 0)
if pending_amount > 0:
# Если сумма уже известна (например, после быстрого выбора),
# сразу создаём платеж и сбрасываем временное значение.
await state.update_data(platega_pending_amount=None)
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_platega_payment_amount(
message,
db_user,
db,
pending_amount,
state,
)
return
min_amount_label = settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS)
max_amount_kopeks = settings.PLATEGA_MAX_AMOUNT_KOPEKS
max_amount_label = (
@@ -75,7 +98,10 @@ async def _prompt_amount(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="platega", platega_method=method_code)
await state.update_data(
platega_prompt_message_id=message.message_id,
platega_prompt_chat_id=message.chat.id,
)
@error_handler
@@ -108,6 +134,8 @@ async def start_platega_payment(
return
await state.update_data(payment_method="platega")
data = await state.get_data()
has_pending_amount = bool(int(data.get("platega_pending_amount") or 0))
if len(active_methods) == 1:
await _prompt_amount(callback.message, db_user, state, active_methods[0])
@@ -137,7 +165,8 @@ async def start_platega_payment(
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
await state.set_state(BalanceStates.waiting_for_platega_method)
if not has_pending_amount:
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@@ -275,7 +304,25 @@ async def process_platega_payment_amount(
),
)
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("platega_prompt_message_id")
prompt_chat_id = state_data.get("platega_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Platega: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Platega: %s",
delete_error,
)
invoice_message = await message.answer(
instructions_template.format(
method=method_title,
amount=settings.format_price(amount_kopeks),
@@ -286,6 +333,29 @@ async def process_platega_payment_amount(
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_platega_payment_by_id(db, local_payment_id)
if payment:
payment_metadata = dict(getattr(payment, "metadata_json", {}) or {})
payment_metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await payment_module.update_platega_payment(
db,
payment=payment,
metadata=payment_metadata,
)
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить данные сообщения Platega: %s", error)
await state.update_data(
platega_invoice_message_id=invoice_message.message_id,
platega_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
+66 -19
View File
@@ -1,12 +1,12 @@
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard, get_payment_methods_keyboard
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -22,11 +22,11 @@ async def start_stars_payment(
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
@@ -35,10 +35,10 @@ async def start_stars_payment(
)
else:
message_text = texts.TOP_UP_AMOUNT
# Создаем клавиатуру
keyboard = get_back_keyboard(db_user.language)
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
@@ -46,12 +46,17 @@ async def start_stars_payment(
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
message_text,
reply_markup=keyboard
)
await state.update_data(
stars_prompt_message_id=callback.message.message_id,
stars_prompt_chat_id=callback.message.chat.id,
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="stars")
await callback.answer()
@@ -64,30 +69,67 @@ async def process_stars_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer("⚠️ Оплата Stars временно недоступна")
return
try:
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
stars_rate = settings.get_stars_rate()
stars_rate = settings.get_stars_rate()
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {texts.format_price(amount_kopeks)}",
payload=f"balance_{db_user.id}_{amount_kopeks}"
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⭐ Оплатить", url=invoice_link)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой Stars: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы Stars: %s",
delete_error,
)
invoice_message = await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
@@ -96,9 +138,14 @@ async def process_stars_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
await state.update_data(
stars_invoice_message_id=invoice_message.message_id,
stars_invoice_chat_id=invoice_message.chat.id,
)
await state.set_state(None)
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer("⚠️ Ошибка создания платежа")
await message.answer("⚠️ Ошибка создания платежа")
+37 -25
View File
@@ -13,47 +13,59 @@ logger = logging.getLogger(__name__)
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
db_user: User
db_user: User,
):
texts = get_texts(db_user.language)
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
try:
from app.services.tribute_service import TributeService
tribute_service = TributeService(callback.bot)
payment_url = await tribute_service.create_payment_link(
user_id=db_user.telegram_id,
amount_kopeks=0,
description="Пополнение баланса VPN"
description="Пополнение баланса VPN",
)
if not payment_url:
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await callback.message.edit_text(
f"💳 <b>Пополнение банковской картой</b>\n\n"
f"• Введите любую сумму от 100₽\n"
f"• Безопасная оплата через Tribute\n"
f"• Мгновенное зачисление на баланс\n"
f"• Принимаем карты Visa, MasterCard, МИР\n\n"
f"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
f"Нажмите кнопку для перехода к оплате:",
reply_markup=keyboard,
parse_mode="HTML"
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Перейти к оплате", url=payment_url)],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
message_text = (
"💳 <b>Пополнение банковской картой</b>\n\n"
"• Введите любую сумму от 100₽\n"
"• Безопасная оплата через Tribute\n"
"• Мгновенное зачисление на баланс\n"
"• Принимаем карты Visa, MasterCard, МИР\n\n"
"• 🚨 НЕ ОТПРАВЛЯТЬ ПЛАТЕЖ АНОНИМНО!\n\n"
"Нажмите кнопку для перехода к оплате:"
)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
TributeService.remember_invoice_message(
db_user.telegram_id,
callback.message.chat.id,
callback.message.message_id,
)
except Exception as e:
logger.error(f"Ошибка создания Tribute платежа: {e}")
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
await callback.answer()
await callback.answer()
+50 -2
View File
@@ -1,8 +1,10 @@
import logging
from datetime import datetime
from typing import Dict
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -56,7 +58,11 @@ async def start_wata_payment(
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="wata")
await state.update_data(
payment_method="wata",
wata_prompt_message_id=callback.message.message_id,
wata_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -159,12 +165,54 @@ async def process_wata_payment_amount(
support=settings.get_support_contact_display_html(),
)
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("wata_prompt_message_id")
prompt_chat_id = state_data.get("wata_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить сообщение с суммой WATA: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - diagnostic
logger.warning(
"Не удалось удалить сообщение с запросом суммы WATA: %s",
delete_error,
)
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_wata_payment_by_local_id(db, local_payment_id)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - diagnostics
logger.warning("Не удалось сохранить сообщение WATA: %s", error)
await state.update_data(
wata_invoice_message_id=invoice_message.message_id,
wata_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(
+153 -17
View File
@@ -1,12 +1,16 @@
import logging
from datetime import datetime
from aiogram import types
from aiogram.fsm.context import FSMContext
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.utils.decorators import error_handler
from app.states import BalanceStates
@@ -58,9 +62,13 @@ async def start_yookassa_payment(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -108,9 +116,13 @@ async def start_yookassa_sbp_payment(
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa_sbp")
await state.update_data(
yookassa_prompt_message_id=callback.message.message_id,
yookassa_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@@ -122,8 +134,26 @@ async def process_yookassa_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
@@ -172,7 +202,25 @@ async def process_yookassa_payment_amount(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa: %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa: %s",
delete_error,
)
invoice_message = await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
@@ -187,9 +235,34 @@ async def process_yookassa_payment_amount(
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa: %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
@@ -207,8 +280,26 @@ async def process_yookassa_sbp_payment_amount(
amount_kopeks: int,
state: FSMContext
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Оплата невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await message.answer("❌ Оплата через СБП временно недоступна")
return
@@ -310,27 +401,45 @@ async def process_yookassa_sbp_payment_amount(
# Создаем клавиатуру с кнопками для оплаты по ссылке и проверки статуса
keyboard_buttons = []
# Добавляем кнопку оплаты, если доступна ссылка
if confirmation_url:
keyboard_buttons.append([types.InlineKeyboardButton(text="🔗 Перейти к оплате", url=confirmation_url)])
else:
# Если ссылка недоступна, предлагаем оплатить через ID платежа в приложении банка
keyboard_buttons.append([types.InlineKeyboardButton(text="📱 Оплатить в приложении банка", callback_data="temp_disabled")])
# Добавляем общие кнопки
keyboard_buttons.append([types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")])
keyboard_buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
state_data = await state.get_data()
prompt_message_id = state_data.get("yookassa_prompt_message_id")
prompt_chat_id = state_data.get("yookassa_prompt_chat_id", message.chat.id)
try:
await message.delete()
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning("Не удалось удалить сообщение с суммой YooKassa (СБП): %s", delete_error)
if prompt_message_id:
try:
await message.bot.delete_message(prompt_chat_id, prompt_message_id)
except Exception as delete_error: # pragma: no cover - диагностический лог
logger.warning(
"Не удалось удалить сообщение с запросом суммы YooKassa (СБП): %s",
delete_error,
)
# Подготавливаем текст сообщения
message_text = (
f"🔗 <b>Оплата через СБП</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
)
# Добавляем инструкции в зависимости от доступных способов оплаты
if not confirmation_url:
message_text += (
@@ -341,18 +450,18 @@ async def process_yookassa_sbp_payment_amount(
f"4. Подтвердите платеж в приложении банка\n"
f"5. Деньги поступят на баланс автоматически\n\n"
)
message_text += (
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем СБП от всех банков-участников\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}"
)
# Отправляем сообщение с инструкциями и клавиатурой
# Если есть QR-код, отправляем его как медиа-сообщение
if qr_photo:
# Используем метод отправки медиа-группы или фото с описанием
await message.answer_photo(
invoice_message = await message.answer_photo(
photo=qr_photo,
caption=message_text,
reply_markup=keyboard,
@@ -360,12 +469,39 @@ async def process_yookassa_sbp_payment_amount(
)
else:
# Если QR-код недоступен, отправляем обычное текстовое сообщение
await message.answer(
invoice_message = await message.answer(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
try:
from app.services import payment_service as payment_module
payment = await payment_module.get_yookassa_payment_by_local_id(
db, payment_result["local_payment_id"]
)
if payment:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
metadata["invoice_message"] = {
"chat_id": invoice_message.chat.id,
"message_id": invoice_message.message_id,
}
await db.execute(
update(payment.__class__)
.where(payment.__class__.id == payment.id)
.values(metadata_json=metadata, updated_at=datetime.utcnow())
)
await db.commit()
except Exception as error: # pragma: no cover - диагностический лог
logger.warning("Не удалось сохранить сообщение YooKassa (СБП): %s", error)
await state.update_data(
yookassa_invoice_message_id=invoice_message.message_id,
yookassa_invoice_chat_id=invoice_message.chat.id,
)
await state.clear()
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
+141 -3
View File
@@ -149,6 +149,18 @@ async def show_main_menu(
*,
skip_callback_answer: bool = False,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
db_user.last_activity = datetime.utcnow()
@@ -164,7 +176,7 @@ async def show_main_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
@@ -230,6 +242,18 @@ async def show_service_rules(
db_user: User,
db: AsyncSession
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
from app.database.crud.rules import get_current_rules_content
texts = get_texts(db_user.language)
@@ -252,6 +276,18 @@ async def show_info_menu(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
header = texts.t("MENU_INFO_HEADER", "️ <b>Инфо</b>")
@@ -283,6 +319,18 @@ async def show_promo_groups_info(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
promo_groups = await get_auto_assign_promo_groups(db)
@@ -423,6 +471,18 @@ async def show_faq_pages(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
pages = await FaqService.get_pages(db, db_user.language)
@@ -467,6 +527,18 @@ async def show_faq_page(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_data = callback.data or ""
@@ -590,6 +662,18 @@ async def show_privacy_policy(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_page = 1
@@ -697,6 +781,18 @@ async def show_public_offer(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
raw_page = 1
@@ -804,6 +900,18 @@ async def show_language_menu(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
@@ -834,6 +942,18 @@ async def process_language_change(
db_user: User,
db: AsyncSession,
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
@@ -889,6 +1009,18 @@ async def handle_back_to_menu(
db_user: User,
db: AsyncSession
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
await callback.answer(
texts.t(
"USER_NOT_FOUND_ERROR",
"Ошибка: пользователь не найден.",
),
show_alert=True,
)
return
await state.clear()
texts = get_texts(db_user.language)
@@ -903,7 +1035,7 @@ async def handle_back_to_menu(
draft_exists = await has_subscription_checkout_draft(db_user.id)
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
# Проверяем наличие сохраненной корзины в Redis
try:
has_saved_cart = await user_cart_service.has_user_cart(db_user.id)
@@ -972,7 +1104,13 @@ def _get_subscription_status(user: User, texts) -> str:
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_text or "")
if actual_status == "trial":
is_trial_subscription = getattr(subscription, "is_trial", False)
is_trial_like_status = actual_status == "trial" or (
is_trial_subscription and actual_status in {"active", "trial"}
)
if is_trial_like_status:
if days_left > 1 and end_date_text:
return texts.t(
"SUB_STATUS_TRIAL_ACTIVE",
+21
View File
@@ -7,6 +7,7 @@ from app.states import PromoCodeStates
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
@@ -59,6 +60,8 @@ async def activate_promocode_for_registration(
user,
result.get("promocode", {"code": code}),
result["description"],
result.get("balance_before_kopeks"),
result.get("balance_after_kopeks"),
)
except Exception as notify_error:
logger.error(
@@ -77,6 +80,24 @@ async def process_promocode(
state: FSMContext,
db: AsyncSession
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Активация промокода невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
code = message.text.strip()
+2 -1
View File
@@ -14,6 +14,7 @@ from app.localization.texts import get_texts
from app.utils.photo_message import edit_or_answer_photo
from app.utils.user_utils import (
get_detailed_referral_list,
get_effective_referral_commission_percent,
get_referral_analytics,
get_user_referral_summary,
)
@@ -86,7 +87,7 @@ async def show_referral_info(
+ texts.t(
"REFERRAL_REWARD_COMMISSION",
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
).format(percent=get_effective_referral_commission_percent(db_user))
+ "\n\n"
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
+ f"\n<code>{referral_link}</code>\n\n"
+24 -6
View File
@@ -134,8 +134,11 @@ async def start_simple_subscription_purchase(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
traffic_limit_gb = subscription_params["traffic_limit_gb"]
traffic_label = "Безлимит" if traffic_limit_gb == 0 else f"{traffic_limit_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
@@ -523,8 +526,11 @@ async def handle_simple_subscription_pay_with_balance(
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_traffic_gb = subscription_params["traffic_limit_gb"]
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {success_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
@@ -721,8 +727,11 @@ async def handle_simple_subscription_other_payment_methods(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
payment_traffic_gb = subscription_params["traffic_limit_gb"]
payment_traffic_label = "Безлимит" if payment_traffic_gb == 0 else f"{payment_traffic_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {payment_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Стоимость: {settings.format_price(price_kopeks)}",
@@ -826,6 +835,9 @@ async def handle_simple_subscription_payment_method(
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(price_kopeks))
stars_traffic_gb = subscription_params["traffic_limit_gb"]
stars_traffic_label = "Безлимит" if stars_traffic_gb == 0 else f"{stars_traffic_gb} ГБ"
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=f"Подписка на {subscription_params['period_days']} дней",
@@ -833,7 +845,7 @@ async def handle_simple_subscription_payment_method(
f"Простая покупка подписки\n"
f"Период: {subscription_params['period_days']} дней\n"
f"Устройства: {subscription_params['device_limit']}\n"
f"Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}"
f"Трафик: {stars_traffic_label}"
),
payload=(
f"simple_sub_{db_user.id}_{order.id}_{subscription_params['period_days']}"
@@ -977,8 +989,11 @@ async def handle_simple_subscription_payment_method(
if show_devices:
message_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
yookassa_traffic_gb = subscription_params["traffic_limit_gb"]
yookassa_traffic_label = "Безлимит" if yookassa_traffic_gb == 0 else f"{yookassa_traffic_gb} ГБ"
message_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {yookassa_traffic_label}",
f"💰 Сумма: {settings.format_price(price_kopeks)}",
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...",
"",
@@ -2220,8 +2235,11 @@ async def confirm_simple_subscription_purchase(
if show_devices:
success_lines.append(f"📱 Устройства: {subscription_params['device_limit']}")
success_traffic_gb = subscription_params["traffic_limit_gb"]
success_traffic_label = "Безлимит" if success_traffic_gb == 0 else f"{success_traffic_gb} ГБ"
success_lines.extend([
f"📊 Трафик: {'Безлимит' if subscription_params['traffic_limit_gb'] == 0 else f'{subscription_params['traffic_limit_gb']} ГБ'}",
f"📊 Трафик: {success_traffic_label}",
f"🌍 Сервер: {server_label}",
"",
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}",
+39 -7
View File
@@ -1,10 +1,10 @@
import logging
from decimal import Decimal, ROUND_HALF_UP
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
texts = get_texts(DEFAULT_LANGUAGE)
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
logger.info(
f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}"
)
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
@@ -35,6 +37,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
@@ -77,6 +80,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
state: FSMContext,
**kwargs
):
texts = get_texts(DEFAULT_LANGUAGE)
@@ -106,6 +110,27 @@ async def handle_successful_payment(
return
payment_service = PaymentService(message.bot)
state_data = await state.get_data()
prompt_message_id = state_data.get("stars_prompt_message_id")
prompt_chat_id = state_data.get("stars_prompt_chat_id", message.chat.id)
invoice_message_id = state_data.get("stars_invoice_message_id")
invoice_chat_id = state_data.get("stars_invoice_chat_id", message.chat.id)
for chat_id, message_id, label in [
(prompt_chat_id, prompt_message_id, "запрос суммы"),
(invoice_chat_id, invoice_message_id, "инвойс Stars"),
]:
if message_id:
try:
await message.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - зависит от прав бота
logger.warning(
"Не удалось удалить сообщение %s после оплаты Stars: %s",
label,
delete_error,
)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
@@ -113,7 +138,14 @@ async def handle_successful_payment(
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
await state.update_data(
stars_prompt_message_id=None,
stars_prompt_chat_id=None,
stars_invoice_message_id=None,
stars_invoice_chat_id=None,
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
@@ -172,15 +204,15 @@ async def handle_successful_payment(
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+291 -56
View File
@@ -20,18 +20,20 @@ from app.database.crud.campaign import (
from app.database.models import UserStatus, SubscriptionStatus
from app.keyboards.inline import (
get_rules_keyboard,
get_privacy_policy_keyboard,
get_main_menu_keyboard,
get_post_registration_keyboard,
get_language_selection_keyboard,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.localization.texts import get_texts, get_rules, get_privacy_policy
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -40,6 +42,7 @@ from app.utils.promo_offer import (
from app.utils.timezone import format_local_datetime
from app.database.crud.user_message import get_random_active_message
from app.database.crud.subscription import decrement_subscription_server_counts
from app.services.blacklist_service import blacklist_service
logger = logging.getLogger(__name__)
@@ -104,6 +107,7 @@ async def handle_potential_referral_code(
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None
]:
@@ -611,12 +615,105 @@ async def process_language_selection(
)
async def _show_privacy_policy_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> bool:
"""
Показывает политику конфиденциальности после принятия правил.
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
"""
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
if not policy or not policy.is_enabled:
logger.info("⚠️ Политика конфиденциальности не включена, пропускаем её показ")
return False
if not policy.content or not policy.content.strip():
privacy_policy_text = get_privacy_policy(language)
if not privacy_policy_text or not privacy_policy_text.strip():
logger.info("⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ")
return False
logger.info(f"🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка {language}")
else:
privacy_policy_text = policy.content
logger.info(f"🔒 Используется политика конфиденциальности из БД для языка {language}")
try:
await callback.message.edit_text(
privacy_policy_text,
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(f"🔒 Политика конфиденциальности отправлена пользователю {callback.from_user.id}")
return True
except Exception as e:
logger.error(f"Ошибка при показе политики конфиденциальности: {e}", exc_info=True)
try:
await callback.message.answer(
privacy_policy_text,
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(f"🔒 Политика конфиденциальности отправлена новым сообщением пользователю {callback.from_user.id}")
return True
except Exception as e2:
logger.error(f"Критическая ошибка при отправке политики конфиденциальности: {e2}", exc_info=True)
return False
async def _continue_registration_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> None:
"""
Продолжает регистрацию после принятия правил (реферальный код или завершение).
"""
data = await state.get_data() or {}
texts = get_texts(language)
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.edit_text(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
async def process_rules_accept(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
"""
Обрабатывает принятие или отклонение правил пользователем.
"""
logger.info(f"📋 RULES: Начало обработки правил")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
@@ -637,50 +734,16 @@ async def process_rules_accept(
if callback.data == 'rules_accept':
logger.info(f"✅ Правила приняты пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с правилами удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с правилами: {e}")
try:
await callback.message.edit_text(
texts.t(
"RULES_ACCEPTED_PROCESSING",
"✅ Правила приняты! Завершаем регистрацию...",
),
reply_markup=None
)
except Exception:
pass
# Пытаемся показать политику конфиденциальности
policy_shown = await _show_privacy_policy_after_rules(
callback, state, db, language
)
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
# Если политика не была показана, продолжаем регистрацию
if not policy_shown:
await _continue_registration_after_rules(
callback, state, db, language
)
else:
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
@@ -697,10 +760,13 @@ async def process_rules_accept(
)
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении правил: {e}")
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
try:
await callback.message.edit_text(
rules_required_text,
reply_markup=get_rules_keyboard(language)
)
except:
pass
logger.info(f"✅ Правила обработаны для пользователя {callback.from_user.id}")
@@ -727,6 +793,128 @@ async def process_rules_accept(
pass
async def process_privacy_policy_accept(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности")
logger.info(f"📊 Callback data: {callback.data}")
logger.info(f"👤 User: {callback.from_user.id}")
current_state = await state.get_state()
logger.info(f"📊 Текущее состояние: {current_state}")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'privacy_policy_accept':
logger.info(f"✅ Политика конфиденциальности принята пользователем {callback.from_user.id}")
try:
await callback.message.delete()
logger.info(f"🗑️ Сообщение с политикой конфиденциальности удалено")
except Exception as e:
logger.warning(f"⚠️ Не удалось удалить сообщение с политикой конфиденциальности: {e}")
try:
await callback.message.edit_text(
texts.t(
"PRIVACY_POLICY_ACCEPTED_PROCESSING",
"✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
),
reply_markup=None
)
except Exception:
pass
if data.get('referral_code'):
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
await complete_registration_from_callback(callback, state, db)
else:
if settings.SKIP_REFERRAL_CODE:
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
await complete_registration_from_callback(callback, state, db)
else:
try:
await state.set_data(data)
await state.set_state(RegistrationStates.waiting_for_referral_code)
await callback.bot.send_message(
chat_id=callback.from_user.id,
text=texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
logger.info(f"🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration_from_callback(callback, state, db)
else:
logger.info(f"❌ Политика конфиденциальности отклонена пользователем {callback.from_user.id}")
privacy_policy_required_text = texts.t(
"PRIVACY_POLICY_REQUIRED",
"Для использования бота необходимо принять политику конфиденциальности.",
)
try:
await callback.message.edit_text(
privacy_policy_required_text,
reply_markup=get_privacy_policy_keyboard(language)
)
except Exception as e:
logger.error(f"Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}")
try:
await callback.message.edit_text(
privacy_policy_required_text,
reply_markup=get_privacy_policy_keyboard(language)
)
except:
pass
logger.info(f"✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}")
except Exception as e:
logger.error(f"❌ Ошибка обработки политики конфиденциальности: {e}", exc_info=True)
await callback.answer(
texts.t("ERROR_TRY_AGAIN", "❌ Произошла ошибка. Попробуйте еще раз."),
show_alert=True,
)
try:
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
await callback.message.answer(
texts.t(
"ERROR_PRIVACY_POLICY_RETRY",
"Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
),
reply_markup=get_privacy_policy_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
except:
pass
async def process_referral_code_input(
message: types.Message,
state: FSMContext,
@@ -813,13 +1001,33 @@ async def process_referral_code_skip(
async def complete_registration_from_callback(
callback: types.CallbackQuery,
state: FSMContext,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.message.answer(
f"🚫 Регистрация невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
await state.clear()
return
from sqlalchemy.orm import selectinload
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1068,12 +1276,32 @@ async def complete_registration_from_callback(
async def complete_registration(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
message.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await message.answer(
f"🚫 Регистрация невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку."
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1751,7 +1979,14 @@ def register_handlers(dp: Dispatcher):
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_privacy_policy_accept,
F.data.in_(["privacy_policy_accept", "privacy_policy_decline"]),
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept)
)
logger.info("✅ Зарегистрирован process_privacy_policy_accept")
dp.callback_query.register(
process_language_selection,
F.data.startswith("language_select:"),
+22
View File
@@ -406,6 +406,17 @@ async def apply_countries_changes(
await callback.answer()
return
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
if len(selected_countries) == 0:
await callback.answer(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
show_alert=True
)
return
try:
if added and total_cost > 0:
success = await subtract_user_balance(
@@ -898,6 +909,17 @@ async def confirm_add_countries_to_subscription(
return
try:
# Проверяем, что пользователь не пытается отключить все страны (должна остаться хотя бы 1 страна)
if len(selected_countries) == 0:
await callback.answer(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
show_alert=True
)
return
if new_countries and total_price > 0:
success = await subtract_user_balance(
db, db_user, total_price,
+209 -78
View File
@@ -51,6 +51,7 @@ from app.services.user_cart_service import user_cart_service
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
from app.services.blacklist_service import blacklist_service
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
get_subscription_checkout_draft,
@@ -995,7 +996,7 @@ async def save_cart_and_redirect_to_topup(
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -1020,7 +1021,7 @@ async def return_to_saved_cart(
):
# Получаем данные корзины из Redis
cart_data = await user_cart_service.get_user_cart(db_user.id)
if not cart_data:
await callback.answer("❌ Сохраненная корзина не найдена", show_alert=True)
return
@@ -1347,6 +1348,25 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Продление подписки невозможно\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
from app.services.admin_notification_service import AdminNotificationService
days = int(callback.data.split('_')[2])
@@ -1528,7 +1548,7 @@ async def confirm_extend_subscription(
'description': f"Продление подписки на {days} дней",
'consume_promo_offer': bool(promo_component["discount"] > 0),
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -1760,20 +1780,34 @@ async def select_devices(
)
countries = await _get_available_countries(db_user.promo_group_id)
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
countries_price = sum(
c['price_kopeks'] for c in countries
if c['uuid'] in data['countries']
if c['uuid'] in selected_countries
)
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
previous_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
data['devices'] = devices
data['total_price'] = base_price + countries_price + devices_price
await state.set_data(data)
await callback.message.edit_reply_markup(
reply_markup=get_devices_keyboard(devices, db_user.language)
)
if devices != previous_devices:
try:
await callback.message.edit_reply_markup(
reply_markup=get_devices_keyboard(devices, db_user.language)
)
except TelegramBadRequest as error:
if "message is not modified" in str(error).lower():
logger.debug(
"ℹ️ Пропускаем обновление клавиатуры устройств: содержимое не изменилось"
)
else:
raise
await callback.answer()
async def devices_continue(
@@ -1797,6 +1831,25 @@ async def confirm_purchase(
):
from app.services.admin_notification_service import AdminNotificationService
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Покупка подписки невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
data = await state.get_data()
texts = get_texts(db_user.language)
@@ -1809,9 +1862,15 @@ async def confirm_purchase(
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = data.get(
'months_in_period', calculate_months_from_days(data['period_days'])
)
period_days = data.get('period_days')
if period_days is None:
await callback.message.edit_text(
texts.t("SUBSCRIPTION_PURCHASE_ERROR", "Ошибка при оформлении подписки. Попробуйте начать сначала."),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
months_in_period = data.get('months_in_period', calculate_months_from_days(period_days))
base_price = data.get('base_price')
base_price_original = data.get('base_price_original')
@@ -1819,10 +1878,10 @@ async def confirm_purchase(
base_discount_total = data.get('base_discount_total')
if base_price is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_price_original = PERIOD_PRICES[period_days]
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
period_days,
)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
@@ -1830,11 +1889,11 @@ async def confirm_purchase(
)
else:
if base_price_original is None:
base_price_original = PERIOD_PRICES[data['period_days']]
base_price_original = PERIOD_PRICES[period_days]
if base_discount_percent is None:
base_discount_percent = db_user.get_promo_discount(
"period",
data['period_days'],
period_days,
)
if base_discount_total is None:
_, base_discount_total = apply_percentage_discount(
@@ -1847,14 +1906,16 @@ async def confirm_purchase(
countries_price_per_month = 0
per_month_prices: List[int] = []
for country in countries:
if country['uuid'] in data['countries']:
# Проверяем, что ключ 'countries' существует в данных перед доступом к нему
selected_countries = data.get('countries', [])
if country['uuid'] in selected_countries:
server_price_per_month = country['price_kopeks']
countries_price_per_month += server_price_per_month
per_month_prices.append(server_price_per_month)
servers_discount_percent = db_user.get_promo_discount(
"servers",
data['period_days'],
period_days,
)
total_servers_price = 0
total_servers_discount = 0
@@ -1916,7 +1977,7 @@ async def confirm_purchase(
else:
devices_discount_percent = db_user.get_promo_discount(
"devices",
data['period_days'],
period_days,
)
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
@@ -1932,9 +1993,15 @@ async def confirm_purchase(
)
else:
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
)
traffic_gb = data.get('traffic_gb')
if traffic_gb is not None:
traffic_price_per_month = data.get(
'traffic_price_per_month', settings.get_traffic_price(traffic_gb)
)
else:
traffic_price_per_month = data.get(
'traffic_price_per_month', 0
)
if 'traffic_discount_percent' in data:
traffic_discount_percent = data.get('traffic_discount_percent', 0)
@@ -1948,7 +2015,7 @@ async def confirm_purchase(
else:
traffic_discount_percent = db_user.get_promo_discount(
"traffic",
data['period_days'],
period_days,
)
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
traffic_price_per_month,
@@ -1959,7 +2026,7 @@ async def confirm_purchase(
total_servers_price = data.get('total_servers_price', total_countries_price)
cached_total_price = data['total_price']
cached_total_price = data.get('total_price', 0)
cached_promo_discount_value = data.get('promo_offer_discount_value', 0)
validation_total_price = data.get('total_price_before_promo_offer')
@@ -2073,7 +2140,7 @@ async def confirm_purchase(
'return_to_cart': True,
'user_id': db_user.id
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
@@ -2169,10 +2236,10 @@ async def confirm_purchase(
trial_duration_days=trial_duration,
payment_method="balance",
first_payment_amount_kopeks=final_price,
first_paid_period_days=data['period_days']
first_paid_period_days=period_days
)
logger.info(
f"Записана конверсия: {trial_duration} дн. триал → {data['period_days']} дн. платная за {final_price / 100}")
f"Записана конверсия: {trial_duration} дн. триал → {period_days} дн. платная за {final_price / 100}")
except Exception as conversion_error:
logger.error(f"Ошибка записи конверсии: {conversion_error}")
@@ -2181,10 +2248,38 @@ async def confirm_purchase(
existing_subscription.traffic_limit_gb = final_traffic_gb
if should_update_devices:
existing_subscription.device_limit = selected_devices
existing_subscription.connected_squads = data['countries']
# Проверяем, что при обновлении существующей подписки есть хотя бы одна страна
selected_countries = data.get('countries')
if not selected_countries:
# Иногда после возврата к оформлению из сохраненной корзины список стран не передается.
# В таком случае повторно используем текущие подключенные страны подписки.
selected_countries = existing_subscription.connected_squads or []
if selected_countries:
data['countries'] = selected_countries # чтобы далее использовать фактический список стран
existing_subscription.start_date = current_time
existing_subscription.end_date = current_time + timedelta(days=data['period_days']) + bonus_period
if not selected_countries:
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
existing_subscription.connected_squads = selected_countries
# Если подписка еще активна, продлеваем от текущей даты окончания,
# иначе начинаем новый период с текущего момента
extension_base_date = current_time
if existing_subscription.end_date and existing_subscription.end_date > current_time:
extension_base_date = existing_subscription.end_date
else:
existing_subscription.start_date = current_time
existing_subscription.end_date = extension_base_date + timedelta(days=period_days) + bonus_period
existing_subscription.updated_at = current_time
existing_subscription.traffic_used_gb = 0.0
@@ -2210,12 +2305,29 @@ async def confirm_purchase(
if resolved_device_limit is None and devices_selection_enabled:
resolved_device_limit = default_device_limit
# Проверяем, что для новой подписки также есть хотя бы одна страна, если пользователь проходит через интерфейс стран
new_subscription_countries = data.get('countries')
if not new_subscription_countries:
# Проверяем, была ли это покупка через интерфейс стран, и если да, то требуем хотя бы одну страну
# Если в данных явно указано, что это интерфейс стран, или есть другие признаки - требуем страну
# Для упрощения - проверим, что страна обязательна, если идет через UI стран
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"COUNTRIES_MINIMUM_REQUIRED",
"❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна."
),
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
duration_days=data['period_days'],
duration_days=period_days,
device_limit=resolved_device_limit,
connected_squads=data['countries'],
connected_squads=new_subscription_countries,
traffic_gb=final_traffic_gb
)
@@ -2225,18 +2337,18 @@ async def confirm_purchase(
from app.database.crud.server_squad import get_server_ids_by_uuids, add_user_to_servers
from app.database.crud.subscription import add_subscription_servers
server_ids = await get_server_ids_by_uuids(db, data['countries'])
server_ids = await get_server_ids_by_uuids(db, data.get('countries', []))
if server_ids:
await add_subscription_servers(db, subscription, server_ids, server_prices)
await add_user_to_servers(db, server_ids)
logger.info(f"Сохранены цены серверов за весь период: {server_prices}")
await db.refresh(db_user)
subscription_service = SubscriptionService()
if db_user.remnawave_uuid:
remnawave_user = await subscription_service.update_remnawave_user(
db,
@@ -2251,7 +2363,7 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки",
)
if not remnawave_user:
logger.error(f"Не удалось создать/обновить RemnaWave пользователя для {db_user.telegram_id}")
remnawave_user = await subscription_service.create_remnawave_user(
@@ -2260,19 +2372,19 @@ async def confirm_purchase(
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="покупка подписки (повторная попытка)",
)
transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_price,
description=f"Подписка на {data['period_days']} дней ({months_in_period} мес)"
description=f"Подписка на {period_days} дней ({months_in_period} мес)"
)
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_purchase_notification(
db, db_user, subscription, transaction, data['period_days'], was_trial_conversion
db, db_user, subscription, transaction, period_days, was_trial_conversion
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
@@ -2867,7 +2979,7 @@ def register_handlers(dp: Dispatcher):
show_device_connection_help,
F.data == "device_connection_help"
)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
@@ -2882,12 +2994,31 @@ async def handle_simple_subscription_purchase(
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id,
callback.from_user.username
)
if is_blacklisted:
logger.warning(f"🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}")
try:
await callback.answer(
f"🚫 Простая покупка подписки невозможна\n\n"
f"Причина: {blacklist_reason}\n\n"
f"Если вы считаете, что это ошибка, обратитесь в поддержку.",
show_alert=True
)
except Exception as e:
logger.error(f"Ошибка при отправке сообщения о блокировке: {e}")
return
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Определяем ограничение по устройствам для текущего режима
simple_device_limit = resolve_simple_subscription_device_limit()
@@ -2917,10 +3048,10 @@ async def handle_simple_subscription_purchase(
"traffic_limit_gb": settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
"squad_uuid": settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID
}
# Сохраняем параметры в состояние
await state.update_data(subscription_params=subscription_params)
# Проверяем баланс пользователя
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0)
# Рассчитываем цену подписки
@@ -2945,7 +3076,7 @@ async def handle_simple_subscription_purchase(
if subscription_params["traffic_limit_gb"] == 0
else f"{subscription_params['traffic_limit_gb']} ГБ"
)
if user_balance_kopeks >= price_kopeks:
# Если баланс достаточный, предлагаем оплатить с баланса
simple_lines = [
@@ -2968,7 +3099,7 @@ async def handle_simple_subscription_purchase(
])
message_text = "\n".join(simple_lines)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="✅ Оплатить с баланса", callback_data="simple_subscription_pay_with_balance")],
[types.InlineKeyboardButton(text="💳 Другие способы оплаты", callback_data="simple_subscription_other_payment_methods")],
@@ -2996,19 +3127,19 @@ async def handle_simple_subscription_purchase(
])
message_text = "\n".join(simple_lines)
keyboard = _get_simple_subscription_payment_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.waiting_for_simple_subscription_payment_method)
await callback.answer()
async def _calculate_simple_subscription_price(
@@ -3033,14 +3164,14 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
"""Создает клавиатуру с методами оплаты для простой подписки."""
texts = get_texts(language)
keyboard = []
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="simple_subscription_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
@@ -3054,38 +3185,38 @@ def _get_simple_subscription_payment_keyboard(language: str) -> types.InlineKeyb
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="simple_subscription_cryptobot"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="simple_subscription_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="simple_subscription_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="simple_subscription_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_purchase"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -3107,9 +3238,9 @@ async def _extend_existing_subscription(
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import calculate_months_from_days
from datetime import datetime, timedelta
texts = get_texts(db_user.language)
# Рассчитываем цену подписки
subscription_params = {
"period_days": period_days,
@@ -3133,7 +3264,7 @@ async def _extend_existing_subscription(
price_breakdown.get("servers_price", 0),
price_breakdown.get("total_discount", 0),
)
# Проверяем баланс пользователя
if db_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - db_user.balance_kopeks
@@ -3151,7 +3282,7 @@ async def _extend_existing_subscription(
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
# Подготовим данные для сохранения в корзину
from app.services.user_cart_service import user_cart_service
cart_data = {
@@ -3169,9 +3300,9 @@ async def _extend_existing_subscription(
'squad_uuid': squad_uuid,
'consume_promo_offer': False,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
@@ -3183,7 +3314,7 @@ async def _extend_existing_subscription(
)
await callback.answer()
return
# Списываем средства
success = await subtract_user_balance(
db,
@@ -3192,15 +3323,15 @@ async def _extend_existing_subscription(
f"Продление подписки на {period_days} дней",
consume_promo_offer=False, # Простая покупка не использует промо-скидки
)
if not success:
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
return
# Обновляем параметры подписки
current_time = datetime.utcnow()
old_end_date = current_subscription.end_date
# Обновляем параметры в зависимости от типа текущей подписки
if current_subscription.is_trial:
# При продлении триальной подписки переводим её в обычную
@@ -3224,7 +3355,7 @@ async def _extend_existing_subscription(
if squad_uuid and squad_uuid not in current_subscription.connected_squads:
# Используем += для безопасного добавления в список SQLAlchemy
current_subscription.connected_squads = current_subscription.connected_squads + [squad_uuid]
# Продлеваем подписку
if current_subscription.end_date > current_time:
# Если подписка ещё активна, добавляем дни к текущей дате окончания
@@ -3232,15 +3363,15 @@ async def _extend_existing_subscription(
else:
# Если подписка уже истекла, начинаем от текущего времени
new_end_date = current_time + timedelta(days=period_days)
current_subscription.end_date = new_end_date
current_subscription.updated_at = current_time
# Сохраняем изменения
await db.commit()
await db.refresh(current_subscription)
await db.refresh(db_user)
# Обновляем пользователя в Remnawave
subscription_service = SubscriptionService()
try:
@@ -3256,7 +3387,7 @@ async def _extend_existing_subscription(
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
except Exception as e:
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
# Создаём транзакцию
transaction = await create_transaction(
db=db,
@@ -3265,7 +3396,7 @@ async def _extend_existing_subscription(
amount_kopeks=price_kopeks,
description=f"Продление подписки на {period_days} дней"
)
# Отправляем уведомление админу
try:
notification_service = AdminNotificationService(callback.bot)
@@ -3281,7 +3412,7 @@ async def _extend_existing_subscription(
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
# Отправляем сообщение пользователю
success_message = (
"✅ Подписка успешно продлена!\n\n"
@@ -3289,15 +3420,15 @@ async def _extend_existing_subscription(
f"Действует до: {format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')}\n\n"
f"💰 Списано: {texts.format_price(price_kopeks)}"
)
# Если это была триальная подписка, добавляем информацию о преобразовании
if current_subscription.is_trial:
success_message += "\n🎯 Триальная подписка преобразована в платную"
await callback.message.edit_text(
success_message,
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {period_days} дней за {price_kopeks / 100}")
await callback.answer()
+42
View File
@@ -54,6 +54,10 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
callback_data="admin_trials",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
@@ -241,6 +245,20 @@ def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMar
])
def get_admin_trials_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_TRIALS_RESET_BUTTON", "♻️ Сбросить все триалы"),
callback_data="admin_trials_reset",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")],
])
def get_admin_reports_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -302,6 +320,18 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_users_filters"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_BLACKLIST", "🔐 Черный список"),
callback_data="admin_blacklist_settings"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_BULK_BAN", "🛑 Массовый бан"),
callback_data="admin_bulk_ban_start"
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_users")
]
@@ -342,6 +372,12 @@ def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMark
callback_data="admin_users_purchases_filter"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_FILTER_RENEW_READY", "♻️ Готовы к продлению"),
callback_data="admin_users_ready_to_renew_filter"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USERS_FILTER_CAMPAIGN", "📢 По кампании"),
@@ -1100,6 +1136,12 @@ def get_sync_options_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="sync_all_users"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SYNC_TO_PANEL", "⬆️ Синхронизация в панель"),
callback_data="sync_to_panel"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_SYNC_ONLY_NEW", "🆕 Только новые"),
+92 -17
View File
@@ -82,7 +82,40 @@ def _build_additional_buttons(additional_section, language: str) -> List[InlineK
_LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский",
"ru-ru": "🇷🇺 Русский",
"en": "🇬🇧 English",
"en-us": "🇺🇸 English",
"en-gb": "🇬🇧 English",
"ua": "🇺🇦 Українська",
"uk": "🇺🇦 Українська",
"uk-ua": "🇺🇦 Українська",
"kk": "🇰🇿 Қазақша",
"kk-kz": "🇰🇿 Қазақша",
"kz": "🇰🇿 Қазақша",
"uz": "🇺🇿 Oʻzbekcha",
"uz-uz": "🇺🇿 Oʻzbekcha",
"tr": "🇹🇷 Türkçe",
"tr-tr": "🇹🇷 Türkçe",
"pl": "🇵🇱 Polski",
"pl-pl": "🇵🇱 Polski",
"de": "🇩🇪 Deutsch",
"de-de": "🇩🇪 Deutsch",
"fr": "🇫🇷 Français",
"fr-fr": "🇫🇷 Français",
"es": "🇪🇸 Español",
"es-es": "🇪🇸 Español",
"it": "🇮🇹 Italiano",
"it-it": "🇮🇹 Italiano",
"pt": "🇵🇹 Português",
"pt-pt": "🇵🇹 Português",
"pt-br": "🇧🇷 Português",
"zh": "🇨🇳 中文",
"zh-cn": "🇨🇳 中文 (简体)",
"zh-hans": "🇨🇳 中文 (简体)",
"zh-tw": "🇹🇼 中文 (繁體)",
"zh-hant": "🇹🇼 中文 (繁體)",
"vi": "🇻🇳 Tiếng Việt",
"vi-vn": "🇻🇳 Tiếng Việt",
}
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
@@ -94,28 +127,50 @@ def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup
]
])
def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.PRIVACY_POLICY_ACCEPT,
callback_data="privacy_policy_accept"
),
InlineKeyboardButton(
text=texts.PRIVACY_POLICY_DECLINE,
callback_data="privacy_policy_decline"
)
]
])
def get_channel_sub_keyboard(
channel_link: str,
channel_link: Optional[str],
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
buttons: List[List[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=channel_link,
)
],
[
InlineKeyboardButton(
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
callback_data="sub_channel_check",
)
],
]
)
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался"),
callback_data="sub_channel_check",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -703,6 +758,7 @@ def get_subscription_keyboard(
texts = get_texts(language)
keyboard = []
has_direct_payment_methods = False
if has_subscription:
subscription_link = get_display_subscription_link(subscription) if subscription else None
@@ -1091,6 +1147,7 @@ def get_balance_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMark
def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
has_direct_payment_methods = False
amount_kopeks = max(0, int(amount_kopeks or 0))
@@ -1106,6 +1163,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("stars")
)
])
has_direct_payment_methods = True
if settings.is_yookassa_enabled():
if settings.YOOKASSA_SBP_ENABLED:
@@ -1115,6 +1173,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("yookassa_sbp"),
)
])
has_direct_payment_methods = True
keyboard.append([
InlineKeyboardButton(
@@ -1122,6 +1181,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("yookassa"),
)
])
has_direct_payment_methods = True
if settings.TRIBUTE_ENABLED:
keyboard.append([
@@ -1130,6 +1190,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("tribute")
)
])
has_direct_payment_methods = True
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
@@ -1142,6 +1203,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("mulenpay")
)
])
has_direct_payment_methods = True
if settings.is_wata_enabled():
keyboard.append([
@@ -1150,6 +1212,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("wata")
)
])
has_direct_payment_methods = True
if settings.is_pal24_enabled():
keyboard.append([
@@ -1158,6 +1221,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("pal24")
)
])
has_direct_payment_methods = True
if settings.is_platega_enabled() and settings.get_platega_active_methods():
keyboard.append([
@@ -1166,6 +1230,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("platega"),
)
])
has_direct_payment_methods = True
if settings.is_cryptobot_enabled():
keyboard.append([
@@ -1174,6 +1239,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("cryptobot")
)
])
has_direct_payment_methods = True
if settings.is_heleket_enabled():
keyboard.append([
@@ -1182,15 +1248,24 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("heleket")
)
])
has_direct_payment_methods = True
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
if len(keyboard) == 1:
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
callback_data="payment_methods_unavailable"
)
])
elif not has_direct_payment_methods and settings.is_support_topup_enabled():
keyboard.insert(0, [
InlineKeyboardButton(
text=texts.t("PAYMENTS_TEMPORARILY_UNAVAILABLE", "⚠️ Способы оплаты временно недоступны"),
+9
View File
@@ -32,3 +32,12 @@ RULES_TEXT: |
2. Do not distribute spam or malicious content.
3. Respect other community members.
PAYMENT_METHODS_NONE_AVAILABLE: |
💳 <b>Balance top-up methods</b>
⚠️ Payment methods are temporarily unavailable.
Please try again later.
Choose a top-up option:
SUPPORT_TOPUP_DISABLED: "Top-ups via support are disabled. Please use another payment method."
+9
View File
@@ -32,3 +32,12 @@ RULES_TEXT: |
2. Не распространяйте спам и вредоносный контент.
3. Уважайте других пользователей.
PAYMENT_METHODS_NONE_AVAILABLE: |
💳 <b>Способы пополнения баланса</b>
⚠️ В данный момент способы оплаты временно недоступны.
Попробуйте позже.
Выберите способ пополнения:
SUPPORT_TOPUP_DISABLED: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
+38 -8
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Settings",
"ADMIN_MAIN_SUPPORT": "🛟 Support",
"ADMIN_MAIN_SYSTEM": "🛠️ System",
"ADMIN_MAIN_TRIALS": "🧪 Trials",
"ADMIN_MAIN_PAYMENTS": "💳 Top-ups",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Users / Subscriptions",
"ADMIN_MESSAGES": "📨 Broadcasts",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 <b>Top-up verification</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Pending top-up invoices created during the last 24 hours.",
"ADMIN_PAYMENTS_NOTICE": "Only invoices younger than 24 hours and waiting for payment can be checked.",
"ADMIN_TRIALS_TITLE": "🧪 Trial management",
"ADMIN_TRIALS_STATS": "• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Reset all trials",
"ADMIN_TRIALS_RESET_RESULT": "♻️ Reset {reset_count} trials.\n\n• Total trials used: {used}\n• Active now: {active}\n• Eligible for reset: {resettable}",
"ADMIN_TRIALS_RESET_TOAST": "✅ Reset completed",
"ADMIN_PAYMENTS_EMPTY": "No pending top-up invoices found in the last 24 hours.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 #{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Pending",
@@ -705,6 +711,10 @@
"ADMIN_USERS_FILTERS": "⚙️ Filters",
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 By activity",
"ADMIN_USERS_FILTER_BALANCE": "💰 By balance",
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Ready to renew",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Users ready to renew",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Their subscription expired and the balance still has {amount} or more.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "No users match this filter right now.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 By campaign",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 By purchases",
"ADMIN_USERS_FILTER_SPENDING": "💳 By spending",
@@ -725,6 +735,22 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "️ The user is already in this promo group.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Referrals",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>User referrals</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Total referrals: {count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Commission percent: {percent}% (default)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Custom percent: {percent}% (default: {default_percent}%)",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Change percent",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Custom referral commission</b>\n\nCurrent value: {current}%\nDefault value: {default}%\n\nSend a value from 0 to 100 or the word 'standard' to reset.",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Reset to default",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Percent updated: {percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Enter a number from 0 to 100 or the word 'standard'",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>List of referrals:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … and {count} more referrals",
"ADMIN_USER_REFERRALS_EMPTY": "No referrals yet.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ To change the list, tap “✏️ Edit” below.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Edit",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
@@ -843,6 +869,7 @@
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Removed countries:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "️ Reconnecting later will be charged",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Countries updated!</b>\n\n",
"COUNTRIES_MINIMUM_REQUIRED": "❌ Cannot disconnect all countries. At least one country must remain connected.",
"COUNTRY_MANAGEMENT_NONE": "No countries connected",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Manage subscription countries</b>\n\n📋 <b>Current countries ({current_count}):</b>\n{current_list}\n\n💡 <b>How it works:</b>\n✅ — currently connected\n — will be added (paid)\n — will be removed (free)\n⚪ — not selected\n\n⚠️ <b>Important:</b> Reconnecting removed countries will be charged again!",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "️ Server management is unavailable — only one server is accessible",
@@ -1017,7 +1044,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Follow the payment page instructions",
"PAL24_PAYMENT_ERROR": "❌ Failed to create a PayPalych payment. Please try again later or contact support.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>PayPalych (SBP) payment</b>\n\n💰 Amount: {amount}\n🆔 Invoice ID: {bill_id}\n\n📱 <b>How to pay:</b>\n1. Press Pay with PayPalych (SBP)\n2. Follow the system prompts\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ Need help? Contact {support}",
"PAL24_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_SBP_PAY_BUTTON": "🏦 Pay with PayPalych (SBP)",
"PAL24_SELECT_PAYMENT_METHOD": "Choose a PayPalych payment method:",
"PAL24_TOPUP_PROMPT": "🏦 <b>PayPalych (SBP) payment</b>\n\nEnter an amount between 100 and 1,000,000 ₽.\nThe payment is processed via the PayPalych Faster Payments System.",
@@ -1055,12 +1081,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "via Faster Payments System",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>SBP (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
@@ -1069,8 +1091,8 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "via WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Bank card (WATA)</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "via Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Cryptocurrency (Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "via YooKassa Fast Payment System",
@@ -1215,8 +1237,14 @@
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
"RULES_DECLINE": "❌ I do not accept",
"RULES_HEADER": "📋 <b>Service Rules</b>",
"PRIVACY_POLICY_ACCEPT": "✅ Accept",
"PRIVACY_POLICY_DECLINE": "❌ Decline",
"PRIVACY_POLICY_REQUIRED": "You must accept the privacy policy to use the bot.",
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Privacy policy accepted! Continuing registration...",
"ERROR_PRIVACY_POLICY_RETRY": "An error occurred. Please try accepting the privacy policy again:",
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Privacy Policy</b>\n\nWe are committed to protecting your privacy and personal data.\n\n<b>Data Collection:</b>\n• We collect only necessary information to provide services\n• Data is used exclusively for service operation\n\n<b>Data Protection:</b>\n• Your data is protected by modern encryption methods\n• We do not share data with third parties without your consent\n\n<b>Your Rights:</b>\n• You can request information about stored data\n• You can request deletion of your data\n\n<b>By using the service, you agree to the privacy policy.</b>",
"SELECT_COUNTRIES": "Select countries:",
"SELECT_DEVICES": "Number of devices:",
"SELECT_PERIOD": "Choose period:",
@@ -1504,5 +1532,7 @@
"POLL_EMPTY": "Poll is not available yet.",
"POLL_ERROR": "Unable to process the poll. Please try again later.",
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance."
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
}
+30 -8
View File
@@ -130,6 +130,7 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
@@ -168,6 +169,11 @@
"ADMIN_PAYMENTS_TITLE": "💳 <b>Проверка пополнений</b>",
"ADMIN_PAYMENTS_DESCRIPTION": "Список счетов на пополнение, созданных за последние 24 часа и ожидающих оплаты.",
"ADMIN_PAYMENTS_NOTICE": "Проверять можно только счета моложе 24 часов и со статусом ожидания.",
"ADMIN_TRIALS_TITLE": "🧪 Управление триалами",
"ADMIN_TRIALS_STATS": "• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
"ADMIN_TRIALS_RESET_BUTTON": "♻️ Сбросить все триалы",
"ADMIN_TRIALS_RESET_RESULT": "♻️ Сбросили {reset_count} триалов.\n\n• Использовано всего: {used}\n• Активно сейчас: {active}\n• Доступно к сбросу: {resettable}",
"ADMIN_TRIALS_RESET_TOAST": "✅ Сброс завершен",
"ADMIN_PAYMENTS_EMPTY": "За последние 24 часа не найдено счетов на пополнение в ожидании.",
"ADMIN_PAYMENTS_ITEM_DETAILS": "📄 №{number}",
"ADMIN_PAYMENT_STATUS_PENDING": "Ожидает оплаты",
@@ -682,6 +688,7 @@
"ADMIN_SUPPORT_SUBMENU_TITLE": "🛟 **Поддержка**\n\n",
"ADMIN_SUPPORT_TICKETS": "🎫 Тикеты поддержки",
"ADMIN_SYNC_BACK": "⬅️ К синхронизации",
"ADMIN_SYNC_TO_PANEL": "⬆️ Синхронизация в панель",
"ADMIN_SYNC_CLEANUP": "🧹 Очистка",
"ADMIN_SYNC_CONFIRM": "✅ Подтвердить",
"ADMIN_SYNC_FULL": "🔄 Полная синхронизация",
@@ -705,6 +712,10 @@
"ADMIN_USERS_FILTERS": "⚙️ Фильтры",
"ADMIN_USERS_FILTER_ACTIVITY": "🕒 По активности",
"ADMIN_USERS_FILTER_BALANCE": "💰 По балансу",
"ADMIN_USERS_FILTER_RENEW_READY": "♻️ Готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
@@ -728,6 +739,13 @@
"ADMIN_USER_REFERRALS_BUTTON": "🤝 Рефералы",
"ADMIN_USER_REFERRALS_TITLE": "🤝 <b>Рефералы пользователя</b>",
"ADMIN_USER_REFERRALS_SUMMARY": "👤 {name} (ID: <code>{telegram_id}</code>)\n👥 Всего рефералов: {count}",
"ADMIN_USER_REFERRAL_COMMISSION_DEFAULT": "• Процент комиссии: {percent}% (стандартное значение)",
"ADMIN_USER_REFERRAL_COMMISSION_CUSTOM": "• Индивидуальный процент: {percent}% (стандарт: {default_percent}%)",
"ADMIN_USER_REFERRAL_COMMISSION_EDIT_BUTTON": "📈 Изменить процент",
"ADMIN_USER_REFERRAL_COMMISSION_PROMPT": "📈 <b>Индивидуальный процент реферальной комиссии</b>\n\nТекущее значение: {current}%\nСтандартное значение: {default}%\n\nОтправьте новое значение от 0 до 100 или слово 'стандарт' для сброса.",
"ADMIN_USER_REFERRAL_COMMISSION_RESET_BUTTON": "♻️ Сбросить на стандартный",
"ADMIN_USER_REFERRAL_COMMISSION_UPDATED": "✅ Процент обновлён: {percent}%",
"ADMIN_USER_REFERRAL_COMMISSION_INVALID": "❌ Введите число от 0 до 100 или слово 'стандарт'",
"ADMIN_USER_REFERRALS_LIST_HEADER": "<b>Список рефералов:</b>",
"ADMIN_USER_REFERRALS_LIST_ITEM": "• {name} (ID: <code>{telegram_id}</code>{username_part})",
"ADMIN_USER_REFERRALS_LIST_TRUNCATED": "• … и ещё {count} рефералов",
@@ -863,6 +881,7 @@
"COUNTRY_CHANGES_REMOVED_HEADER": " <b>Отключены страны:</b>\n",
"COUNTRY_CHANGES_REMOVED_WARNING": "ℹ️ Повторное подключение будет платным",
"COUNTRY_CHANGES_SUCCESS_HEADER": "✅ <b>Страны успешно обновлены!</b>\n\n",
"COUNTRIES_MINIMUM_REQUIRED": "❌ Нельзя отключить все страны. Должна быть подключена хотя бы одна страна.",
"COUNTRY_MANAGEMENT_NONE": "Нет подключенных стран",
"COUNTRY_MANAGEMENT_PROMPT": "🌍 <b>Управление странами подписки</b>\n\n📋 <b>Текущие страны ({current_count}):</b>\n{current_list}\n\n💡 <b>Инструкция:</b>\n✅ - страна подключена\n➕ - будет добавлена (платно)\n➖ - будет отключена (бесплатно)\n⚪ - не выбрана\n\n⚠️ <b>Важно:</b> Повторное подключение отключенных стран будет платным!",
"COUNTRY_MANAGEMENT_UNAVAILABLE": "ℹ️ Управление серверами недоступно - доступен только один сервер",
@@ -1037,7 +1056,6 @@
"PAL24_INSTRUCTION_FOLLOW": "{step}. Следуйте подсказкам платёжной системы",
"PAL24_PAYMENT_ERROR": "❌ Ошибка создания платежа PayPalych. Попробуйте позже или обратитесь в поддержку.",
"PAL24_PAYMENT_INSTRUCTIONS": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\n💰 Сумма: {amount}\n🆔 ID счета: {bill_id}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)’\n2. Следуйте подсказкам платежной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PAL24_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_SBP_PAY_BUTTON": "🏦 Оплатить через PayPalych (СБП)",
"PAL24_SELECT_PAYMENT_METHOD": "Выберите способ оплаты PayPalych:",
"PAL24_TOPUP_PROMPT": "🏦 <b>Оплата через PayPalych (СБП)</b>\n\nВведите сумму для пополнения от 100 до 1 000 000 ₽.\nОплата проходит через систему быстрых платежей PayPalych.",
@@ -1075,12 +1093,8 @@
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PAL24_DESCRIPTION": "через систему быстрых платежей",
"PAYMENT_METHOD_PAL24_NAME": "🏦 <b>СБП (PayPalych)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
@@ -1089,8 +1103,8 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_WATA_DESCRIPTION": "через WATA",
"PAYMENT_METHOD_WATA_NAME": "💳 <b>Банковская карта (WATA)</b>",
"PAYMENT_METHOD_HELEKET_DESCRIPTION": "через Heleket",
"PAYMENT_METHOD_HELEKET_NAME": "🪙 <b>Криптовалюта (Heleket)</b>",
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
"PAYMENT_METHOD_YOOKASSA_SBP_DESCRIPTION": "через систему быстрых платежей YooKassa",
@@ -1235,8 +1249,14 @@
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
"RULES_DECLINE": "❌ Не принимаю",
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
"PRIVACY_POLICY_ACCEPT": "✅ Принять",
"PRIVACY_POLICY_DECLINE": "❌ Отклонить",
"PRIVACY_POLICY_REQUIRED": "Для использования бота необходимо принять политику конфиденциальности.",
"PRIVACY_POLICY_ACCEPTED_PROCESSING": "✅ Политика конфиденциальности принята! Продолжаем регистрацию...",
"ERROR_PRIVACY_POLICY_RETRY": "Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:",
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
"PRIVACY_POLICY_TEXT_DEFAULT": "🔒 <b>Политика конфиденциальности</b>\n\nМы обязуемся защищать вашу конфиденциальность и личные данные.\n\n<b>Сбор данных:</b>\n• Мы собираем только необходимую информацию для предоставления услуг\n• Данные используются исключительно для работы сервиса\n\n<b>Защита данных:</b>\n• Ваши данные защищены современными методами шифрования\n• Мы не передаем данные третьим лицам без вашего согласия\n\n<b>Ваши права:</b>\n• Вы можете запросить информацию о хранимых данных\n• Вы можете запросить удаление ваших данных\n\n<b>Используя сервис, вы соглашаетесь с политикой конфиденциальности.</b>",
"SELECT_COUNTRIES": "Выберите страны:",
"SELECT_DEVICES": "Количество устройств:",
"SELECT_PERIOD": "Выберите период:",
@@ -1524,5 +1544,7 @@
"POLL_EMPTY": "Опрос пока недоступен.",
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс."
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110 -37
View File
@@ -16,6 +16,79 @@ _logger = logging.getLogger(__name__)
_cached_rules: Dict[str, str] = {}
_LANGUAGE_ALIASES = {
"uk": "ua",
}
_DYNAMIC_LANGUAGE_CONFIGS = {
"ru": {
"traffic_pattern": "📊 {size} ГБ - {price}",
"unlimited_pattern": "📊 Безлимит - {price}",
"support_info": (
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
),
},
"en": {
"traffic_pattern": "📊 {size} GB - {price}",
"unlimited_pattern": "📊 Unlimited - {price}",
"support_info": (
"\n🛟 <b>RemnaWave Support</b>\n\n"
"This is the ticket center: create requests, view replies and history.\n\n"
"• 🎫 Create ticket — describe your issue or question\n"
"• 📋 My tickets — status and conversation\n"
"• 💬 Contact — message directly if needed\n\n"
"Prefer tickets — it helps us respond faster and keep context.\n"
),
},
"ua": {
"traffic_pattern": "📊 {size} ГБ - {price}",
"unlimited_pattern": "📊 Безліміт - {price}",
"support_info": (
"\n🛠️ <b>Технічна підтримка</b>\n\n"
"З усіх питань звертайтеся до нашої підтримки:\n\n"
"👤 {support_username}\n\n"
"Ми допоможемо з:\n"
"• Налаштуванням підключення\n"
"• Вирішенням технічних проблем\n"
"• Питаннями щодо оплати\n"
"• Іншими питаннями\n\n"
"⏰ Час відповіді: зазвичай протягом 1-2 годин\n"
),
},
"zh": {
"traffic_pattern": "📊{size}GB-{price}",
"unlimited_pattern": "📊无限-{price}",
"support_info": (
"\n🛠️ <b>技术支持</b>\n\n"
"如有任何问题,请联系我们的支持团队:\n\n"
"👤 {support_username}\n\n"
"我们将帮助您:\n"
"• 设置连接\n"
"• 解决技术问题\n"
"• 付款问题\n"
"• 其他问题\n\n"
"⏰ 响应时间:通常在 1-2 小时内\n"
),
},
}
_TRAFFIC_TIERS = (
("TRAFFIC_5GB", "5", "PRICE_TRAFFIC_5GB"),
("TRAFFIC_10GB", "10", "PRICE_TRAFFIC_10GB"),
("TRAFFIC_25GB", "25", "PRICE_TRAFFIC_25GB"),
("TRAFFIC_50GB", "50", "PRICE_TRAFFIC_50GB"),
("TRAFFIC_100GB", "100", "PRICE_TRAFFIC_100GB"),
("TRAFFIC_250GB", "250", "PRICE_TRAFFIC_250GB"),
)
def _get_cached_rules_value(language: str) -> str:
if language in _cached_rules:
return _cached_rules[language]
@@ -28,45 +101,32 @@ def _get_cached_rules_value(language: str) -> str:
def _build_dynamic_values(language: str) -> Dict[str, Any]:
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
if language_code == "ru":
return {
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>Поддержка</b>\n\n"
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
"• 📋 Мои тикеты — статус и переписка\n"
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
),
}
language_code = _LANGUAGE_ALIASES.get(language_code, language_code)
config = _DYNAMIC_LANGUAGE_CONFIGS.get(language_code)
if language_code == "en":
return {
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"SUPPORT_INFO": (
"\n🛟 <b>RemnaWave Support</b>\n\n"
"This is the ticket center: create requests, view replies and history.\n\n"
"• 🎫 Create ticket — describe your issue or question\n"
"• 📋 My tickets — status and conversation\n"
"• 💬 Contact — message directly if needed\n\n"
"Prefer tickets — it helps us respond faster and keep context.\n"
),
}
if not config:
return {}
return {}
values: Dict[str, Any] = {}
traffic_pattern = config["traffic_pattern"]
for key, size, price_attr in _TRAFFIC_TIERS:
price_value = getattr(settings, price_attr)
values[key] = traffic_pattern.format(
size=size,
price=settings.format_price(price_value),
)
values["TRAFFIC_UNLIMITED"] = config["unlimited_pattern"].format(
price=settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)
)
support_template = config.get("support_info")
if support_template:
values["SUPPORT_INFO"] = support_template.format(
support_username=settings.SUPPORT_USERNAME
)
return values
class Texts:
@@ -174,6 +234,19 @@ def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
return fallback.get(default_key, "")
def _get_default_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
default_key = "PRIVACY_POLICY_TEXT_DEFAULT"
locale = load_locale(language)
if default_key in locale:
return locale[default_key]
fallback = load_locale(DEFAULT_LANGUAGE)
return fallback.get(default_key, "")
def get_privacy_policy(language: str = DEFAULT_LANGUAGE) -> str:
return _get_default_privacy_policy(language)
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
if language in _cached_rules:
return _cached_rules[language]
+2 -1
View File
@@ -118,6 +118,7 @@ class AuthMiddleware(BaseMiddleware):
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
@@ -128,7 +129,7 @@ class AuthMiddleware(BaseMiddleware):
isinstance(event, CallbackQuery)
and event.data
and (
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
event.data in ['rules_accept', 'rules_decline', 'privacy_policy_accept', 'privacy_policy_decline', 'referral_skip']
or event.data.startswith('language_select:')
)
)
+51 -8
View File
@@ -82,7 +82,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
bot: Bot = data["bot"]
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
return await handler(event, data)
@@ -93,7 +93,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
return await handler(event, data)
channel_link = settings.CHANNEL_LINK
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
if not channel_link:
logger.warning(
"⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта"
)
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
@@ -112,16 +117,16 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
return await self._deny_message(event, bot, channel_link)
return await self._deny_message(event, bot, channel_link, channel_id)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramForbiddenError as e:
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramBadRequest as e:
if "chat not found" in str(e).lower():
logger.error(f"❌ Канал {channel_id} не найден: {e}")
@@ -130,11 +135,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
else:
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link)
return await self._deny_message(event, bot, channel_link, channel_id)
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
@staticmethod
def _normalize_channel_link(channel_link: Optional[str], channel_id: Optional[str]) -> Optional[str]:
link = (channel_link or "").strip()
if link.startswith("@"): # raw username
return f"https://t.me/{link.lstrip('@')}"
if link and not link.lower().startswith(("http://", "https://", "tg://")):
return f"https://{link}"
if link:
return link
if channel_id and str(channel_id).startswith("@"):
return f"https://t.me/{str(channel_id).lstrip('@')}"
return None
async def _capture_start_payload(
self,
state: Optional[FSMContext],
@@ -278,7 +301,12 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
async def _deny_message(
event: TelegramObject,
bot: Bot,
channel_link: Optional[str],
channel_id: Optional[str],
):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
user = None
@@ -301,11 +329,26 @@ class ChannelCheckerMiddleware(BaseMiddleware):
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
)
if not channel_link and channel_id:
channel_hint = None
if str(channel_id).startswith("@"): # username-based channel id
channel_hint = f"@{str(channel_id).lstrip('@')}"
if channel_hint:
text = f"{text}\n\n{channel_hint}"
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
elif isinstance(event, CallbackQuery):
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
try:
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
logger.debug("ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование")
return await event.answer(text, show_alert=True)
raise
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
+323 -15
View File
@@ -8,6 +8,7 @@ from sqlalchemy.exc import MissingGreenlet
from app.config import settings
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.subscription_event import create_subscription_event
from app.database.crud.user import get_user_by_id
from app.database.crud.transaction import get_transaction_by_id
from app.database.models import (
@@ -92,6 +93,51 @@ class AdminNotificationService:
return "IDUnknown"
return f"ID{telegram_id}"
async def _record_subscription_event(
self,
db: AsyncSession,
*,
event_type: str,
user: User,
subscription: Subscription | None,
transaction: Transaction | None = None,
amount_kopeks: int | None = None,
message: str | None = None,
extra: Dict[str, Any] | None = None,
occurred_at: datetime | None = None,
) -> None:
"""Persist subscription-related event for external dashboards."""
try:
await create_subscription_event(
db,
user_id=user.id,
event_type=event_type,
subscription_id=subscription.id if subscription else None,
transaction_id=transaction.id if transaction else None,
amount_kopeks=amount_kopeks,
currency=None,
message=message,
occurred_at=occurred_at,
extra=extra or None,
)
except Exception:
logger.error(
"Не удалось сохранить событие подписки (%s) для пользователя %s",
event_type,
getattr(user, "id", "unknown"),
exc_info=True,
)
try:
await db.rollback()
except Exception:
logger.error(
"Не удалось выполнить rollback после ошибки события подписки пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
@@ -194,10 +240,27 @@ class AdminNotificationService:
*,
charged_amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
try:
await self._record_subscription_event(
db,
event_type="activation",
user=user,
subscription=subscription,
transaction=None,
amount_kopeks=charged_amount_kopeks,
message="Trial activation",
occurred_at=datetime.utcnow(),
extra={
"charged_amount_kopeks": charged_amount_kopeks,
"trial_duration_days": settings.TRIAL_DURATION_DAYS,
"traffic_limit_gb": settings.TRIAL_TRAFFIC_LIMIT_GB,
"device_limit": subscription.device_limit,
},
)
if not self._is_enabled():
return False
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
@@ -255,10 +318,28 @@ class AdminNotificationService:
was_trial_conversion: bool = False,
amount_kopeks: Optional[int] = None,
) -> bool:
if not self._is_enabled():
return False
try:
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
await self._record_subscription_event(
db,
event_type="purchase",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=total_amount,
message="Subscription purchase",
occurred_at=(transaction.completed_at or transaction.created_at) if transaction else datetime.utcnow(),
extra={
"period_days": period_days,
"was_trial_conversion": was_trial_conversion,
"payment_method": self._get_payment_method_display(transaction.payment_method) if transaction else "Баланс",
},
)
if not self._is_enabled():
return False
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
if was_trial_conversion:
@@ -275,7 +356,6 @@ class AdminNotificationService:
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
total_amount = amount_kopeks if amount_kopeks is not None else (transaction.amount_kopeks if transaction else 0)
transaction_id = transaction.id if transaction else ""
message = f"""💎 <b>{event_type}</b>
@@ -469,11 +549,38 @@ class AdminNotificationService:
promo_group: PromoGroup | None,
db: AsyncSession | None = None,
) -> bool:
logger.info("Начинаем отправку уведомления о пополнении баланса")
if db:
try:
await self._record_subscription_event(
db,
event_type="balance_topup",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=transaction.amount_kopeks,
message="Balance top-up",
occurred_at=transaction.completed_at or transaction.created_at,
extra={
"status": topup_status,
"balance_before": old_balance,
"balance_after": user.balance_kopeks,
"referrer_info": referrer_info,
"promo_group_id": getattr(promo_group, "id", None),
"promo_group_name": getattr(promo_group, "name", None),
},
)
except Exception:
logger.error(
"Не удалось сохранить событие пополнения баланса пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
logger.info("Начинаем отправку уведомления о пополнении баланса")
try:
logger.info("Пытаемся создать сообщение уведомления")
message = self._build_balance_topup_message(
@@ -567,19 +674,37 @@ class AdminNotificationService:
new_end_date: datetime | None = None,
balance_after: int | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
await self._record_subscription_event(
db,
event_type="renewal",
user=user,
subscription=subscription,
transaction=transaction,
amount_kopeks=transaction.amount_kopeks,
message="Subscription renewed",
occurred_at=transaction.completed_at or transaction.created_at,
extra={
"extended_days": extended_days,
"previous_end_date": old_end_date.isoformat(),
"new_end_date": current_end_date.isoformat(),
"payment_method": transaction.payment_method,
"balance_after": current_balance,
},
)
if not self._is_enabled():
return False
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user_display}
@@ -619,7 +744,41 @@ class AdminNotificationService:
user: User,
promocode_data: Dict[str, Any],
effect_description: str,
balance_before_kopeks: int | None = None,
balance_after_kopeks: int | None = None,
) -> bool:
try:
await self._record_subscription_event(
db,
event_type="promocode_activation",
user=user,
subscription=None,
transaction=None,
amount_kopeks=promocode_data.get("balance_bonus_kopeks"),
message="Promocode activation",
occurred_at=datetime.utcnow(),
extra={
"code": promocode_data.get("code"),
"type": promocode_data.get("type"),
"subscription_days": promocode_data.get("subscription_days"),
"balance_bonus_kopeks": promocode_data.get("balance_bonus_kopeks"),
"description": effect_description,
"valid_until": (
promocode_data.get("valid_until").isoformat()
if isinstance(promocode_data.get("valid_until"), datetime)
else promocode_data.get("valid_until")
),
"balance_before_kopeks": balance_before_kopeks,
"balance_after_kopeks": balance_after_kopeks,
},
)
except Exception:
logger.error(
"Не удалось сохранить событие активации промокода пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
@@ -665,6 +824,13 @@ class AdminNotificationService:
message_lines.extend(
[
"",
"💼 <b>Баланс:</b>",
(
f"{settings.format_price(balance_before_kopeks)}{settings.format_price(balance_after_kopeks)}"
if balance_before_kopeks is not None and balance_after_kopeks is not None
else "ℹ️ Баланс не изменился"
),
"",
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
@@ -686,6 +852,31 @@ class AdminNotificationService:
campaign: AdvertisingCampaign,
user: Optional[User] = None,
) -> bool:
if user:
try:
await self._record_subscription_event(
db,
event_type="referral_link_visit",
user=user,
subscription=None,
transaction=None,
amount_kopeks=None,
message="Referral link visit",
occurred_at=datetime.utcnow(),
extra={
"campaign_id": campaign.id,
"campaign_name": campaign.name,
"start_parameter": campaign.start_parameter,
"was_registered": bool(user),
},
)
except Exception:
logger.error(
"Не удалось сохранить событие перехода по кампании для пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
@@ -744,6 +935,33 @@ class AdminNotificationService:
initiator: Optional[User] = None,
automatic: bool = False,
) -> bool:
try:
await self._record_subscription_event(
db,
event_type="promo_group_change",
user=user,
subscription=None,
transaction=None,
message="Promo group change",
occurred_at=datetime.utcnow(),
extra={
"old_group_id": getattr(old_group, "id", None),
"old_group_name": getattr(old_group, "name", None),
"new_group_id": new_group.id,
"new_group_name": new_group.name,
"reason": reason,
"initiator_id": getattr(initiator, "id", None),
"initiator_telegram_id": getattr(initiator, "telegram_id", None),
"automatic": automatic,
},
)
except Exception:
logger.error(
"Не удалось сохранить событие смены промогруппы пользователя %s",
getattr(user, "id", "unknown"),
exc_info=True,
)
if not self._is_enabled():
return False
@@ -1233,6 +1451,50 @@ class AdminNotificationService:
return str(value)
return str(value)
async def send_bulk_ban_notification(
self,
admin_user_id: int,
successfully_banned: int,
not_found: int,
errors: int,
admin_name: str = "Администратор"
) -> bool:
"""Отправляет уведомление о массовой блокировке пользователей"""
if not self._is_enabled():
return False
try:
message_lines = [
"🛑 <b>МАССОВАЯ БЛОКИРОВКА ПОЛЬЗОВАТЕЛЕЙ</b>",
"",
f"👮 <b>Администратор:</b> {admin_name}",
f"🆔 <b>ID администратора:</b> {admin_user_id}",
"",
"📊 <b>Результаты:</b>",
f"✅ Успешно заблокировано: {successfully_banned}",
f"❌ Не найдено: {not_found}",
f"💥 Ошибок: {errors}"
]
total_processed = successfully_banned + not_found + errors
if total_processed > 0:
success_rate = (successfully_banned / total_processed) * 100
message_lines.append(f"📈 Успешность: {success_rate:.1f}%")
message_lines.extend(
[
"",
f"⏰ <i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>",
]
)
message = "\n".join(message_lines)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о массовой блокировке: {e}")
return False
async def send_ticket_event_notification(
self,
text: str,
@@ -1250,3 +1512,49 @@ class AdminNotificationService:
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
async def send_suspicious_traffic_notification(
self,
message: str,
bot: Bot,
topic_id: Optional[int] = None
) -> bool:
"""
Отправляет уведомление о подозрительной активности трафика
Args:
message: текст уведомления
bot: экземпляр бота для отправки сообщения
topic_id: ID топика для отправки уведомления (если не указан, использует стандартный)
"""
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
return False
# Используем специальный топик для подозрительной активности, если он задан
notification_topic_id = topic_id or self.topic_id
try:
message_kwargs = {
'chat_id': self.chat_id,
'text': message,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
if notification_topic_id:
message_kwargs['message_thread_id'] = notification_topic_id
await bot.send_message(**message_kwargs)
logger.info(f"Уведомление о подозрительной активности отправлено в чат {self.chat_id}, топик {notification_topic_id}")
return True
except TelegramForbiddenError:
logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}")
return False
except TelegramBadRequest as e:
logger.error(f"Ошибка отправки уведомления о подозрительной активности: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке уведомления о подозрительной активности: {e}")
return False
+230
View File
@@ -0,0 +1,230 @@
"""
Сервис для работы с черным списком пользователей
Проверяет пользователей по списку из GitHub репозитория
"""
import asyncio
import logging
import re
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class BlacklistService:
"""
Сервис для проверки пользователей по черному списку
"""
def __init__(self):
self.blacklist_data = [] # Список в формате [(telegram_id, username, reason), ...]
self.last_update = None
# Используем интервал из настроек, по умолчанию 24 часа
interval_hours = self.get_blacklist_update_interval_hours()
self.update_interval = timedelta(hours=interval_hours)
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
def is_blacklist_check_enabled(self) -> bool:
"""Проверяет, включена ли проверка черного списка"""
return getattr(settings, 'BLACKLIST_CHECK_ENABLED', False)
def get_blacklist_github_url(self) -> Optional[str]:
"""Получает URL к файлу черного списка на GitHub"""
return getattr(settings, 'BLACKLIST_GITHUB_URL', None)
def get_blacklist_update_interval_hours(self) -> int:
"""Получает интервал обновления черного списка в часах"""
return getattr(settings, 'BLACKLIST_UPDATE_INTERVAL_HOURS', 24)
def should_ignore_admins(self) -> bool:
"""Проверяет, нужно ли игнорировать администраторов при проверке черного списка"""
return getattr(settings, 'BLACKLIST_IGNORE_ADMINS', True)
def is_admin(self, telegram_id: int) -> bool:
"""Проверяет, является ли пользователь администратором"""
return settings.is_admin(telegram_id)
async def update_blacklist(self) -> bool:
"""
Обновляет черный список из GitHub репозитория
"""
async with self.lock:
github_url = self.get_blacklist_github_url()
if not github_url:
logger.warning("URL к черному списку не задан в настройках")
return False
try:
# Заменяем github.com на raw.githubusercontent.com для получения raw содержимого
if "github.com" in github_url:
raw_url = github_url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
else:
raw_url = github_url
# Получаем содержимое файла
async with aiohttp.ClientSession() as session:
async with session.get(raw_url) as response:
if response.status != 200:
logger.error(f"Ошибка при получении черного списка: статус {response.status}")
return False
content = await response.text()
# Разбираем содержимое файла
blacklist_data = []
lines = content.splitlines()
for line_num, line in enumerate(lines, 1):
line = line.strip()
if not line or line.startswith('#'):
continue # Пропускаем пустые строки и комментарии
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
parts = line.split()
if not parts:
continue
try:
telegram_id = int(parts[0]) # Первое число - это Telegram ID
# Всё остальное - просто комментарий, не используем его для логики
# Но можем использовать первую часть после ID как username для отображения
username = ""
if len(parts) > 1:
# Берем вторую часть как username (если начинается с @)
if parts[1].startswith('@'):
username = parts[1]
# По умолчанию используем "Занесен в черный список", если нет другой информации
reason = "Занесен в черный список"
# Если есть запятая в строке, можем использовать часть после нее как причину
full_line_after_id = line[len(str(telegram_id)):].strip()
if ',' in full_line_after_id:
# Извлекаем причину после запятой
after_comma = full_line_after_id.split(',', 1)[1].strip()
reason = after_comma
blacklist_data.append((telegram_id, username, reason))
except ValueError:
# Если не удается преобразовать в число, это не ID
logger.warning(f"Неверный формат строки {line_num} в черном списке - первое значение не является числом: {line}")
self.blacklist_data = blacklist_data
self.last_update = datetime.utcnow()
logger.info(f"Черный список успешно обновлен. Найдено {len(blacklist_data)} записей")
return True
except ValueError as e:
logger.error(f"Ошибка при парсинге ID из черного списка: {e}")
return False
except Exception as e:
logger.error(f"Ошибка при обновлении черного списка: {e}")
return False
async def is_user_blacklisted(self, telegram_id: int, username: Optional[str] = None) -> Tuple[bool, Optional[str]]:
"""
Проверяет, находится ли пользователь в черном списке
Args:
telegram_id: Telegram ID пользователя
username: Username пользователя (опционально)
Returns:
Кортеж (в черном списке, причина)
"""
if not self.is_blacklist_check_enabled():
return False, None
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
if self.should_ignore_admins() and self.is_admin(telegram_id):
logger.info(f"Пользователь {telegram_id} является администратором, игнорируем проверку черного списка")
return False, None
# Если черный список пуст или устарел, обновляем его
interval_hours = self.get_blacklist_update_interval_hours()
required_interval = timedelta(hours=interval_hours)
if not self.blacklist_data or (self.last_update and
datetime.utcnow() - self.last_update > required_interval):
await self.update_blacklist()
# Проверяем по Telegram ID
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
logger.info(f"Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}")
return True, bl_reason
# Проверяем по username, если он передан
if username:
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username and (bl_username == username or bl_username == f"@{username}"):
logger.info(f"Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}")
return True, bl_reason
return False, None
async def get_all_blacklisted_users(self) -> List[Tuple[int, str, str]]:
"""
Возвращает весь черный список
"""
interval_hours = self.get_blacklist_update_interval_hours()
required_interval = timedelta(hours=interval_hours)
if not self.blacklist_data or (self.last_update and
datetime.utcnow() - self.last_update > required_interval):
await self.update_blacklist()
return self.blacklist_data.copy()
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[Tuple[int, str, str]]:
"""
Возвращает информацию о пользователе из черного списка по Telegram ID
Args:
telegram_id: Telegram ID пользователя
Returns:
Кортеж (telegram_id, username, reason) или None, если не найден
"""
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
return (bl_id, bl_username, bl_reason)
return None
async def get_user_by_username(self, username: str) -> Optional[Tuple[int, str, str]]:
"""
Возвращает информацию о пользователе из черного списка по username
Args:
username: Username пользователя
Returns:
Кортеж (telegram_id, username, reason) или None, если не найден
"""
# Проверяем как с @, так и без
username_with_at = f"@{username}" if not username.startswith('@') else username
username_without_at = username.lstrip('@')
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username == username_with_at or bl_username.lstrip('@') == username_without_at:
return (bl_id, bl_username, bl_reason)
return None
async def force_update_blacklist(self) -> Tuple[bool, str]:
"""
Принудительно обновляет черный список
Returns:
Кортеж (успешно, сообщение)
"""
success = await self.update_blacklist()
if success:
return True, f"Черный список обновлен успешно. Записей: {len(self.blacklist_data)}"
else:
return False, "Ошибка обновления черного списка"
# Глобальный экземпляр сервиса
blacklist_service = BlacklistService()
+238 -63
View File
@@ -8,6 +8,7 @@ from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
@@ -22,6 +23,8 @@ logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
@dataclass(slots=True)
@@ -137,52 +140,53 @@ class BroadcastService:
keyboard = self._build_keyboard(config.selected_buttons)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
logger.info(
"Запускаем стабильный режим рассылки для %s получателей", len(recipients)
)
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_resilient_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
else:
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_standard_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
if cancelled_during_run:
logger.info(
"Рассылка %s была отменена во время выполнения, финальный статус уже установлен",
broadcast_id,
)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
if cancel_event.is_set():
logger.info(
"Запрос на отмену рассылки %s пришел после завершения отправки, фиксируем итоговый статус",
broadcast_id,
)
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=False,
)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
@@ -198,6 +202,127 @@ class BroadcastService:
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
async def _run_standard_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Базовый режим рассылки для небольших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent_count, failed_count, False
async def _run_resilient_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(15)
async def send_single_message(user):
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
return False
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc: # noqa: BLE001
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
return False
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i:i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
else:
failed_count += 1
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0:
await self._update_progress(broadcast_id, sent_count, failed_count)
await asyncio.sleep(0.1)
return sent_count, failed_count, False
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
@@ -251,18 +376,14 @@ class BroadcastService:
*,
cancelled: bool,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = "cancelled" if cancelled else (
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
)
broadcast.completed_at = datetime.utcnow()
await session.commit()
),
)
async def _mark_cancelled(
self,
@@ -283,16 +404,70 @@ class BroadcastService:
sent_count: int = 0,
failed_count: int = 0,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="failed",
)
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count or broadcast.failed_count
broadcast.status = "failed"
broadcast.completed_at = datetime.utcnow()
await session.commit()
async def _update_progress(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Периодически обновляет прогресс рассылки, чтобы держать соединение активным."""
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status="in_progress",
update_completed_at=False,
)
async def _safe_status_update(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
status: str,
update_completed_at: bool = True,
) -> None:
attempts = 0
while attempts < 2:
try:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = status
if update_completed_at:
broadcast.completed_at = datetime.utcnow()
await session.commit()
return
except InterfaceError as exc:
attempts += 1
logger.warning(
"Проблемы с соединением при обновлении статуса рассылки %s: %s. Повтор %s/2",
broadcast_id,
exc,
attempts,
)
await asyncio.sleep(0.2)
except SQLAlchemyError:
logger.exception(
"Не удалось обновить статус рассылки %s", broadcast_id
)
return
broadcast_service = BroadcastService()
+177
View File
@@ -0,0 +1,177 @@
"""
Модуль для массовой блокировки пользователей по списку Telegram ID
"""
import logging
from typing import List, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from app.database.crud.user import get_user_by_telegram_id
from app.services.user_service import UserService
from app.services.admin_notification_service import AdminNotificationService
from app.config import settings
from app.database.models import UserStatus
logger = logging.getLogger(__name__)
class BulkBanService:
"""
Сервис для массовой блокировки пользователей по списку Telegram ID
"""
def __init__(self):
self.user_service = UserService()
async def ban_users_by_telegram_ids(
self,
db: AsyncSession,
admin_user_id: int,
telegram_ids: List[int],
reason: str = "Заблокирован администратором по списку",
bot: Bot = None,
notify_admin: bool = True,
admin_name: str = "Администратор"
) -> Tuple[int, int, List[int]]:
"""
Массовая блокировка пользователей по Telegram ID
Args:
db: Асинхронная сессия базы данных
admin_user_id: ID администратора, который осуществляет блокировку
telegram_ids: Список Telegram ID для блокировки
reason: Причина блокировки
bot: Бот для отправки уведомлений
notify_admin: Отправлять ли уведомления администратору
admin_name: Имя администратора для логирования
Returns:
Кортеж из (успешно заблокированных, не найденных, список ID с ошибками)
"""
successfully_banned = 0
not_found_users = []
error_ids = []
for telegram_id in telegram_ids:
try:
# Получаем пользователя по Telegram ID
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.warning(f"Пользователь с Telegram ID {telegram_id} не найден")
not_found_users.append(telegram_id)
continue
# Проверяем, что пользователь не заблокирован уже
if user.status == UserStatus.BLOCKED.value:
logger.info(f"Пользователь {telegram_id} уже заблокирован")
continue
# Блокируем пользователя
ban_success = await self.user_service.block_user(
db, user.id, admin_user_id, reason
)
if ban_success:
successfully_banned += 1
logger.info(f"Пользователь {telegram_id} успешно заблокирован")
# Отправляем уведомление пользователю, если возможно
if bot:
try:
await bot.send_message(
chat_id=telegram_id,
text=(
f"🚫 <b>Ваш аккаунт заблокирован</b>\n\n"
f"Причина: {reason}\n\n"
f"Если вы считаете, что блокировка произошла ошибочно, "
f"обратитесь в поддержку."
),
parse_mode="HTML"
)
except Exception as e:
logger.warning(f"Не удалось отправить уведомление пользователю {telegram_id}: {e}")
else:
logger.error(f"Не удалось заблокировать пользователя {telegram_id}")
error_ids.append(telegram_id)
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя {telegram_id}: {e}")
error_ids.append(telegram_id)
# Отправляем уведомление администратору
if notify_admin and bot:
try:
admin_notification_service = AdminNotificationService(bot)
await admin_notification_service.send_bulk_ban_notification(
admin_user_id,
successfully_banned,
len(not_found_users),
len(error_ids),
admin_name
)
except Exception as e:
logger.error(f"Ошибка при отправке уведомления администратору: {e}")
logger.info(
f"Массовая блокировка завершена: успешно={successfully_banned}, "
f"не найдено={len(not_found_users)}, ошибки={len(error_ids)}"
)
return successfully_banned, len(not_found_users), error_ids
async def parse_telegram_ids_from_text(self, text: str) -> List[int]:
"""
Парсит Telegram ID из текста. Поддерживает различные форматы:
- по одному ID на строку
- через запятую
- через пробелы
- с @username (если username соответствует формату ID)
"""
if not text:
return []
# Удаляем лишние пробелы и разбиваем по переносам строк
lines = text.strip().split('\n')
ids = []
for line in lines:
# Убираем комментарии и лишние пробелы
line = line.strip()
if not line or line.startswith('#'):
continue
# Разбиваем строку по запятым или пробелам
tokens = line.replace(',', ' ').split()
for token in tokens:
token = token.strip()
# Убираем символ @ если присутствует
if token.startswith('@'):
token = token[1:]
# Проверяем, является ли токен числом (Telegram ID)
try:
telegram_id = int(token)
if telegram_id > 0: # Telegram ID должны быть положительными
ids.append(telegram_id)
except ValueError:
# Пропускаем, если не является числом
continue
# Убираем дубликаты, сохранив порядок
unique_ids = []
seen = set()
for tid in ids:
if tid not in seen:
unique_ids.append(tid)
seen.add(tid)
return unique_ids
# Создаем глобальный экземпляр сервиса
bulk_ban_service = BulkBanService()
+7 -2
View File
@@ -912,12 +912,17 @@ class MonitoringService:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.autopay_enabled == True,
Subscription.is_trial == False
Subscription.is_trial == False
)
)
)
+23 -4
View File
@@ -38,9 +38,28 @@ class PaymentCommonMixin:
texts = get_texts(user.language if user else "ru")
# Определяем статус подписки, чтобы показать подходящую кнопку.
has_active_subscription = bool(
user and user.subscription and not user.subscription.is_trial and user.subscription.is_active
)
has_active_subscription = False
subscription = None
if user:
try:
subscription = user.subscription
has_active_subscription = bool(
subscription
and not getattr(subscription, "is_trial", False)
and getattr(subscription, "is_active", False)
)
except MissingGreenlet as error:
logger.warning(
"Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
except Exception as error: # pragma: no cover - защитный код
logger.error(
"Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s",
getattr(user, "id", None),
error,
)
# Создаем основную кнопку: если есть активная подписка - продлить, иначе купить
first_button = build_miniapp_or_callback_button(
@@ -86,7 +105,7 @@ class PaymentCommonMixin:
])
else:
draft_exists = await has_subscription_checkout_draft(user.id)
if should_offer_checkout_resume(user, draft_exists):
if should_offer_checkout_resume(user, draft_exists, subscription=subscription):
keyboard_rows.append([
build_miniapp_or_callback_button(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
+199 -2
View File
@@ -16,12 +16,24 @@ from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_purchase_saved_cart_after_topup,
)
from app.services.subscription_renewal_service import (
SubscriptionRenewalChargeError,
SubscriptionRenewalPricing,
SubscriptionRenewalService,
RenewalPaymentDescriptor,
build_renewal_period_id,
decode_payment_payload,
parse_payment_metadata,
)
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
renewal_service = SubscriptionRenewalService()
@dataclass(slots=True)
class _AdminNotificationContext:
user_id: int
@@ -173,6 +185,36 @@ class CryptoBotPaymentMixin:
db, invoice_id, status, paid_at
)
descriptor = decode_payment_payload(
getattr(updated_payment, "payload", "") or "",
expected_user_id=updated_payment.user_id,
)
if descriptor is None:
inline_payload = payload.get("payload")
if isinstance(inline_payload, str) and inline_payload:
descriptor = decode_payment_payload(
inline_payload,
expected_user_id=updated_payment.user_id,
)
if descriptor is None:
metadata = payload.get("metadata")
if isinstance(metadata, dict) and metadata:
descriptor = parse_payment_metadata(
metadata,
expected_user_id=updated_payment.user_id,
)
if descriptor:
renewal_handled = await self._process_subscription_renewal_payment(
db,
updated_payment,
descriptor,
cryptobot_crud,
)
if renewal_handled:
return True
if not updated_payment.transaction_id:
amount_usd = updated_payment.amount_float
@@ -339,7 +381,7 @@ class CryptoBotPaymentMixin:
texts = get_texts(user.language)
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
total_amount=settings.format_price(payment.amount_kopeks)
total_amount=settings.format_price(amount_kopeks)
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
@@ -360,7 +402,7 @@ class CryptoBotPaymentMixin:
saved_cart_notification = _SavedCartNotificationPayload(
telegram_id=user.telegram_id,
text=(
f"✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n"
f"✅ Баланс пополнен на {settings.format_price(amount_kopeks)}!\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
@@ -394,6 +436,161 @@ class CryptoBotPaymentMixin:
)
return False
async def _process_subscription_renewal_payment(
self,
db: AsyncSession,
payment: Any,
descriptor: RenewalPaymentDescriptor,
cryptobot_crud: Any,
) -> bool:
try:
payment_service_module = import_module("app.services.payment_service")
user = await payment_service_module.get_user_by_id(db, payment.user_id)
except Exception as error:
logger.error(
"Не удалось загрузить пользователя %s для продления через CryptoBot: %s",
getattr(payment, "user_id", None),
error,
)
return False
if not user:
logger.error(
"Пользователь %s не найден при обработке продления через CryptoBot",
getattr(payment, "user_id", None),
)
return False
subscription = getattr(user, "subscription", None)
if not subscription or subscription.id != descriptor.subscription_id:
logger.warning(
"Продление через CryptoBot отклонено: подписка %s не совпадает с ожидаемой %s",
getattr(subscription, "id", None),
descriptor.subscription_id,
)
return False
pricing_model: Optional[SubscriptionRenewalPricing] = None
if descriptor.pricing_snapshot:
try:
pricing_model = SubscriptionRenewalPricing.from_payload(
descriptor.pricing_snapshot
)
except Exception as error:
logger.warning(
"Не удалось восстановить сохраненную стоимость продления из payload %s: %s",
payment.invoice_id,
error,
)
if pricing_model is None:
try:
pricing_model = await renewal_service.calculate_pricing(
db,
user,
subscription,
descriptor.period_days,
)
except Exception as error:
logger.error(
"Не удалось пересчитать стоимость продления для CryptoBot %s: %s",
payment.invoice_id,
error,
)
return False
if pricing_model.final_total != descriptor.total_amount_kopeks:
logger.warning(
"Сумма продления через CryptoBot %s изменилась (ожидалось %s, получено %s)",
payment.invoice_id,
descriptor.total_amount_kopeks,
pricing_model.final_total,
)
pricing_model.final_total = descriptor.total_amount_kopeks
pricing_model.per_month = (
descriptor.total_amount_kopeks // pricing_model.months
if pricing_model.months
else descriptor.total_amount_kopeks
)
pricing_model.period_days = descriptor.period_days
pricing_model.period_id = build_renewal_period_id(descriptor.period_days)
required_balance = max(
0,
min(
pricing_model.final_total,
descriptor.balance_component_kopeks,
),
)
current_balance = getattr(user, "balance_kopeks", 0)
if current_balance < required_balance:
logger.warning(
"Недостаточно средств на балансе пользователя %s для завершения продления: нужно %s, доступно %s",
user.id,
required_balance,
current_balance,
)
return False
description = f"Продление подписки на {descriptor.period_days} дней"
try:
result = await renewal_service.finalize(
db,
user,
subscription,
pricing_model,
charge_balance_amount=required_balance,
description=description,
payment_method=PaymentMethod.CRYPTOBOT,
)
except SubscriptionRenewalChargeError as error:
logger.error(
"Списание баланса не выполнено при продлении через CryptoBot %s: %s",
payment.invoice_id,
error,
)
return False
except Exception as error:
logger.error(
"Ошибка завершения продления через CryptoBot %s: %s",
payment.invoice_id,
error,
exc_info=True,
)
return False
transaction = result.transaction
if transaction:
try:
await cryptobot_crud.link_cryptobot_payment_to_transaction(
db,
payment.invoice_id,
transaction.id,
)
except Exception as error:
logger.warning(
"Не удалось связать платеж CryptoBot %s с транзакцией %s: %s",
payment.invoice_id,
transaction.id,
error,
)
external_amount_label = settings.format_price(descriptor.missing_amount_kopeks)
balance_amount_label = settings.format_price(required_balance)
logger.info(
"Подписка %s продлена через CryptoBot invoice %s (внешний платеж %s, списано с баланса %s)",
subscription.id,
payment.invoice_id,
external_amount_label,
balance_amount_label,
)
return True
async def _deliver_admin_topup_notification(
self, context: _AdminNotificationContext
) -> None:
+36
View File
@@ -255,6 +255,42 @@ class HeleketPaymentMixin:
if updated_payment is None:
return None
metadata = dict(getattr(updated_payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт Heleket %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if invoice_message_removed:
try:
from app.database.crud import heleket as heleket_crud
await heleket_crud.update_heleket_payment(
db,
updated_payment.uuid,
metadata=metadata,
)
updated_payment.metadata_json = metadata
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные Heleket после удаления счёта: %s",
error,
)
if updated_payment.transaction_id:
logger.info(
"Heleket платеж %s уже связан с транзакцией %s",
+37
View File
@@ -182,7 +182,43 @@ class MulenPayPaymentMixin:
)
return False
metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None):
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить %s счёт %s: %s",
display_name,
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if payment.is_paid:
if invoice_message_removed:
try:
await payment_module.update_mulenpay_payment_metadata(
db,
payment=payment,
metadata=metadata,
)
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные %s после удаления счёта: %s",
display_name,
error,
)
logger.info(
"%s платеж %s уже обработан, игнорируем повторный callback",
display_name,
@@ -197,6 +233,7 @@ class MulenPayPaymentMixin:
status="success",
callback_payload=callback_data,
mulen_payment_id=mulen_payment_id_int,
metadata=metadata,
)
if payment.transaction_id:
+35
View File
@@ -333,6 +333,41 @@ class Pal24PaymentMixin:
payment_module = import_module("app.services.payment_service")
metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт PayPalych %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
invoice_message_removed = True
if invoice_message_removed:
try:
await payment_module.update_pal24_payment_status(
db,
payment,
status=payment.status,
metadata=metadata,
)
payment.metadata_json = metadata
except Exception as error: # pragma: no cover - diagnostics
logger.warning(
"Не удалось обновить метаданные PayPalych после удаления счёта: %s",
error,
)
if payment.transaction_id:
logger.info(
"Pal24 платеж %s уже привязан к транзакции (trigger=%s)",
+27 -4
View File
@@ -128,6 +128,7 @@ class PlategaPaymentMixin:
"status": status,
"expires_at": expires_at,
"correlation_id": correlation_id,
"payload": payload_token,
}
async def process_platega_webhook(
@@ -306,6 +307,22 @@ class PlategaPaymentMixin:
metadata = dict(getattr(payment, "metadata_json", {}) or {})
balance_already_credited = bool(metadata.get("balance_credited"))
invoice_message = metadata.get("invoice_message") or {}
if getattr(self, "bot", None):
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить Platega счёт %s: %s",
message_id,
delete_error,
)
else:
metadata.pop("invoice_message", None)
if payment.transaction_id:
logger.info(
"Platega платеж %s уже связан с транзакцией %s",
@@ -319,6 +336,16 @@ class PlategaPaymentMixin:
logger.error("Пользователь %s не найден для Platega", payment.user_id)
return payment
# Убеждаемся, что промогруппы загружены в асинхронном контексте,
# чтобы избежать попыток ленивой загрузки без greenlet
await db.refresh(user, attribute_names=["promo_group", "user_promo_groups"])
for user_promo_group in getattr(user, "user_promo_groups", []):
await db.refresh(user_promo_group, attribute_names=["promo_group"])
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
transaction_external_id = (
str(payload.get("id"))
if isinstance(payload, dict) and payload.get("id")
@@ -376,10 +403,6 @@ class PlategaPaymentMixin:
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "🆕 Первое пополнение" if was_first_topup else "🔄 Пополнение"
try:
+1 -38
View File
@@ -56,14 +56,7 @@ class TelegramStarsMixin:
# Если количество звёзд не задано, вычисляем его из курса.
if stars_amount is None:
rate = Decimal(str(settings.get_stars_rate()))
if rate <= 0:
raise ValueError("Stars rate must be positive")
normalized_stars = (amount_rubles / rate).to_integral_value(
rounding=ROUND_FLOOR
)
stars_amount = int(normalized_stars) or 1
stars_amount = settings.rubles_to_stars(float(amount_rubles))
if stars_amount <= 0:
raise ValueError("Stars amount must be positive")
@@ -515,36 +508,6 @@ class TelegramStarsMixin:
exc_info=True,
)
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
charge_id_short = (telegram_payment_charge_id or getattr(transaction, "external_id", ""))[:8]
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {charge_id_short}...\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
logger.info(
"✅ Отправлено уведомление пользователю %s о пополнении на %s",
user.telegram_id,
settings.format_price(amount_kopeks),
)
except Exception as error: # pragma: no cover - диагностический лог
logger.error(
"Ошибка отправки уведомления о пополнении Stars: %s",
error,
)
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
try:
from aiogram import types
+19
View File
@@ -415,6 +415,25 @@ class WataPaymentMixin:
if not paid_at and getattr(payment, "paid_at", None):
paid_at = payment.paid_at
existing_metadata = dict(getattr(payment, "metadata_json", {}) or {})
invoice_message = existing_metadata.get("invoice_message") or {}
invoice_message_removed = False
if getattr(self, "bot", None) and invoice_message:
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on rights
logger.warning(
"Не удалось удалить счёт WATA %s: %s",
message_id,
delete_error,
)
else:
invoice_message_removed = True
existing_metadata.pop("invoice_message", None)
existing_metadata["transaction"] = transaction_payload
await payment_module.update_wata_payment_status(
+41 -8
View File
@@ -383,11 +383,19 @@ class YooKassaPaymentMixin:
payment_module = import_module("app.services.payment_service")
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
get_transaction_by_external_id = getattr(
payment_module, "get_transaction_by_external_id", None
)
existing_transaction = None
if get_transaction_by_external_id:
try:
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
except AttributeError:
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
if existing_transaction:
# Если транзакция уже существует, просто завершаем обработку
@@ -437,6 +445,22 @@ class YooKassaPaymentMixin:
except Exception as parse_error:
logger.error(f"Ошибка парсинга метаданных платежа: {parse_error}")
invoice_message = payment_metadata.get("invoice_message") or {}
if getattr(self, "bot", None):
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if chat_id and message_id:
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as delete_error: # pragma: no cover - depends on bot rights
logger.warning(
"Не удалось удалить сообщение YooKassa %s: %s",
message_id,
delete_error,
)
else:
payment_metadata.pop("invoice_message", None)
processing_completed = bool(payment_metadata.get("processing_completed"))
transaction = None
@@ -472,11 +496,20 @@ class YooKassaPaymentMixin:
)
if transaction is None:
existing_transaction = await payment_module.get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
get_transaction_by_external_id = getattr(
payment_module, "get_transaction_by_external_id", None
)
existing_transaction = None
if get_transaction_by_external_id:
try:
existing_transaction = await get_transaction_by_external_id( # type: ignore[attr-defined]
db,
payment.yookassa_payment_id,
PaymentMethod.YOOKASSA,
)
except AttributeError:
logger.debug("get_transaction_by_external_id недоступен, пропускаем проверку дубликатов")
if existing_transaction:
# Если транзакция уже существует, пропускаем обработку
+5
View File
@@ -112,6 +112,11 @@ async def update_mulenpay_payment_status(*args, **kwargs):
return await mulenpay_crud.update_mulenpay_payment_status(*args, **kwargs)
async def update_mulenpay_payment_metadata(*args, **kwargs):
mulenpay_crud = import_module("app.database.crud.mulenpay")
return await mulenpay_crud.update_mulenpay_payment_metadata(*args, **kwargs)
async def link_mulenpay_payment_to_transaction(*args, **kwargs):
mulenpay_crud = import_module("app.database.crud.mulenpay")
return await mulenpay_crud.link_mulenpay_payment_to_transaction(*args, **kwargs)
+31 -1
View File
@@ -26,6 +26,7 @@ class PlategaService:
self._max_retries = 3
self._retry_delay = 0.5
self._retryable_statuses = {500, 502, 503, 504}
self._description_max_length = 64
@property
def is_configured(self) -> bool:
@@ -51,7 +52,10 @@ class PlategaService:
}
if description:
body["description"] = description
sanitized_description = self._sanitize_description(
description, self._description_max_length
)
body["description"] = sanitized_description
if return_url:
body["return"] = return_url
if failed_url:
@@ -176,6 +180,32 @@ class PlategaService:
return None, raw_text
@staticmethod
def _sanitize_description(description: str, max_bytes: int) -> str:
"""Обрезает описание с учётом байтового лимита Platega."""
cleaned = (description or "").strip()
if not max_bytes:
return cleaned
encoded = cleaned.encode("utf-8")
if len(encoded) <= max_bytes:
return cleaned
logger.debug(
"Platega description trimmed from %s to %s bytes",
len(encoded),
max_bytes,
)
trimmed_bytes = encoded[:max_bytes]
while True:
try:
return trimmed_bytes.decode("utf-8")
except UnicodeDecodeError:
trimmed_bytes = trimmed_bytes[:-1]
@staticmethod
def parse_expires_at(expires_in: Optional[str]) -> Optional[datetime]:
if not expires_in:
+5
View File
@@ -52,7 +52,10 @@ class PromoCodeService:
if existing_use:
return {"success": False, "error": "already_used_by_user"}
balance_before_kopeks = user.balance_kopeks
result_description = await self._apply_promocode_effects(db, user, promocode)
balance_after_kopeks = user.balance_kopeks
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
@@ -123,6 +126,8 @@ class PromoCodeService:
"success": True,
"description": result_description,
"promocode": promocode_data,
"balance_before_kopeks": balance_before_kopeks,
"balance_after_kopeks": balance_after_kopeks,
}
except Exception as e:
+15 -16
View File
@@ -7,6 +7,7 @@ from app.config import settings
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.referral import create_referral_earning
from app.database.models import TransactionType, ReferralEarning
from app.utils.user_utils import get_effective_referral_commission_percent
logger = logging.getLogger(__name__)
@@ -48,8 +49,9 @@ async def process_referral_registration(
amount_kopeks=0,
reason="referral_registration_pending"
)
if bot:
commission_percent = get_effective_referral_commission_percent(referrer)
referral_notification = (
f"🎉 <b>Добро пожаловать!</b>\n\n"
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
@@ -64,8 +66,8 @@ async def process_referral_registration(
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
f"{commission_percent}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {commission_percent}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
@@ -94,13 +96,14 @@ async def process_referral_topup(
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
commission_percent = get_effective_referral_commission_percent(referrer)
qualifies_for_first_bonus = (
topup_amount_kopeks >= settings.REFERRAL_MINIMUM_TOPUP_KOPEKS
)
commission_amount = 0
if settings.REFERRAL_COMMISSION_PERCENT > 0:
if commission_percent > 0:
commission_amount = int(
topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100
topup_amount_kopeks * commission_percent / 100
)
if not user.has_made_first_topup:
@@ -116,7 +119,7 @@ async def process_referral_topup(
db,
referrer,
commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
bot=bot,
)
@@ -139,7 +142,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
@@ -180,7 +183,7 @@ async def process_referral_topup(
)
await send_referral_notification(bot, user.telegram_id, bonus_notification)
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
commission_amount = int(topup_amount_kopeks * commission_percent / 100)
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
if inviter_bonus > 0:
@@ -204,7 +207,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная награда!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
f"📈 Теперь с каждого его пополнения вы будете получать {commission_percent}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
@@ -212,7 +215,7 @@ async def process_referral_topup(
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
f"Комиссия {commission_percent}% с пополнения {user.full_name}",
bot=bot
)
@@ -231,7 +234,7 @@ async def process_referral_topup(
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
@@ -261,11 +264,7 @@ async def process_referral_purchase(
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
commission_percent = 10
else:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
commission_percent = get_effective_referral_commission_percent(referrer)
commission_amount = int(purchase_amount_kopeks * commission_percent / 100)
+163 -58
View File
@@ -3,6 +3,7 @@ import logging
import re
from contextlib import AsyncExitStack, asynccontextmanager
from datetime import datetime, timedelta
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
@@ -46,6 +47,26 @@ from app.utils.timezone import get_local_timezone
logger = logging.getLogger(__name__)
def _get_user_traffic_bytes(panel_user: Dict[str, Any]) -> int:
"""Извлекает usedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
# Новый формат: userTraffic.usedTrafficBytes
user_traffic = panel_user.get('userTraffic')
if user_traffic and isinstance(user_traffic, dict):
return user_traffic.get('usedTrafficBytes', 0)
# Старый формат: usedTrafficBytes напрямую
return panel_user.get('usedTrafficBytes', 0)
def _get_lifetime_traffic_bytes(panel_user: Dict[str, Any]) -> int:
"""Извлекает lifetimeUsedTrafficBytes из панельного пользователя (совместимо с новым и старым API)"""
# Новый формат: userTraffic.lifetimeUsedTrafficBytes
user_traffic = panel_user.get('userTraffic')
if user_traffic and isinstance(user_traffic, dict):
return user_traffic.get('lifetimeUsedTrafficBytes', 0)
# Старый формат: lifetimeUsedTrafficBytes напрямую
return panel_user.get('lifetimeUsedTrafficBytes', 0)
_UUID_MAP_MISSING = object()
@@ -256,6 +277,29 @@ class RemnaWaveService:
)
return self._now_utc() + timedelta(days=30)
def _safe_expire_at_for_panel(self, expire_at: Optional[datetime]) -> datetime:
"""Гарантирует, что дата окончания не в прошлом для панели."""
now = self._now_utc()
minimum_expire = now + timedelta(minutes=1)
if not expire_at:
return minimum_expire
normalized_expire = expire_at
if normalized_expire.tzinfo is not None:
normalized_expire = normalized_expire.replace(tzinfo=None)
if normalized_expire < minimum_expire:
logger.debug(
"⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели",
normalized_expire,
minimum_expire,
)
return minimum_expire
return normalized_expire
def _safe_panel_expire_date(self, panel_user: Dict[str, Any]) -> datetime:
"""Парсит дату окончания подписки пользователя панели для сравнения."""
@@ -539,6 +583,13 @@ class RemnaWaveService:
nodes_weekly_data = list(nodes_by_name.values())
nodes_weekly_data.sort(key=lambda x: x['total_bytes'], reverse=True)
uptime_seconds = 0
uptime_value = system_stats.get('uptime')
try:
uptime_seconds = int(float(uptime_value)) if uptime_value is not None else 0
except (TypeError, ValueError):
logger.warning(f"Не удалось преобразовать uptime '{uptime_value}' в число, используем 0")
result = {
"system": {
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
@@ -558,7 +609,7 @@ class RemnaWaveService:
"memory_used": system_stats.get('memory', {}).get('used', 0),
"memory_free": system_stats.get('memory', {}).get('free', 0),
"memory_available": system_stats.get('memory', {}).get('available', 0),
"uptime_seconds": system_stats.get('uptime', 0)
"uptime_seconds": uptime_seconds
},
"bandwidth": {
"realtime_download": total_download,
@@ -730,7 +781,20 @@ class RemnaWaveService:
"is_xray_running": node.is_xray_running,
"users_online": node.users_online or 0,
"traffic_used_bytes": node.traffic_used_bytes or 0,
"traffic_limit_bytes": node.traffic_limit_bytes or 0
"traffic_limit_bytes": node.traffic_limit_bytes or 0,
"last_status_change": node.last_status_change,
"last_status_message": node.last_status_message,
"xray_uptime": node.xray_uptime,
"is_traffic_tracking_active": node.is_traffic_tracking_active,
"traffic_reset_day": node.traffic_reset_day,
"notify_percent": node.notify_percent,
"consumption_multiplier": node.consumption_multiplier,
"cpu_count": node.cpu_count,
"cpu_model": node.cpu_model,
"total_ram": node.total_ram,
"created_at": node.created_at,
"updated_at": node.updated_at,
"provider_uuid": node.provider_uuid,
}
except Exception as e:
@@ -788,15 +852,19 @@ class RemnaWaveService:
try:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
result = []
for squad in squads:
inbounds = [
asdict(inbound) if is_dataclass(inbound) else inbound
for inbound in squad.inbounds or []
]
result.append({
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.inbounds
'inbounds': inbounds,
})
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
@@ -1079,7 +1147,7 @@ class RemnaWaveService:
while True:
logger.info(f"📥 Загружаем пользователей: start={start}, size={size}")
response = await api.get_all_users(start=start, size=size)
response = await api.get_all_users(start=start, size=size, enrich_happ_links=True)
users_batch = response['users']
total_users = response['total']
@@ -1433,10 +1501,10 @@ class RemnaWaveService:
traffic_limit_bytes = panel_user.get('trafficLimitBytes', 0)
traffic_limit_gb = traffic_limit_bytes // (1024**3) if traffic_limit_bytes > 0 else 0
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
traffic_used_gb = used_traffic_bytes / (1024**3)
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
if isinstance(active_squads, list):
@@ -1538,10 +1606,10 @@ class RemnaWaveService:
if subscription.status != new_status:
subscription.status = new_status
logger.debug(f"Обновлен статус подписки: {new_status}")
used_traffic_bytes = panel_user.get('usedTrafficBytes', 0)
used_traffic_bytes = _get_user_traffic_bytes(panel_user)
traffic_used_gb = used_traffic_bytes / (1024**3)
if abs(subscription.traffic_used_gb - traffic_used_gb) > 0.01:
subscription.traffic_used_gb = traffic_used_gb
logger.debug(f"Обновлен использованный трафик: {traffic_used_gb} GB")
@@ -1608,39 +1676,28 @@ class RemnaWaveService:
async def sync_users_to_panel(self, db: AsyncSession) -> Dict[str, int]:
try:
stats = {"created": 0, "updated": 0, "errors": 0}
users = await get_users_list(db, offset=0, limit=10000)
batch_size = 100
offset = 0
async with self.get_api_client() as api:
for user in users:
if not user.subscription:
continue
while True:
users = await get_users_list(db, offset=offset, limit=batch_size)
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
if not users:
break
if user.remnawave_uuid:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=subscription.end_date,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
telegram_id=user.telegram_id
),
active_internal_squads=subscription.connected_squads,
)
for user in users:
if not user.subscription:
continue
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
try:
subscription = user.subscription
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
expire_at = self._safe_expire_at_for_panel(subscription.end_date)
status = UserStatus.ACTIVE if subscription.is_active else UserStatus.DISABLED
await api.update_user(**update_kwargs)
stats["updated"] += 1
else:
username = settings.format_remnawave_username(
full_name=user.full_name,
username=user.username,
@@ -1649,8 +1706,8 @@ class RemnaWaveService:
create_kwargs = dict(
username=username,
expire_at=subscription.end_date,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
expire_at=expire_at,
status=status,
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=user.telegram_id,
@@ -1665,20 +1722,66 @@ class RemnaWaveService:
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
new_user = await api.create_user(**create_kwargs)
await update_user(db, user, remnawave_uuid=new_user.uuid)
subscription.remnawave_short_uuid = new_user.short_uuid
# Убираем немедленный коммит для пакетной обработки
# await db.commit()
stats["created"] += 1
except Exception as e:
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
stats["errors"] += 1
logger.info(f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}")
if user.remnawave_uuid:
update_kwargs = dict(
uuid=user.remnawave_uuid,
status=status,
expire_at=expire_at,
traffic_limit_bytes=create_kwargs['traffic_limit_bytes'],
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
description=create_kwargs['description'],
active_internal_squads=subscription.connected_squads,
)
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
try:
await api.update_user(**update_kwargs)
stats["updated"] += 1
except RemnaWaveAPIError as api_error:
if api_error.status_code == 404:
logger.warning(
"⚠️ Не найден пользователь %s в панели, создаем заново",
user.remnawave_uuid,
)
new_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_user.uuid
subscription.remnawave_short_uuid = new_user.short_uuid
stats["created"] += 1
else:
raise
else:
new_user = await api.create_user(**create_kwargs)
user.remnawave_uuid = new_user.uuid
subscription.remnawave_short_uuid = new_user.short_uuid
stats["created"] += 1
except Exception as e:
logger.error(f"Ошибка синхронизации пользователя {user.telegram_id} в панель: {e}")
stats["errors"] += 1
try:
await db.commit()
except Exception as commit_error:
logger.error(
"Ошибка фиксации транзакции при синхронизации в панель: %s",
commit_error,
)
await db.rollback()
stats["errors"] += len(users)
if len(users) < batch_size:
break
offset += batch_size
logger.info(
f"✅ Синхронизация в панель завершена: создано {stats['created']}, обновлено {stats['updated']}, ошибок {stats['errors']}"
)
return stats
except Exception as e:
@@ -1762,12 +1865,16 @@ class RemnaWaveService:
async with self.get_api_client() as api:
squad = await api.get_internal_squad_by_uuid(squad_uuid)
if squad:
inbounds = [
asdict(inbound) if is_dataclass(inbound) else inbound
for inbound in squad.inbounds or []
]
return {
'uuid': squad.uuid,
'name': squad.name,
'members_count': squad.members_count,
'inbounds_count': squad.inbounds_count,
'inbounds': squad.inbounds
'inbounds': inbounds
}
return None
except Exception as e:
@@ -2500,5 +2607,3 @@ class RemnaWaveService:
"api_url": settings.REMNAWAVE_API_URL,
"attempts_used": attempts,
}
@@ -124,10 +124,13 @@ def _safe_int(value: Optional[object], default: int = 0) -> int:
async def _prepare_auto_extend_context(
db: AsyncSession,
user: User,
cart_data: dict,
) -> Optional[AutoExtendContext]:
subscription = getattr(user, "subscription", None)
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
if subscription is None:
logger.info(
"🔁 Автопокупка: у пользователя %s нет активной подписки для продления",
@@ -233,7 +236,7 @@ async def _auto_extend_subscription(
bot: Optional[Bot] = None,
) -> bool:
try:
prepared = await _prepare_auto_extend_context(user, cart_data)
prepared = await _prepare_auto_extend_context(db, user, cart_data)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"❌ Автопокупка: ошибка подготовки данных продления для пользователя %s: %s",
+23 -3
View File
@@ -1,9 +1,15 @@
import logging
from typing import Optional
from app.database.models import User
from sqlalchemy.exc import MissingGreenlet
from app.database.models import Subscription, User
from app.utils.cache import UserCache
logger = logging.getLogger(__name__)
_CHECKOUT_SESSION_KEY = "subscription_checkout"
_CHECKOUT_TTL_SECONDS = 3600
@@ -33,7 +39,12 @@ async def has_subscription_checkout_draft(user_id: int) -> bool:
return draft is not None
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
def should_offer_checkout_resume(
user: User,
has_draft: bool,
*,
subscription: Subscription | None = None,
) -> bool:
"""
Determine whether checkout resume button should be available for the user.
@@ -44,7 +55,16 @@ def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
if not has_draft:
return False
subscription = getattr(user, "subscription", None)
if subscription is None:
try:
subscription = getattr(user, "subscription", None)
except MissingGreenlet as error:
logger.warning(
"Не удалось лениво загрузить подписку пользователя %s при проверке возврата к checkout: %s",
getattr(user, "id", None),
error,
)
subscription = None
if subscription is None:
return True
+11 -4
View File
@@ -329,7 +329,9 @@ class MiniAppSubscriptionPurchaseService:
"""Builds configuration and pricing for subscription purchases in the mini app."""
async def build_options(self, db: AsyncSession, user: User) -> PurchaseOptionsContext:
subscription = getattr(user, "subscription", None)
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
texts = get_texts(getattr(user, "language", None))
@@ -1108,8 +1110,14 @@ class MiniAppSubscriptionPurchaseService:
subscription.traffic_limit_gb = pricing.selection.traffic_value
subscription.device_limit = pricing.selection.devices
subscription.connected_squads = pricing.selection.servers
subscription.start_date = now
subscription.end_date = now + timedelta(days=pricing.selection.period.days) + bonus_period
extension_base_date = now
if subscription.end_date and subscription.end_date > now:
extension_base_date = subscription.end_date
else:
subscription.start_date = now
subscription.end_date = extension_base_date + timedelta(days=pricing.selection.period.days) + bonus_period
subscription.updated_at = now
subscription.traffic_used_gb = 0.0
@@ -1227,4 +1235,3 @@ class SubscriptionPurchaseService:
purchase_service = MiniAppSubscriptionPurchaseService()
@@ -0,0 +1,564 @@
from __future__ import annotations
import base64
import json
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Awaitable, Callable, Dict, List, Optional
from uuid import uuid4
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import get_server_ids_by_uuids
from app.database.crud.subscription import (
add_subscription_servers,
calculate_subscription_total_cost,
extend_subscription,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import PaymentMethod, Subscription, Transaction, TransactionType, User
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
apply_percentage_discount,
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
logger = logging.getLogger(__name__)
class SubscriptionRenewalError(Exception):
"""Base class for subscription renewal related errors."""
class SubscriptionRenewalChargeError(SubscriptionRenewalError):
"""Raised when the balance charge step fails."""
@dataclass(slots=True)
class SubscriptionRenewalPricing:
period_days: int
period_id: str
months: int
base_original_total: int
discounted_total: int
final_total: int
promo_discount_value: int
promo_discount_percent: int
overall_discount_percent: int
per_month: int
server_ids: List[int]
details: Dict[str, Any]
def to_payload(self) -> Dict[str, Any]:
return {
"period_id": self.period_id,
"period_days": self.period_days,
"months": self.months,
"base_original_total": self.base_original_total,
"discounted_total": self.discounted_total,
"final_total": self.final_total,
"promo_discount_value": self.promo_discount_value,
"promo_discount_percent": self.promo_discount_percent,
"overall_discount_percent": self.overall_discount_percent,
"per_month": self.per_month,
"server_ids": list(self.server_ids),
"details": dict(self.details),
}
@classmethod
def from_payload(cls, payload: Dict[str, Any]) -> "SubscriptionRenewalPricing":
return cls(
period_days=int(payload.get("period_days", 0) or 0),
period_id=str(payload.get("period_id") or build_renewal_period_id(int(payload.get("period_days", 0) or 0))),
months=int(payload.get("months", 0) or 0),
base_original_total=int(payload.get("base_original_total", 0) or 0),
discounted_total=int(payload.get("discounted_total", 0) or 0),
final_total=int(payload.get("final_total", 0) or 0),
promo_discount_value=int(payload.get("promo_discount_value", 0) or 0),
promo_discount_percent=int(payload.get("promo_discount_percent", 0) or 0),
overall_discount_percent=int(payload.get("overall_discount_percent", 0) or 0),
per_month=int(payload.get("per_month", 0) or 0),
server_ids=list(payload.get("server_ids", []) or []),
details=dict(payload.get("details", {}) or {}),
)
@dataclass(slots=True)
class SubscriptionRenewalResult:
subscription: Subscription
transaction: Optional[Transaction]
total_amount_kopeks: int
charged_from_balance_kopeks: int
old_end_date: Optional[datetime]
@dataclass(slots=True)
class RenewalPaymentDescriptor:
user_id: int
subscription_id: int
period_days: int
total_amount_kopeks: int
missing_amount_kopeks: int
payload_id: str
pricing_snapshot: Optional[Dict[str, Any]] = None
@property
def balance_component_kopeks(self) -> int:
remaining = self.total_amount_kopeks - self.missing_amount_kopeks
return max(0, remaining)
_PAYLOAD_PREFIX = "subscription_renewal"
def build_renewal_period_id(period_days: int) -> str:
return f"days:{period_days}"
def build_payment_descriptor(
user_id: int,
subscription_id: int,
period_days: int,
total_amount_kopeks: int,
missing_amount_kopeks: int,
*,
pricing_snapshot: Optional[Dict[str, Any]] = None,
) -> RenewalPaymentDescriptor:
return RenewalPaymentDescriptor(
user_id=user_id,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, int(total_amount_kopeks)),
missing_amount_kopeks=max(0, int(missing_amount_kopeks)),
payload_id=uuid4().hex[:8],
pricing_snapshot=pricing_snapshot or None,
)
def encode_payment_payload(descriptor: RenewalPaymentDescriptor) -> str:
snapshot_segment = ""
if descriptor.pricing_snapshot:
try:
raw_snapshot = json.dumps(
descriptor.pricing_snapshot,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
snapshot_segment = base64.urlsafe_b64encode(raw_snapshot).decode("ascii").rstrip("=")
except (TypeError, ValueError):
snapshot_segment = ""
payload = (
f"{_PAYLOAD_PREFIX}|{descriptor.user_id}|{descriptor.subscription_id}|"
f"{descriptor.period_days}|{descriptor.total_amount_kopeks}|"
f"{descriptor.missing_amount_kopeks}|{descriptor.payload_id}"
)
if snapshot_segment:
payload = f"{payload}|{snapshot_segment}"
return payload
def decode_payment_payload(payload: str, expected_user_id: Optional[int] = None) -> Optional[RenewalPaymentDescriptor]:
if not payload or not payload.startswith(f"{_PAYLOAD_PREFIX}|"):
return None
parts = payload.split("|")
if len(parts) < 7:
return None
try:
(
_,
user_id_raw,
subscription_raw,
period_raw,
total_raw,
missing_raw,
payload_id,
*snapshot_parts,
) = parts
user_id = int(user_id_raw)
subscription_id = int(subscription_raw)
period_days = int(period_raw)
total_amount = int(total_raw)
missing_amount = int(missing_raw)
except (TypeError, ValueError):
return None
pricing_snapshot: Optional[Dict[str, Any]] = None
if snapshot_parts:
encoded_snapshot = snapshot_parts[0]
if encoded_snapshot:
padding = "=" * (-len(encoded_snapshot) % 4)
try:
decoded = base64.urlsafe_b64decode((encoded_snapshot + padding).encode("ascii"))
snapshot_data = json.loads(decoded.decode("utf-8"))
if isinstance(snapshot_data, dict):
pricing_snapshot = snapshot_data
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Failed to decode renewal pricing snapshot from payload")
if expected_user_id is not None and user_id != expected_user_id:
return None
return RenewalPaymentDescriptor(
user_id=user_id,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, total_amount),
missing_amount_kopeks=max(0, missing_amount),
payload_id=payload_id,
pricing_snapshot=pricing_snapshot,
)
def build_payment_metadata(descriptor: RenewalPaymentDescriptor) -> Dict[str, Any]:
return {
"payment_purpose": _PAYLOAD_PREFIX,
"subscription_id": str(descriptor.subscription_id),
"period_days": str(descriptor.period_days),
"total_amount_kopeks": str(descriptor.total_amount_kopeks),
"missing_amount_kopeks": str(descriptor.missing_amount_kopeks),
"payload_id": descriptor.payload_id,
"pricing_snapshot": descriptor.pricing_snapshot or {},
}
def parse_payment_metadata(
metadata: Optional[Dict[str, Any]],
*,
expected_user_id: Optional[int] = None,
) -> Optional[RenewalPaymentDescriptor]:
if not metadata:
return None
if metadata.get("payment_purpose") != _PAYLOAD_PREFIX:
return None
try:
subscription_id = int(metadata.get("subscription_id"))
period_days = int(metadata.get("period_days"))
total_amount = int(metadata.get("total_amount_kopeks"))
missing_amount = int(metadata.get("missing_amount_kopeks"))
except (TypeError, ValueError):
return None
payload_id = str(metadata.get("payload_id") or "")
user_id = metadata.get("user_id")
if user_id is not None:
try:
user_id_int = int(user_id)
except (TypeError, ValueError):
user_id_int = None
else:
user_id_int = None
if expected_user_id is not None and user_id_int is not None and user_id_int != expected_user_id:
return None
pricing_snapshot = metadata.get("pricing_snapshot")
if isinstance(pricing_snapshot, dict):
snapshot_dict = pricing_snapshot
else:
snapshot_dict = None
return RenewalPaymentDescriptor(
user_id=user_id_int or expected_user_id or 0,
subscription_id=subscription_id,
period_days=period_days,
total_amount_kopeks=max(0, total_amount),
missing_amount_kopeks=max(0, missing_amount),
payload_id=payload_id,
pricing_snapshot=snapshot_dict,
)
async def with_admin_notification_service(
handler: Callable[[AdminNotificationService], Awaitable[Any]],
) -> None:
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
if not settings.BOT_TOKEN:
logger.debug("Skipping admin notification: bot token is not configured")
return
bot: Bot | None = None
try:
bot = Bot(token=settings.BOT_TOKEN)
service = AdminNotificationService(bot)
await handler(service)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Failed to send admin notification from renewal service: %s", error)
finally:
if bot is not None:
await bot.session.close()
class SubscriptionRenewalService:
"""Shared helpers for subscription renewal pricing and processing."""
async def calculate_pricing(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
period_days: int,
) -> SubscriptionRenewalPricing:
connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])]
server_ids: List[int] = []
if connected_uuids:
server_ids = await get_server_ids_by_uuids(db, connected_uuids)
traffic_limit = subscription.traffic_limit_gb
if traffic_limit is None:
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
devices_limit = subscription.device_limit
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
int(traffic_limit or 0),
server_ids,
int(devices_limit or 0),
user=user,
)
months = details.get("months_in_period") or calculate_months_from_days(period_days)
base_original_total = (
details.get("base_price_original", 0)
+ details.get("traffic_price_per_month", 0) * months
+ details.get("servers_price_per_month", 0) * months
+ details.get("devices_price_per_month", 0) * months
)
discounted_total = total_cost
monthly_additions = 0
if months > 0:
monthly_additions = (
details.get("total_servers_price", 0) // months
+ details.get("total_devices_price", 0) // months
+ details.get("total_traffic_price", 0) // months
)
if not validate_pricing_calculation(
details.get("base_price", 0),
monthly_additions,
months,
discounted_total,
):
logger.warning(
"Renewal pricing validation failed for subscription %s (period %s)",
subscription.id,
period_days,
)
from app.utils.promo_offer import get_user_active_promo_discount_percent
promo_percent = get_user_active_promo_discount_percent(user)
final_total = discounted_total
promo_discount_value = 0
if promo_percent > 0 and discounted_total > 0:
final_total, promo_discount_value = apply_percentage_discount(
discounted_total,
promo_percent,
)
overall_discount_value = max(0, base_original_total - final_total)
overall_discount_percent = 0
if base_original_total > 0 and overall_discount_value > 0:
overall_discount_percent = int(
round(overall_discount_value * 100 / base_original_total)
)
per_month = final_total // months if months else final_total
return SubscriptionRenewalPricing(
period_days=period_days,
period_id=build_renewal_period_id(period_days),
months=months,
base_original_total=base_original_total,
discounted_total=discounted_total,
final_total=final_total,
promo_discount_value=promo_discount_value,
promo_discount_percent=promo_percent if promo_discount_value else 0,
overall_discount_percent=overall_discount_percent,
per_month=per_month,
server_ids=list(server_ids),
details=details,
)
async def finalize(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
pricing: SubscriptionRenewalPricing,
*,
charge_balance_amount: Optional[int] = None,
description: Optional[str] = None,
payment_method: Optional[PaymentMethod] = None,
) -> SubscriptionRenewalResult:
final_total = int(pricing.final_total)
if final_total < 0:
final_total = 0
period_days = int(pricing.period_days)
charge_from_balance = charge_balance_amount
if charge_from_balance is None:
charge_from_balance = final_total
charge_from_balance = max(0, min(charge_from_balance, final_total))
consume_promo_offer = bool(pricing.promo_discount_value)
description_text = description or f"Продление подписки на {period_days} дней"
if charge_from_balance > 0 or consume_promo_offer:
success = await subtract_user_balance(
db,
user,
charge_from_balance,
description_text,
consume_promo_offer=consume_promo_offer,
)
if not success:
raise SubscriptionRenewalChargeError("Failed to charge balance")
await db.refresh(user)
subscription_before = subscription
old_end_date = subscription_before.end_date
subscription_after = await extend_subscription(db, subscription_before, period_days)
server_ids = pricing.server_ids or []
server_prices_for_period = pricing.details.get("servers_individual_prices", [])
if server_ids:
try:
await add_subscription_servers(
db,
subscription_after,
server_ids,
server_prices_for_period,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to record renewal server prices for subscription %s: %s",
subscription_after.id,
error,
)
subscription_service = SubscriptionService()
try:
await subscription_service.update_remnawave_user(
db,
subscription_after,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="subscription renewal",
)
except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues
logger.warning("RemnaWave update skipped: %s", error)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
"Failed to update RemnaWave user for subscription %s: %s",
subscription_after.id,
error,
)
transaction: Optional[Transaction] = None
try:
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=final_total,
description=description_text,
payment_method=payment_method,
)
except Exception as error: # pragma: no cover - defensive logging
logger.warning(
"Failed to create renewal transaction for subscription %s: %s",
subscription_after.id,
error,
)
await db.refresh(user)
await db.refresh(subscription_after)
if transaction and old_end_date and subscription_after.end_date:
await with_admin_notification_service(
lambda service: service.send_subscription_extension_notification(
db,
user,
subscription_after,
transaction,
period_days,
old_end_date,
new_end_date=subscription_after.end_date,
balance_after=user.balance_kopeks,
)
)
return SubscriptionRenewalResult(
subscription=subscription_after,
transaction=transaction,
total_amount_kopeks=final_total,
charged_from_balance_kopeks=charge_from_balance,
old_end_date=old_end_date,
)
def build_option_payload(
self,
pricing: SubscriptionRenewalPricing,
*,
language: str,
) -> Dict[str, Any]:
label = format_period_description(pricing.period_days, language)
price_label = settings.format_price(pricing.final_total)
original_label = None
if (
pricing.base_original_total
and pricing.base_original_total != pricing.final_total
):
original_label = settings.format_price(pricing.base_original_total)
per_month_label = settings.format_price(pricing.per_month)
payload = {
"id": pricing.period_id,
"days": pricing.period_days,
"months": pricing.months,
"price_kopeks": pricing.final_total,
"price_label": price_label,
"original_price_kopeks": pricing.base_original_total,
"original_price_label": original_label,
"discount_percent": pricing.overall_discount_percent,
"price_per_month_kopeks": pricing.per_month,
"price_per_month_label": per_month_label,
"title": label,
}
return payload
def calculate_missing_amount(balance_kopeks: int, total_kopeks: int) -> int:
if total_kopeks <= 0:
return 0
if balance_kopeks <= 0:
return total_kopeks
return max(0, total_kopeks - min(balance_kopeks, total_kopeks))
+48 -28
View File
@@ -132,6 +132,13 @@ class SubscriptionService:
self._last_config_signature = config_signature
@staticmethod
def _resolve_user_tag(subscription: Subscription) -> Optional[str]:
if getattr(subscription, "is_trial", False):
return settings.get_trial_user_tag()
return settings.get_paid_subscription_user_tag()
@property
def is_configured(self) -> bool:
return self._config_error is None
@@ -173,7 +180,9 @@ class SubscriptionService:
if not validation_success:
logger.error(f"Ошибка валидации подписки для пользователя {user.telegram_id}")
return None
user_tag = self._resolve_user_tag(subscription)
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
@@ -201,6 +210,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
@@ -236,6 +248,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
create_kwargs['tag'] = user_tag
if hwid_limit is not None:
create_kwargs['hwid_device_limit'] = hwid_limit
@@ -288,15 +303,17 @@ class SubscriptionService:
is_actually_active = (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date > current_time)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
subscription.status = SubscriptionStatus.EXPIRED.value
subscription.updated_at = current_time
await db.commit()
is_actually_active = False
logger.info(f"🔔 Статус подписки {subscription.id} автоматически изменен на 'expired'")
user_tag = self._resolve_user_tag(subscription)
async with self.get_api_client() as api:
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
@@ -314,6 +331,9 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads,
)
if user_tag is not None:
update_kwargs['tag'] = user_tag
if hwid_limit is not None:
update_kwargs['hwid_device_limit'] = hwid_limit
@@ -521,36 +541,36 @@ class SubscriptionService:
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
logger.info(f"Расчет стоимости новой подписки:")
logger.debug("Расчет стоимости новой подписки:")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if discounted_traffic_price > 0:
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
logger.debug(message)
if discounted_devices_price > 0:
message = f" Устройства ({devices}): {devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -634,36 +654,36 @@ class SubscriptionService:
+ discounted_traffic_price
)
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
logger.debug(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if servers_price > 0:
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}"
if servers_discount > 0:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if devices_price > 0:
message = f" 📱 Устройства ({device_limit}): {discounted_devices_price/100}"
if devices_discount > 0:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
)
logger.info(message)
logger.debug(message)
if traffic_price > 0:
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}"
if traffic_discount > 0:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" 💎 ИТОГО: {total_price/100}")
return total_price
@@ -855,14 +875,14 @@ class SubscriptionService:
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.debug(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
base_log = f" Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if total_traffic_price > 0:
message = (
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
@@ -871,14 +891,14 @@ class SubscriptionService:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_servers_price > 0:
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}"
if servers_discount_percent > 0:
message += (
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
@@ -887,8 +907,8 @@ class SubscriptionService:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -972,14 +992,14 @@ class SubscriptionService:
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.debug(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
base_log = f" 📅 Период {period_days} дней: {base_price_original/100}"
if base_discount_total > 0:
base_log += (
f"{base_price/100}"
f" (скидка {period_discount_percent}%: -{base_discount_total/100}₽)"
)
logger.info(base_log)
logger.debug(base_log)
if total_servers_price > 0:
message = (
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}"
@@ -988,7 +1008,7 @@ class SubscriptionService:
message += (
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_devices_price > 0:
message = (
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}"
@@ -997,7 +1017,7 @@ class SubscriptionService:
message += (
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.debug(message)
if total_traffic_price > 0:
message = (
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}"
@@ -1006,8 +1026,8 @@ class SubscriptionService:
message += (
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
)
logger.info(message)
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.debug(message)
logger.debug(f" 💎 ИТОГО: {total_price/100}")
return total_price
+37
View File
@@ -220,6 +220,7 @@ class BotConfigurationService:
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PAID_SUBSCRIPTION_USER_TAG": "SUBSCRIPTION_PRICES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "SUBSCRIPTIONS_CORE",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "SUBSCRIPTIONS_CORE",
@@ -227,6 +228,7 @@ class BotConfigurationService:
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "TRIAL",
"TRIAL_USER_TAG": "TRIAL",
"SUPPORT_USERNAME": "SUPPORT",
"SUPPORT_MENU_ENABLED": "SUPPORT",
"SUPPORT_SYSTEM_MODE": "SUPPORT",
@@ -254,6 +256,7 @@ class BotConfigurationService:
"SIMPLE_SUBSCRIPTION_TRAFFIC_GB": "SIMPLE_SUBSCRIPTION",
"SIMPLE_SUBSCRIPTION_SQUAD_UUID": "SIMPLE_SUBSCRIPTION",
"DISABLE_TOPUP_BUTTONS": "PAYMENT",
"SUPPORT_TOPUP_ENABLED": "PAYMENT",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
@@ -504,6 +507,22 @@ class BotConfigurationService:
"warning": "Слишком малый интервал может привести к частым обращениям к платёжным API.",
"dependencies": "PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED",
},
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": {
"description": (
"Включает применение базовых скидок на периоды подписок в групповых промо."
),
"format": "Булево значение.",
"example": "true",
"warning": "Скидки применяются только если указаны корректные пары периодов и процентов.",
},
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": {
"description": (
"Список скидок для групп: каждая пара задаёт дни периода и процент скидки."
),
"format": "Через запятую пары вида &lt;дней&gt;:&lt;скидка&gt;.",
"example": "30:10,60:20,90:30,180:50,360:65",
"warning": "Некорректные записи будут проигнорированы. Процент ограничен 0-100.",
},
"AUTO_PURCHASE_AFTER_TOPUP_ENABLED": {
"description": (
"При достаточном балансе автоматически оформляет сохранённую подписку сразу после пополнения."
@@ -626,6 +645,24 @@ class BotConfigurationService:
"warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.",
"dependencies": "Результат вызова getMe() в Telegram Bot API",
},
"TRIAL_USER_TAG": {
"description": (
"Тег, который бот передаст пользователю при активации триальной подписки в панели RemnaWave."
),
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
"example": "TRIAL_USER",
"warning": "Неверный формат будет проигнорирован при создании пользователя.",
"dependencies": "Активация триала и включенная интеграция с RemnaWave",
},
"PAID_SUBSCRIPTION_USER_TAG": {
"description": (
"Тег, который бот ставит пользователю при покупке платной подписки в панели RemnaWave."
),
"format": "До 16 символов: заглавные A-Z, цифры и подчёркивание.",
"example": "PAID_USER",
"warning": "Если тег не задан или невалиден, существующий тег не будет изменён.",
"dependencies": "Оплата подписки и интеграция с RemnaWave",
},
}
@classmethod
+332
View File
@@ -0,0 +1,332 @@
"""
Сервис для мониторинга трафика пользователей
Проверяет, не превышает ли пользователь заданный порог трафика за сутки
"""
import logging
import asyncio
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from decimal import Decimal
import aiohttp
from app.config import settings
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveService
from app.database.crud.user import get_user_by_remnawave_uuid
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
class TrafficMonitoringService:
"""
Сервис для мониторинга трафика пользователей
"""
def __init__(self):
self.remnawave_service = RemnaWaveService()
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных проверок
def is_traffic_monitoring_enabled(self) -> bool:
"""Проверяет, включен ли мониторинг трафика"""
return getattr(settings, 'TRAFFIC_MONITORING_ENABLED', False)
def get_traffic_threshold_gb(self) -> float:
"""Получает порог трафика в ГБ за сутки"""
return getattr(settings, 'TRAFFIC_THRESHOLD_GB_PER_DAY', 10.0)
def get_monitoring_interval_hours(self) -> int:
"""Получает интервал мониторинга в часах"""
return getattr(settings, 'TRAFFIC_MONITORING_INTERVAL_HOURS', 24)
def get_suspicious_notifications_topic_id(self) -> Optional[int]:
"""Получает ID топика для уведомлений о подозрительной активности"""
return getattr(settings, 'SUSPICIOUS_NOTIFICATIONS_TOPIC_ID', None)
async def get_user_daily_traffic(self, user_uuid: str) -> Dict:
"""
Получает статистику трафика пользователя за последние 24 часа
Args:
user_uuid: UUID пользователя в Remnawave
Returns:
Словарь с информацией о трафике
"""
try:
# Получаем время начала и конца суток (сегодня)
now = datetime.utcnow()
start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0)
end_of_day = now.replace(hour=23, minute=59, second=59, microsecond=999999)
# Форматируем даты в ISO формат
start_date = start_of_day.strftime("%Y-%m-%dT%H:%M:%S.000Z")
end_date = end_of_day.strftime("%Y-%m-%dT%H:%M:%S.999Z")
# Получаем API клиент и вызываем метод получения статистики
async with self.remnawave_service.get_api_client() as api:
traffic_data = await api.get_user_stats_usage(user_uuid, start_date, end_date)
# Обрабатываем ответ API
if traffic_data and 'response' in traffic_data:
response = traffic_data['response']
# Вычисляем общий трафик
total_gb = 0
nodes_info = []
if isinstance(response, list):
for item in response:
node_name = item.get('nodeName', 'Unknown')
total_bytes = item.get('total', 0)
total_gb_item = round(total_bytes / (1024**3), 2) # Конвертируем в ГБ
total_gb += total_gb_item
nodes_info.append({
'node': node_name,
'gb': total_gb_item
})
else:
# Если response - это уже результат обработки (как в примере)
total_gb = response.get('total_gb', 0)
nodes_info = response.get('nodes', [])
return {
'total_gb': total_gb,
'nodes': nodes_info,
'date_range': {
'start': start_date,
'end': end_date
}
}
else:
logger.warning(f"Нет данных о трафике для пользователя {user_uuid}")
return {
'total_gb': 0,
'nodes': [],
'date_range': {
'start': start_date,
'end': end_date
}
}
except Exception as e:
logger.error(f"Ошибка при получении статистики трафика для {user_uuid}: {e}")
return {
'total_gb': 0,
'nodes': [],
'date_range': {
'start': None,
'end': None
}
}
async def check_user_traffic_threshold(
self,
db: AsyncSession,
user_uuid: str,
user_telegram_id: int = None
) -> Tuple[bool, Dict]:
"""
Проверяет, превышает ли трафик пользователя заданный порог
Args:
db: Сессия базы данных
user_uuid: UUID пользователя в Remnawave
user_telegram_id: Telegram ID пользователя (для логирования)
Returns:
Кортеж (превышен ли порог, информация о трафике)
"""
if not self.is_traffic_monitoring_enabled():
return False, {}
# Получаем статистику трафика
traffic_info = await self.get_user_daily_traffic(user_uuid)
total_gb = traffic_info.get('total_gb', 0)
# Получаем порог для сравнения
threshold_gb = self.get_traffic_threshold_gb()
# Проверяем, превышает ли трафик порог
is_exceeded = total_gb > threshold_gb
# Логируем проверку
user_id_info = f"telegram_id={user_telegram_id}" if user_telegram_id else f"uuid={user_uuid}"
status = "ПРЕВЫШЕНИЕ" if is_exceeded else "норма"
logger.info(
f"📊 Проверка трафика для {user_id_info}: {total_gb} ГБ, "
f"порог: {threshold_gb} ГБ, статус: {status}"
)
return is_exceeded, traffic_info
async def process_suspicious_traffic(
self,
db: AsyncSession,
user_uuid: str,
traffic_info: Dict,
bot
):
"""
Обрабатывает подозрительный трафик - отправляет уведомление администраторам
"""
try:
# Получаем информацию о пользователе из базы данных
user = await get_user_by_remnawave_uuid(db, user_uuid)
if not user:
logger.warning(f"Пользователь с UUID {user_uuid} не найден в базе данных")
return
# Формируем сообщение для администраторов
total_gb = traffic_info.get('total_gb', 0)
threshold_gb = self.get_traffic_threshold_gb()
message = (
f"⚠️ <b>Подозрительная активность трафика</b>\n\n"
f"👤 Пользователь: {user.full_name} (ID: {user.telegram_id})\n"
f"🔑 UUID: {user_uuid}\n"
f"📊 Трафик за сутки: <b>{total_gb} ГБ</b>\n"
f"📈 Порог: <b>{threshold_gb} ГБ</b>\n"
f"🚨 Превышение: <b>{total_gb - threshold_gb:.2f} ГБ</b>\n\n"
)
# Добавляем информацию по нодам, если есть
nodes = traffic_info.get('nodes', [])
if nodes:
message += "<b>Разбивка по нодам:</b>\n"
for node_info in nodes[:5]: # Показываем первые 5 нод
message += f"{node_info.get('node', 'Unknown')}: {node_info.get('gb', 0)} ГБ\n"
if len(nodes) > 5:
message += f" • и ещё {len(nodes) - 5} нод(ы)\n"
message += f"\n⏰ Время проверки: {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S UTC')}"
# Создаем AdminNotificationService с ботом
admin_notification_service = AdminNotificationService(bot)
# Отправляем уведомление администраторам
topic_id = self.get_suspicious_notifications_topic_id()
await admin_notification_service.send_suspicious_traffic_notification(
message,
bot,
topic_id
)
logger.info(
f"✅ Уведомление о подозрительном трафике отправлено для пользователя {user.telegram_id}"
)
except Exception as e:
logger.error(f"❌ Ошибка при обработке подозрительного трафика для {user_uuid}: {e}")
async def check_all_users_traffic(self, db: AsyncSession, bot):
"""
Проверяет трафик всех пользователей с активной подпиской
"""
if not self.is_traffic_monitoring_enabled():
logger.info("Мониторинг трафика отключен, пропускаем проверку всех пользователей")
return
try:
from app.database.crud.user import get_users_with_active_subscriptions
# Получаем всех пользователей с активной подпиской
users = await get_users_with_active_subscriptions(db)
logger.info(f"Начинаем проверку трафика для {len(users)} пользователей")
# Проверяем трафик для каждого пользователя
for user in users:
if user.remnawave_uuid: # Проверяем только пользователей с UUID
is_exceeded, traffic_info = await self.check_user_traffic_threshold(
db,
user.remnawave_uuid,
user.telegram_id
)
if is_exceeded:
await self.process_suspicious_traffic(
db,
user.remnawave_uuid,
traffic_info,
bot
)
logger.info("Завершена проверка трафика всех пользователей")
except Exception as e:
logger.error(f"❌ Ошибка при проверке трафика всех пользователей: {e}")
class TrafficMonitoringScheduler:
"""
Класс для планирования периодических проверок трафика
"""
def __init__(self, traffic_service: TrafficMonitoringService):
self.traffic_service = traffic_service
self.check_task = None
self.is_running = False
async def start_monitoring(self, db: AsyncSession, bot):
"""
Запускает периодическую проверку трафика
"""
if self.is_running:
logger.warning("Мониторинг трафика уже запущен")
return
if not self.traffic_service.is_traffic_monitoring_enabled():
logger.info("Мониторинг трафика отключен в настройках")
return
self.is_running = True
interval_hours = self.traffic_service.get_monitoring_interval_hours()
interval_seconds = interval_hours * 3600
logger.info(f"Запуск мониторинга трафика с интервалом {interval_hours} часов")
# Запускаем задачу с интервалом
self.check_task = asyncio.create_task(self._periodic_check(db, bot, interval_seconds))
async def stop_monitoring(self):
"""
Останавливает периодическую проверку трафика
"""
if self.check_task:
self.check_task.cancel()
try:
await self.check_task
except asyncio.CancelledError:
pass
self.is_running = False
logger.info("Мониторинг трафика остановлен")
async def _periodic_check(self, db: AsyncSession, bot, interval_seconds: int):
"""
Выполняет периодическую проверку трафика
"""
while self.is_running:
try:
logger.info("Запуск периодической проверки трафика")
await self.traffic_service.check_all_users_traffic(db, bot)
# Ждем указанный интервал перед следующей проверкой
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
logger.info("Задача периодической проверки трафика отменена")
break
except Exception as e:
logger.error(f"Ошибка в периодической проверке трафика: {e}")
# Даже при ошибке продолжаем цикл, ждем интервал и пробуем снова
await asyncio.sleep(interval_seconds)
# Глобальные экземпляры сервисов
traffic_monitoring_service = TrafficMonitoringService()
traffic_monitoring_scheduler = TrafficMonitoringScheduler(traffic_monitoring_service)
+22 -1
View File
@@ -23,10 +23,30 @@ logger = logging.getLogger(__name__)
class TributeService:
_invoice_messages: Dict[int, Dict[str, int]] = {}
def __init__(self, bot: Bot):
self.bot = bot
self.tribute_api = TributeAPI()
@classmethod
def remember_invoice_message(cls, user_id: int, chat_id: int, message_id: int) -> None:
cls._invoice_messages[user_id] = {"chat_id": chat_id, "message_id": message_id}
async def _cleanup_invoice_message(self, user_id: int) -> None:
invoice_message = self._invoice_messages.pop(user_id, None)
if not invoice_message or not getattr(self, "bot", None):
return
chat_id = invoice_message.get("chat_id")
message_id = invoice_message.get("message_id")
if not chat_id or not message_id:
return
try:
await self.bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - depends on bot rights
logger.warning("Не удалось удалить Tribute счёт %s: %s", message_id, error)
async def create_payment_link(
self,
@@ -174,7 +194,8 @@ class TributeService:
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
await self._cleanup_invoice_message(user_telegram_id)
await self._send_success_notification(user_telegram_id, amount_kopeks)
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
+77 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, select, update, func
from sqlalchemy.orm import selectinload
from aiogram import Bot, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from app.database.crud.user import (
@@ -20,7 +21,7 @@ from app.database.crud.subscription import (
from app.database.models import (
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
CryptoBotPayment, PlategaPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment, HeleketPayment,
AdvertisingCampaign, AdvertisingCampaignRegistration, PaymentMethod,
TransactionType
@@ -218,6 +219,60 @@ class UserService:
"has_prev": False
}
async def get_users_ready_to_renew(
self,
db: AsyncSession,
min_balance_kopeks: int,
page: int = 1,
limit: int = 20,
) -> Dict[str, Any]:
"""Возвращает пользователей с истекшей подпиской и достаточным балансом."""
try:
offset = (page - 1) * limit
now = datetime.utcnow()
base_filters = [
User.balance_kopeks >= min_balance_kopeks,
Subscription.end_date.isnot(None),
Subscription.end_date <= now,
]
query = (
select(User)
.options(selectinload(User.subscription))
.join(Subscription, Subscription.user_id == User.id)
.where(*base_filters)
.order_by(User.balance_kopeks.desc(), Subscription.end_date.asc())
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
users = result.scalars().all()
count_query = (
select(func.count(User.id))
.join(Subscription, Subscription.user_id == User.id)
.where(*base_filters)
)
total_count = (await db.execute(count_query)).scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 0
return {
"users": users,
"current_page": page,
"total_pages": total_pages,
"total_count": total_count,
}
except Exception as e:
logger.error(f"Ошибка получения пользователей для продления: {e}")
return {
"users": [],
"current_page": 1,
"total_pages": 1,
"total_count": 0,
}
async def get_user_spending_stats_map(
self,
db: AsyncSession,
@@ -732,6 +787,27 @@ class UserService:
except Exception as e:
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
try:
platega_result = await db.execute(
select(PlategaPayment).where(PlategaPayment.user_id == user_id)
)
platega_payments = platega_result.scalars().all()
if platega_payments:
logger.info(f"🔄 Удаляем {len(platega_payments)} Platega платежей")
await db.execute(
update(PlategaPayment)
.where(PlategaPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(
delete(PlategaPayment).where(PlategaPayment.user_id == user_id)
)
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка удаления Platega платежей: {e}")
try:
mulenpay_result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.user_id == user_id)
+4
View File
@@ -3,6 +3,7 @@ from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_language = State()
waiting_for_rules_accept = State()
waiting_for_privacy_policy_accept = State()
waiting_for_referral_code = State()
class SubscriptionStates(StatesGroup):
@@ -37,6 +38,7 @@ class PromoCodeStates(StatesGroup):
class AdminStates(StatesGroup):
waiting_for_user_search = State()
waiting_for_bulk_ban_list = State()
sending_user_message = State()
editing_user_balance = State()
extending_subscription = State()
@@ -95,6 +97,7 @@ class AdminStates(StatesGroup):
editing_user_devices = State()
editing_user_traffic = State()
editing_user_referrals = State()
editing_user_referral_percent = State()
editing_rules_page = State()
editing_privacy_policy = State()
@@ -139,6 +142,7 @@ class AdminStates(StatesGroup):
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
class SupportStates(StatesGroup):
waiting_for_message = State()
+3
View File
@@ -10,12 +10,15 @@ def is_registration_process(event: TelegramObject, current_state: Optional[str])
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
registration_callbacks = [
"rules_accept",
"rules_decline",
"privacy_policy_accept",
"privacy_policy_decline",
"referral_skip"
]
+22 -11
View File
@@ -100,14 +100,14 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
"callback": "topup_platega",
})
# Поддержка всегда доступна
methods.append({
"id": "support",
"name": "Через поддержку",
"icon": "🛠️",
"description": "другие способы",
"callback": "topup_support"
})
if settings.is_support_topup_enabled():
methods.append({
"id": "support",
"name": "Через поддержку",
"icon": "🛠️",
"description": "другие способы",
"callback": "topup_support"
})
return methods
@@ -118,7 +118,18 @@ def get_payment_methods_text(language: str) -> str:
texts = get_texts(language)
methods = get_available_payment_methods()
if len(methods) <= 1: # Только поддержка
if not methods:
return texts.t(
"PAYMENT_METHODS_NONE_AVAILABLE",
"""💳 <b>Способы пополнения баланса</b>
В данный момент способы оплаты временно недоступны.
Попробуйте позже.
Выберите способ пополнения:""",
)
if len(methods) == 1 and methods[0]["id"] == "support":
return texts.t(
"PAYMENT_METHODS_ONLY_SUPPORT",
"""💳 <b>Способы пополнения баланса</b>
@@ -186,7 +197,7 @@ def is_payment_method_available(method_id: str) -> bool:
elif method_id == "platega":
return settings.is_platega_enabled() and bool(settings.get_platega_active_methods())
elif method_id == "support":
return True # Поддержка всегда доступна
return settings.is_support_topup_enabled()
else:
return False
@@ -204,7 +215,7 @@ def get_payment_method_status() -> Dict[str, bool]:
"cryptobot": settings.is_cryptobot_enabled(),
"heleket": settings.is_heleket_enabled(),
"platega": settings.is_platega_enabled() and bool(settings.get_platega_active_methods()),
"support": True
"support": settings.is_support_topup_enabled()
}
def get_enabled_payment_methods_count() -> int:
+2 -5
View File
@@ -72,11 +72,8 @@ def calculate_user_price(
# Get user's promo group discount for this category
discount_percent = user.get_promo_discount(category, period_days)
else:
# For None user, use base settings discount (only for period category)
if category == "period":
discount_percent = settings.get_base_promo_group_period_discount(period_days)
else:
discount_percent = 0
# For None user, use base settings discount
discount_percent = settings.get_base_promo_group_period_discount(period_days)
logger.debug(
f"calculate_user_price: user={user.telegram_id if user else 'None'}, "
+21
View File
@@ -1,12 +1,14 @@
import logging
import secrets
import string
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import User, ReferralEarning, Transaction, TransactionType
logger = logging.getLogger(__name__)
@@ -58,6 +60,25 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
return f"ref{timestamp}"
def get_effective_referral_commission_percent(user: User) -> int:
"""Возвращает индивидуальный процент комиссии пользователя или дефолтное значение."""
percent = getattr(user, "referral_commission_percent", None)
if percent is None:
percent = settings.REFERRAL_COMMISSION_PERCENT
if percent < 0 or percent > 100:
logger.error(
"❌ Некорректный процент комиссии для пользователя %s: %s",
getattr(user, "telegram_id", None),
percent,
)
return max(0, min(100, settings.REFERRAL_COMMISSION_PERCENT))
return percent
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
try:
if user.has_had_paid_subscription:
+45 -17
View File
@@ -121,25 +121,53 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
def sanitize_html(text: str) -> str:
"""
Безопасно санитизирует HTML-текст, заменяя HTML-сущности на соответствующие теги,
при этом предотвращая XSS-уязвимости за счет безопасной обработки атрибутов.
Args:
text (str): Текст с HTML-сущностями (например, &lt;b&gt; жирный &lt;/b&gt;)
Returns:
str: Санитизированный HTML-текст (например, <b> жирный </b>)
"""
if not text:
return text
text = html.escape(text)
for tag in ALLOWED_HTML_TAGS:
text = re.sub(
f'&lt;{tag}(&gt;|\\s[^&]*&gt;)',
lambda m: m.group(0).replace('&lt;', '<').replace('&gt;', '>'),
text,
flags=re.IGNORECASE
)
text = re.sub(
f'&lt;/{tag}&gt;',
f'</{tag}>',
text,
flags=re.IGNORECASE
)
# Для безопасности нужно обработать разрешенные теги, заменяя их сущности на теги
# Но при этом безопасно обрабатывая атрибуты, чтобы избежать XSS
allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS)
# Обработка всех разрешенных тегов
for tag in allowed_tags:
# Паттерн: захватываем &lt;tag&gt;, &lt;/tag&gt;, или &lt;tag атрибуты&gt;
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего &gt;
# (?s) - позволяет . захватывать новую строку
# [^>]*? - ленивый захват до >
pattern = rf'(&lt;)(/?{tag}\b)([^>]*?)(&gt;)'
def replace_tag(match):
opening = match.group(1) # &lt;
full_tag_content = match.group(2) # /?tagname
attrs_part = match.group(3) # атрибуты (без >)
closing = match.group(4) # &gt;
# Убираем начальный пробел, если есть
if attrs_part.startswith(' '):
attrs_part = attrs_part[1:]
# Формируем результат
if attrs_part:
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
# Не разворачиваем &lt; и &gt; внутри атрибутов, чтобы избежать XSS
processed_attrs = attrs_part.replace('&quot;', '"').replace('&#x27;', "'")
return f'<{full_tag_content} {processed_attrs}>'
else:
return f'<{full_tag_content}>'
text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE)
return text
+52 -2
View File
@@ -4,6 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.webapi.docs import add_redoc_endpoint
from .middleware import RequestLoggingMiddleware
from .routes import (
@@ -13,14 +14,19 @@ from .routes import (
config,
health,
main_menu_buttons,
media,
miniapp,
partners,
polls,
promocodes,
promo_groups,
promo_offers,
user_messages,
welcome_texts,
pages,
remnawave,
servers,
subscription_events,
stats,
subscriptions,
tickets,
@@ -46,7 +52,11 @@ OPENAPI_TAGS = [
},
{
"name": "main-menu",
"description": "Управление кнопками главного меню Telegram-бота.",
"description": "Управление кнопками и сообщениями главного меню Telegram-бота.",
},
{
"name": "welcome-texts",
"description": "Создание, редактирование и управление приветственными текстами.",
},
{
"name": "users",
@@ -96,10 +106,18 @@ OPENAPI_TAGS = [
"данных между ботом и панелью."
),
},
{
"name": "media",
"description": "Загрузка файлов в Telegram и получение ссылок на медиа.",
},
{
"name": "miniapp",
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
},
{
"name": "partners",
"description": "Просмотр участников реферальной программы, их доходов и рефералов.",
},
{
"name": "polls",
"description": "Создание опросов, удаление, статистика и ответы пользователей.",
@@ -108,6 +126,14 @@ OPENAPI_TAGS = [
"name": "pages",
"description": "Управление контентом публичных страниц: оферта, политика, FAQ и правила.",
},
{
"name": "notifications",
"description": (
"Получение и просмотр уведомлений о покупках, активациях и продлениях подписок, "
"пополнениях баланса, активациях промокодов, переходах по реферальным ссылкам и "
"сменах промогрупп пользователей для административной панели."
),
},
]
@@ -119,11 +145,18 @@ def create_web_api_app() -> FastAPI:
title=settings.WEB_API_TITLE,
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
redoc_url=None,
openapi_url=docs_config.get("openapi_url"),
swagger_ui_parameters={"persistAuthorization": True},
)
add_redoc_endpoint(
app,
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
title=settings.WEB_API_TITLE,
)
allowed_origins = settings.get_web_api_allowed_origins()
app.add_middleware(
CORSMiddleware,
@@ -151,6 +184,16 @@ def create_web_api_app() -> FastAPI:
prefix="/main-menu/buttons",
tags=["main-menu"],
)
app.include_router(
user_messages.router,
prefix="/main-menu/messages",
tags=["main-menu"],
)
app.include_router(
welcome_texts.router,
prefix="/welcome-texts",
tags=["welcome-texts"],
)
app.include_router(pages.router, prefix="/pages", tags=["pages"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
@@ -158,8 +201,15 @@ def create_web_api_app() -> FastAPI:
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
app.include_router(media.router, tags=["media"])
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
app.include_router(partners.router, prefix="/partners", tags=["partners"])
app.include_router(polls.router, prefix="/polls", tags=["polls"])
app.include_router(logs.router, prefix="/logs", tags=["logs"])
app.include_router(
subscription_events.router,
prefix="/notifications/subscriptions",
tags=["notifications"],
)
return app
+33
View File
@@ -0,0 +1,33 @@
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html
def add_redoc_endpoint(
app: FastAPI,
*,
redoc_url: str | None,
openapi_url: str | None,
title: str | None,
) -> None:
"""Attach a ReDoc endpoint if docs are enabled.
The default FastAPI ReDoc handler sometimes renders a blank page when the
CDN bundle fails to load. By explicitly registering the handler and
pinning the bundle version, we ensure the endpoint always returns a fully
rendered page.
"""
if not redoc_url or not openapi_url:
return
for route in app.router.routes:
if getattr(route, "path", None) == redoc_url:
return
@app.get(redoc_url, include_in_schema=False)
async def redoc_html(): # pragma: no cover - template rendering
return get_redoc_html(
openapi_url=openapi_url,
title=f"{title or app.title} - ReDoc",
redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js",
)
+10
View File
@@ -2,13 +2,18 @@ from . import (
config,
health,
main_menu_buttons,
media,
miniapp,
partners,
polls,
promo_offers,
user_messages,
welcome_texts,
pages,
promo_groups,
servers,
remnawave,
subscription_events,
stats,
subscriptions,
tickets,
@@ -22,13 +27,18 @@ __all__ = [
"config",
"health",
"main_menu_buttons",
"media",
"miniapp",
"partners",
"polls",
"promo_offers",
"user_messages",
"welcome_texts",
"pages",
"promo_groups",
"servers",
"remnawave",
"subscription_events",
"stats",
"subscriptions",
"tickets",
+28
View File
@@ -24,6 +24,7 @@ from ..schemas.logs import (
SupportAuditLogEntry,
SupportAuditLogListResponse,
SystemLogPreviewResponse,
SystemLogFullResponse,
)
router = APIRouter()
@@ -141,6 +142,33 @@ async def download_system_log(
raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error
@router.get("/system/full", response_model=SystemLogFullResponse)
async def get_system_log_full(
_: Any = Security(require_api_token),
) -> SystemLogFullResponse:
"""Получить полный системный лог-файл бота."""
log_path = _resolve_system_log_path()
if not log_path.exists() or not log_path.is_file():
raise HTTPException(status_code=404, detail="Лог-файл не найден")
try:
content, size_bytes, mtime = await _read_system_log(log_path)
except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения
logger.error("Ошибка чтения лог-файла %s: %s", log_path, error)
raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error
return SystemLogFullResponse(
path=str(log_path),
exists=True,
updated_at=_format_timestamp(mtime),
size_bytes=size_bytes,
size_chars=len(content),
content=content,
)
@router.get("/monitoring", response_model=MonitoringLogListResponse)
async def list_monitoring_logs(
_: Any = Security(require_api_token),
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import logging
import mimetypes
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import (
APIRouter,
File,
Form,
HTTPException,
Request,
Response,
Security,
UploadFile,
status,
)
from app.config import settings
from ..dependencies import require_api_token
from ..schemas.media import MediaUploadResponse
router = APIRouter()
logger = logging.getLogger(__name__)
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
def _resolve_target_chat_id() -> int:
"""Выбирает чат для загрузки файлов (канал уведомлений или первый админ)."""
chat_id = settings.get_admin_notifications_chat_id()
if chat_id is not None:
return chat_id
admin_ids = settings.get_admin_ids()
if admin_ids:
return admin_ids[0]
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
"Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)",
)
def _build_media_url(request: Request, file_id: str) -> str:
return str(request.url_for("download_media", file_id=file_id))
@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
_: Any = Security(require_api_token),
file: UploadFile = File(...),
media_type: str = Form("document", description="Тип файла: photo, video или document"),
caption: str | None = Form(None, description="Необязательная подпись к файлу"),
) -> MediaUploadResponse:
media_type_normalized = (media_type or "").strip().lower()
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type")
file_bytes = await file.read()
if not file_bytes:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty")
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or "upload")
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
if media_type_normalized == "photo":
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
caption=caption,
)
media = message.photo[-1]
elif media_type_normalized == "video":
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
caption=caption,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
caption=caption,
)
media = message.document
media_url = _build_media_url(request, media.file_id)
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, "file_unique_id", None),
media_url=media_url,
)
except HTTPException:
raise
except Exception as error:
logger.error("Failed to upload media: %s", error)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error
finally:
await bot.session.close()
@router.get("/media/{file_id}", name="download_media", tags=["media"])
async def download_media(
file_id: str,
_: Any = Security(require_api_token),
) -> Response:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(file_id)
if not file.file_path:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media file not found")
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, "seek"):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, "read") else bytes(buffer)
filename = file.file_path.split("/")[-1]
media_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
return Response(
content=content,
media_type=media_type,
headers={
"Content-Disposition": f"inline; filename={filename}",
},
)
except HTTPException:
raise
except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла
logger.error("Failed to download media %s: %s", file_id, error)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error
finally:
await bot.session.close()
File diff suppressed because it is too large Load Diff
+61 -41
View File
@@ -2,7 +2,16 @@ from __future__ import annotations
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
Query,
Response,
Security,
status,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -233,6 +242,57 @@ async def list_faq_pages(
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: Optional[FaqStatusUpdateRequest] = Body(None),
language: str = Query("ru", min_length=2, max_length=10),
is_enabled: Optional[bool] = Query(None),
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
resolved_language = FaqService.normalize_language(
payload.language if payload and payload.language else language
)
enabled_status = payload.is_enabled if payload else is_enabled
if enabled_status is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Parameter 'is_enabled' is required"
)
setting = await FaqService.set_enabled(db, resolved_language, enabled_status)
return FaqStatusResponse(
requested_language=resolved_language,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.post("/faq", response_model=FaqPageResponse, status_code=status.HTTP_201_CREATED)
async def create_faq_page(
payload: FaqPageCreateRequest,
@@ -366,46 +426,6 @@ async def reorder_faq_pages(
)
@router.get("/faq/status", response_model=FaqStatusResponse)
async def get_faq_status(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
language: str = Query("ru", min_length=2, max_length=10),
fallback: bool = Query(True),
) -> FaqStatusResponse:
requested_lang = FaqService.normalize_language(language)
setting = await FaqService.get_setting(db, requested_lang, fallback=fallback)
if not setting:
return FaqStatusResponse(
requested_language=requested_lang,
language=requested_lang,
is_enabled=False,
)
return FaqStatusResponse(
requested_language=requested_lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.put("/faq/status", response_model=FaqStatusResponse)
async def update_faq_status(
payload: FaqStatusUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> FaqStatusResponse:
lang = FaqService.normalize_language(payload.language)
setting = await FaqService.set_enabled(db, lang, payload.is_enabled)
return FaqStatusResponse(
requested_language=lang,
language=setting.language,
is_enabled=bool(setting.is_enabled),
)
@router.get("/service-rules", response_model=ServiceRulesResponse)
async def get_service_rules(
_: object = Security(require_api_token),
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased, selectinload
from app.database.crud.referral import get_user_referral_stats
from app.database.crud.user import (
get_user_by_id,
get_user_by_telegram_id,
update_user,
)
from app.database.models import User
from app.utils.user_utils import (
get_detailed_referral_list,
get_effective_referral_commission_percent,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.partners import (
PartnerReferralItem,
PartnerReferralList,
PartnerReferralCommissionUpdate,
PartnerReferrerDetail,
PartnerReferrerItem,
PartnerReferrerListResponse,
)
router = APIRouter()
def _apply_search_filter(query, search: str):
search_lower = f"%{search.lower()}%"
conditions = [
func.lower(User.username).like(search_lower),
func.lower(User.first_name).like(search_lower),
func.lower(User.last_name).like(search_lower),
func.lower(User.referral_code).like(search_lower),
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
conditions.append(User.id == int(search))
return query.where(or_(*conditions))
def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem:
total_earned_kopeks = int(stats.get("total_earned_kopeks") or 0)
month_earned_kopeks = int(stats.get("month_earned_kopeks") or 0)
return PartnerReferrerItem(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
referral_code=user.referral_code,
referral_commission_percent=getattr(user, "referral_commission_percent", None),
effective_referral_commission_percent=get_effective_referral_commission_percent(user),
invited_count=int(stats.get("invited_count") or 0),
active_referrals=int(stats.get("active_referrals") or 0),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
month_earned_kopeks=month_earned_kopeks,
month_earned_rubles=round(month_earned_kopeks / 100, 2),
created_at=user.created_at,
last_activity=user.last_activity,
)
def _serialize_referral_item(referral: dict) -> PartnerReferralItem:
balance_kopeks = int(referral.get("balance_kopeks") or 0)
total_earned_kopeks = int(referral.get("total_earned_kopeks") or 0)
return PartnerReferralItem(
id=int(referral.get("id")),
telegram_id=int(referral.get("telegram_id")),
full_name=str(referral.get("full_name")),
username=referral.get("username"),
created_at=referral.get("created_at"),
last_activity=referral.get("last_activity"),
has_made_first_topup=bool(referral.get("has_made_first_topup", False)),
balance_kopeks=balance_kopeks,
balance_rubles=round(balance_kopeks / 100, 2),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
topups_count=int(referral.get("topups_count") or 0),
days_since_registration=int(referral.get("days_since_registration") or 0),
days_since_activity=referral.get("days_since_activity"),
status=str(referral.get("status") or "inactive"),
)
@router.get("/referrers", response_model=PartnerReferrerListResponse)
async def list_referrers(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: Optional[str] = Query(default=None),
) -> PartnerReferrerListResponse:
referral_alias = aliased(User)
has_referrals = (
select(referral_alias.id)
.where(referral_alias.referred_by_id == User.id)
.exists()
)
base_query = select(User).options(selectinload(User.referrer)).where(
or_(User.referral_code.isnot(None), has_referrals)
)
if search:
base_query = _apply_search_filter(base_query, search)
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
)
referrers = result.scalars().unique().all()
items: list[PartnerReferrerItem] = []
for referrer in referrers:
stats = await get_user_referral_stats(db, referrer.id)
items.append(_serialize_referrer(referrer, stats))
return PartnerReferrerListResponse(
items=items,
total=int(total),
limit=limit,
offset=offset,
)
@router.get("/referrers/{user_id}", response_model=PartnerReferrerDetail)
async def get_referrer_detail(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PartnerReferrerDetail:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
stats = await get_user_referral_stats(db, user.id)
referrer_item = _serialize_referrer(user, stats)
referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset)
referral_items = [
_serialize_referral_item(referral) for referral in referrals_data.get("referrals", [])
]
referrals_list = PartnerReferralList(
items=referral_items,
total=int(referrals_data.get("total_count") or 0),
limit=limit,
offset=offset,
has_next=bool(referrals_data.get("has_next")),
has_prev=bool(referrals_data.get("has_prev")),
current_page=int(referrals_data.get("current_page") or 1),
total_pages=int(referrals_data.get("total_pages") or 1),
)
return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list)
@router.patch("/referrers/{user_id}/commission", response_model=PartnerReferrerItem)
async def update_referrer_commission(
user_id: int,
payload: PartnerReferralCommissionUpdate,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PartnerReferrerItem:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
await update_user(
db,
user,
referral_commission_percent=payload.referral_commission_percent,
)
stats = await get_user_referral_stats(db, user.id)
return _serialize_referrer(user, stats)

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