Compare commits

...

1257 Commits

Author SHA1 Message Date
Egor 5ad9fd8ae7 Update Dockerfile 2025-12-21 08:25:31 +03:00
Egor f9cfaa4bc1 Update docker-registry.yml 2025-12-21 08:25:21 +03:00
Egor e1cc7a2e23 Update docker-hub.yml 2025-12-21 08:25:10 +03:00
Egor 7a0faa4b0a Merge pull request #2177 from BEDOLAGA-DEV/dev5
Remnawave 2.4.0+ Api Update, Menu layout Api, Partners Stats Api, Yookassa 22%
2025-12-21 08:13:51 +03:00
Egor a87d067133 Update exported_at to use timezone-aware datetime 2025-12-21 08:08:07 +03:00
Egor 42759854c2 Use timezone-aware datetime for calculations 2025-12-21 08:07:31 +03:00
Egor b2027df7f3 Replace deprecated datetime.utcnow() with _utcnow()
Replaced deprecated datetime.utcnow() with a custom _utcnow() function to return current UTC time as naive datetime.
2025-12-21 08:06:52 +03:00
Egor ce865a9b2a Update start.py 2025-12-21 08:05:53 +03:00
Egor 7dcd11b964 Update contests.py 2025-12-21 07:53:20 +03:00
Egor 6482284cd8 Use configured timezone for contest scheduling
Refactor time handling to use configured timezone for scheduling.
2025-12-21 07:52:35 +03:00
Egor 7f8fbf069b Update referral_contest_service.py 2025-12-21 07:52:00 +03:00
Egor 3e6064756e Update contests.py 2025-12-21 07:51:21 +03:00
Egor 1d1a9e0d78 Add tables for menu layout history and button clicks 2025-12-21 07:18:52 +03:00
Egor bc6e57765e Use UTC for registration days calculation 2025-12-21 07:18:31 +03:00
Egor 16031023cb Change datetime from now() to utcnow() 2025-12-21 07:17:52 +03:00
Egor 151ce092b9 Enhance button stats middleware with builtin callbacks
Added a set of known builtin callback data for button statistics logging.
2025-12-21 07:16:55 +03:00
Egor 01d3659ad2 Update stats_service.py 2025-12-21 07:16:24 +03:00
Egor 7dd0f5f7b0 Improve button configuration and logging
Refactor button construction and configuration handling.
2025-12-21 07:16:06 +03:00
Egor cfd79f4b23 Update partner_stats_service.py 2025-12-21 07:15:39 +03:00
PEDZEO 0cd355a878 Merge pull request #2175 from BEDOLAGA-DEV/buttons
Buttons
2025-12-21 05:02:45 +03:00
Egor 344ff65710 Merge pull request #2173 from Gy9vin/main
Юкасса сдк обновление
2025-12-21 04:52:41 +03:00
PEDZEO 1b47f2aa99 Remove unused imports from partner_stats_service.py to streamline the code and improve readability. 2025-12-21 04:38:42 +03:00
PEDZEO 1d01a77457 Add global partner statistics endpoints and enhance partner-related schemas; implement detailed stats retrieval for referrers and daily statistics. 2025-12-21 04:35:58 +03:00
PEDZEO aa669fa3cd Refactor logging in ButtonStatsMiddleware and cleanup debug endpoints in menu_layout; streamline button click logging and enhance error handling. 2025-12-21 04:05:26 +03:00
Egor e487776510 Update remnawave_service.py 2025-12-21 03:40:00 +03:00
Egor 9d038f7443 Update remnawave_api.py 2025-12-21 03:38:41 +03:00
PEDZEO 02ebcd368e Improve logging in get_top_users to include detailed user data and streamline response item creation 2025-12-21 03:15:25 +03:00
PEDZEO 7e1a2998af Add debug_stats endpoint to expose raw button click log data and enhance logging for user_id checks in get_top_users 2025-12-21 03:10:41 +03:00
Egor 0a5383eb80 Update LICENSE 2025-12-21 02:54:58 +03:00
PEDZEO 763d8f2aaa Enhance logging in get_stats_by_button_type to include total record count in button_click_logs and update stats logging to info level for better visibility. 2025-12-21 02:54:40 +03:00
PEDZEO ce11ec7c0e Enhance ButtonStatsMiddleware with improved logging and error handling; log middleware activation status based on MENU_LAYOUT_ENABLED setting. 2025-12-21 02:17:40 +03:00
PEDZEO 1bb0a5c000 Refactor MenuLayoutStatsService to return hourly statistics as a complete list for all 24 hours, ensuring counts default to 0 when no data exists for a given hour. 2025-12-21 01:14:04 +03:00
gy9vin 2cb6dfb4aa Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-21 00:38:14 +03:00
gy9vin 837473b274 Апи для детальной статистики по конкурсу рефералов 2025-12-21 00:38:08 +03:00
gy9vin aab32299ac Детальная статистика по конкурсу рефералов. 2025-12-21 00:19:13 +03:00
PEDZEO 2a3f1bac76 Refactor MenuLayoutStatsService to create a dictionary for quick access to weekday statistics, ensuring all weekdays are returned with a count of 0 if no data exists. 2025-12-20 23:33:35 +03:00
PEDZEO f4ed274623 Merge branch 'buttons' of https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot into buttons 2025-12-20 21:56:36 +03:00
PEDZEO 32c28aedea Enhance MenuLayoutStatsService to include daily, weekly, and monthly click statistics for buttons, improving analytics capabilities in the menu layout. 2025-12-20 21:56:33 +03:00
PEDZEO 69927a4db2 Improve error handling and logging in MenuLayout statistics endpoints; change button click log ordering to descending. 2025-12-20 21:50:13 +03:00
gy9vin bdc1df737b Фикс автопродления и Фиксы конкурсов 2025-12-20 14:57:38 +03:00
gy9vin c8c79ea807 Улучшение реферального конкурса! 2025-12-20 14:32:26 +03:00
PEDZEO d75fc0c60f Add statistics endpoints in MenuLayoutService for button clicks, including by type, hour, weekday, top users, period comparison, and user click sequences 2025-12-20 03:32:34 +03:00
PEDZEO 931b282f5b Enhance button handling in MenuLayoutService to improve connect button identification and URL management 2025-12-20 03:27:31 +03:00
PEDZEO 10bc00d429 Merge pull request #2174 from BEDOLAGA-DEV/buttons
Buttons
2025-12-20 02:55:59 +03:00
PEDZEO dd24b7ffde Add ButtonStatsMiddleware for automatic button click logging in bot setup 2025-12-20 02:42:40 +03:00
PEDZEO 5fa627bd7f Refactor subscription days calculation and update autopay property naming in main menu keyboard 2025-12-20 02:31:52 +03:00
PEDZEO b8671ef07d Refactor subscription days variable usage in MenuLayoutService to improve clarity and consistency 2025-12-20 02:29:41 +03:00
PEDZEO 7a30aa23dc Update subscription parameter naming in main menu keyboard function 2025-12-20 02:23:55 +03:00
PEDZEO a87d52f2bc Update subscription placeholder naming in menu layout and adjust context variable accordingly 2025-12-20 02:15:51 +03:00
PEDZEO 5919cfff16 Refactor referral data retrieval and update subscription placeholder naming in menu layout 2025-12-20 01:55:26 +03:00
PEDZEO e743689b34 Enhance main menu keyboard functionality by adding user data support and subscription details retrieval 2025-12-20 01:40:23 +03:00
PEDZEO 37dd5ede9f fix 2025-12-19 23:27:00 +03:00
Mikhail 930aabc166 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-19 17:42:58 +03:00
gy9vin f70ad92efe Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-19 17:42:10 +03:00
gy9vin 01fd7a317d НДС 22 2025-12-19 17:42:03 +03:00
Egor 507df0af33 Merge pull request #2172 from BEDOLAGA-DEV/d2pxq4-bedolaga/fix-missing-localization-key-for-ru
Add missing Buy Traffic localization key
2025-12-19 07:28:16 +03:00
PEDZEO af91423dec Merge pull request #2171 from BEDOLAGA-DEV/buttons
Buttons
2025-12-19 07:27:44 +03:00
Egor 893ff0e8f1 Add missing Buy Traffic localization key 2025-12-19 07:27:27 +03:00
Egor cde838864f Merge pull request #2170 from BEDOLAGA-DEV/main
w
2025-12-19 07:23:40 +03:00
Egor 2eb2661848 Merge pull request #2167 from BEDOLAGA-DEV/dev5
Dev5
2025-12-19 07:23:00 +03:00
Egor d7a935906e Merge pull request #2168 from Gy9vin/main
Фиксы
2025-12-19 07:22:42 +03:00
PEDZEO b81400105f Add button open mode and webapp url 2025-12-19 04:02:58 +03:00
PEDZEO 8c3e71bdfd Fix menu layout update conversions 2025-12-19 03:22:44 +03:00
PEDZEO 025d50675f test 2025-12-19 02:14:57 +03:00
Mikhail 5f540d907e Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 17:51:05 +03:00
gy9vin c6175255a5 фикс кнопок конкурсов 2025-12-18 17:50:36 +03:00
gy9vin 71facd9624 Активация подписки после пополнения баланса! 2025-12-18 08:45:59 +03:00
Egor 89a5f5c6f7 Merge pull request #2166 from BEDOLAGA-DEV/j0zhkm-bedolaga/add-subscription-management-for-users-who-unsubscribed
Add toggle for trial deactivation on channel unsubscribe
2025-12-18 03:23:14 +03:00
Egor 7b60be1ec7 Add toggle for trial deactivation on channel unsubscribe 2025-12-18 03:04:13 +03:00
Egor ed314ca19b Merge pull request #2165 from Gy9vin/main
Фиксы, правки и хотелки
2025-12-18 00:16:25 +03:00
Mikhail 0b714893e7 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-18 00:14:20 +03:00
gy9vin 5ca0f5fc27 Еще правки 2025-12-18 00:13:57 +03:00
Egor 31fa7b7072 Merge pull request #2164 from BEDOLAGA-DEV/main
ц
2025-12-17 20:23:29 +03:00
Egor 86d7003c33 Merge pull request #2161 from Gy9vin/main
Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!!
2025-12-17 19:13:32 +03:00
Egor 753d77b4c2 Merge pull request #2162 from Vysokostnyi/main
Добавлено передача telegram_user_id в описание платежа
2025-12-17 19:13:05 +03:00
Vysokostnyi 4add298aab Добавлено передача telegram_user_id в описание платежа 2025-12-16 02:41:58 +03:00
gy9vin e76f2f3d50 Кнопка конкурсов + прототип налоговой. не трогайте налоговую еще!!! 2025-12-16 00:59:23 +03:00
Egor dcb0002f4a Merge pull request #2160 from Gy9vin/main
Конкурсные
2025-12-15 22:58:21 +03:00
gy9vin 332c20fc45 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-15 21:36:23 +03:00
gy9vin 305a3c4490 Фиксы по конкурсам. 2025-12-15 21:34:56 +03:00
Mikhail a9ecd5f620 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-15 20:05:03 +03:00
gy9vin 2f0594e361 Конкурсная система. 2025-12-15 20:04:39 +03:00
Egor 65b36c3a5f Merge pull request #2159 from BEDOLAGA-DEV/revert-2158-c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:12:22 +03:00
Egor f8ef2b9f5a Revert "Add limited Happ cryptolink support" 2025-12-15 11:12:14 +03:00
Egor 70a283d1f1 Merge pull request #2158 from BEDOLAGA-DEV/c8yqa8-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 11:00:54 +03:00
Egor f2a5032f15 Refresh Happ link before displaying subscription 2025-12-15 11:00:39 +03:00
Egor 0f5fcdd067 Merge pull request #2157 from BEDOLAGA-DEV/revert-2156-2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Revert "Add limited Happ cryptolink support"
2025-12-15 11:00:26 +03:00
Egor 80388be54f Revert "Add limited Happ cryptolink support" 2025-12-15 11:00:17 +03:00
Egor 47f0b77138 Merge pull request #2156 from BEDOLAGA-DEV/2gz1mq-bedolaga/add-subscription-settings-for-happ_cryptolink
Add limited Happ cryptolink support
2025-12-15 10:41:18 +03:00
Egor 162e7da350 Add cooldown for Happ device reset links 2025-12-15 10:40:59 +03:00
Egor 07e1e08b1d Merge pull request #2154 from BEDOLAGA-DEV/revert-2153-49fvja-bedolaga/add-web-admin-button-for-admins
Revert "Add admin web panel miniapp toggle"
2025-12-15 07:24:51 +03:00
Egor fc955bd3b2 Revert "Add admin web panel miniapp toggle" 2025-12-15 07:24:41 +03:00
Egor 1b504063d2 Merge pull request #2153 from BEDOLAGA-DEV/49fvja-bedolaga/add-web-admin-button-for-admins
Add admin web panel miniapp toggle
2025-12-15 07:19:01 +03:00
Egor 174f9b8517 Add admin web panel miniapp toggle 2025-12-15 07:18:45 +03:00
Egor 3706d5ceb4 Merge pull request #2152 from BEDOLAGA-DEV/main
w
2025-12-14 19:35:39 +03:00
Egor 3577054d79 Merge pull request #2151 from Gy9vin/main
Коркурсная система + АПИ
2025-12-14 19:32:41 +03:00
gy9vin afd4fe8d1d Конкурсы +АПИ 2025-12-14 14:37:29 +03:00
gy9vin 1409a0ab8d Конкурсы 2025-12-14 01:38:22 +03:00
Egor 9df8ca52d6 Merge pull request #2150 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 08:54:47 +03:00
Egor 0902efc007 Merge pull request #2149 from BEDOLAGA-DEV/9ks456-bedolaga/fix-url-validation-error-in-bot
Restrict blacklist URL input to waiting state
2025-12-12 08:52:51 +03:00
Egor 0b4fea02a1 Restrict blacklist URL input to waiting state 2025-12-12 08:52:36 +03:00
Egor 01580aae74 Merge pull request #2148 from BEDOLAGA-DEV/dev5
Dev5
2025-12-12 06:58:03 +03:00
Egor 7e022a1eca Merge pull request #2147 from BEDOLAGA-DEV/om94g5-bedolaga/update-subscription-link-display
Wrap Happ CryptoLink subscription link in blockquote
2025-12-12 06:56:36 +03:00
Egor f31c64602c Wrap Happ CryptoLink subscription link in blockquote 2025-12-12 06:55:58 +03:00
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 1e56e4eaa6 Merge pull request #2146 from BEDOLAGA-DEV/main
w
2025-12-12 06:08:29 +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
Egor 813193102c Update Dockerfile 2025-11-10 06:28:50 +03:00
Egor 42112d6c8a Update docker-registry.yml 2025-11-10 06:28:40 +03:00
Egor dfe5e36fe0 Update docker-hub.yml 2025-11-10 06:28:31 +03:00
Egor b5d4a54e97 Update README.md 2025-11-10 06:22:26 +03:00
Egor 1eaf7204ce Merge pull request #1859 from Fr1ngg/dev4
fix #1849 Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:13:51 +03:00
Egor 8cc37dc439 Merge pull request #1858 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot-i1csym
Round CryptoBot top-up amounts up to whole rubles
2025-11-10 06:11:42 +03:00
Egor ccafb18122 Round CryptoBot top-up amounts up to whole rubles 2025-11-10 06:11:18 +03:00
Egor 654b132ae6 Merge pull request #1857 from Fr1ngg/revert-1855-bedolaga/fix-webhook-handler-error-in-cryptobot
Revert "Refactor CryptoBot notifications to avoid transaction rollback"
2025-11-10 06:11:05 +03:00
Egor 815fa0560c Revert "Refactor CryptoBot notifications to avoid transaction rollback" 2025-11-10 06:10:54 +03:00
Egor c4d19810a4 Merge pull request #1856 from Fr1ngg/dev4
fix
2025-11-10 06:05:55 +03:00
Egor 9fbb32eb5b Merge pull request #1855 from Fr1ngg/bedolaga/fix-webhook-handler-error-in-cryptobot
Refactor CryptoBot notifications to avoid transaction rollback
2025-11-10 05:59:09 +03:00
Egor 3ce3cde859 Refactor CryptoBot notifications to avoid transaction rollback 2025-11-10 05:53:26 +03:00
Egor 4e71dcd2b1 Merge pull request #1853 from Fr1ngg/dev4
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:33:11 +03:00
Egor acf21fa6d2 Merge pull request #1852 from Fr1ngg/bedolaga/update-requirements.txt-and-fix-pal24-webhook
Update Pal24 callback handling and SBP link fallback
2025-11-10 05:29:04 +03:00
Egor f2b724a78d Adjust Pal24 callback handling and SBP link fallback 2025-11-10 05:26:02 +03:00
Egor 03473360c1 Merge pull request #1851 from Fr1ngg/dev4
Dev4
2025-11-10 04:30:16 +03:00
Egor 27dc68be41 Merge pull request #1850 from Fr1ngg/bedolaga/fix-webhook-bot-error-handling
Prevent maintenance monitoring from crashing without RemnaWave URL
2025-11-10 04:26:35 +03:00
Egor b15d78f8af Handle missing RemnaWave config in maintenance monitoring 2025-11-10 04:25:52 +03:00
Egor d232d14937 Merge pull request #1848 from Fr1ngg/dev4
fix migration on new bases
2025-11-10 04:13:19 +03:00
Egor 927d469db2 Merge pull request #1847 from Fr1ngg/bedolaga/fix-database-migration-crash-on-first-bot-launch
Handle missing tables when creating indexes during init_db
2025-11-10 04:06:46 +03:00
Egor e20e9e7cec Fix init_db index creation on fresh databases 2025-11-10 04:06:28 +03:00
Egor 0f097c3108 Merge pull request #1830 from Gy9vin/main
Фикс простой покупки!
2025-11-10 03:37:31 +03:00
gy9vin eaa3c80d59 Фикс простой покупки! 2025-11-09 21:55:58 +03:00
Egor 0c49eddfde Merge pull request #1828 from Gy9vin/main
Фикс проверки зачисления платежа юкассы(защита от дублирования)
2025-11-09 20:09:15 +03:00
gy9vin 33882f1e93 Фикс проверки зачисления платежа юкассы(защита от дублирования) 2025-11-09 18:31:39 +03:00
Egor f94666b68d Merge pull request #1826 from Fr1ngg/dev4
Dev4
2025-11-09 10:29:27 +03:00
Egor d32a49f801 Merge pull request #1825 from Fr1ngg/bedolaga/fix-bulk_update-to-include-where-clause
Fix bulk_update to target records by primary key
2025-11-09 10:29:11 +03:00
Egor 9596f82ea6 Fix bulk_update to target records by primary key 2025-11-09 10:28:47 +03:00
Egor 0e3fdf7d61 Merge pull request #1824 from Fr1ngg/dev4
Razgonchik
2025-11-09 10:24:57 +03:00
Egor 54cfec6029 Merge pull request #1823 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits-y7sxo6
Ensure YooKassa retries finish pending credits
2025-11-09 10:13:20 +03:00
Egor 9e88eae416 Ensure YooKassa retries finish pending credits 2025-11-09 10:13:01 +03:00
Egor 8d32efac73 Merge pull request #1822 from Fr1ngg/revert-1821-bedolaga/fix-repeated-yookassa-deposits
Revert "Prevent double processing of YooKassa payments"
2025-11-09 10:04:02 +03:00
Egor 9f07b6007e Revert "Prevent double processing of YooKassa payments" 2025-11-09 10:03:54 +03:00
Egor 2fb3b6c5dc Merge pull request #1821 from Fr1ngg/bedolaga/fix-repeated-yookassa-deposits
Prevent double processing of YooKassa payments
2025-11-09 10:03:38 +03:00
Egor 0637576425 Prevent double processing of YooKassa payments 2025-11-09 10:00:17 +03:00
Egor ee7a445610 Update database.py 2025-11-09 09:46:40 +03:00
Egor ba4c7ee7ac Update database.py 2025-11-09 09:43:26 +03:00
Egor 4da169eb0f Merge pull request #1819 from Fr1ngg/bedolaga/fix-health-endpoints-for-sqlite-nullpool
Handle missing pool metrics for NullPool
2025-11-09 09:40:53 +03:00
Egor 28937abab4 Handle missing pool metrics for NullPool 2025-11-09 09:40:08 +03:00
Egor b1dcf2779e Update database.py 2025-11-09 09:25:59 +03:00
Egor 3219062ce2 Update database.py 2025-11-09 09:22:36 +03:00
Egor 2ac72951f8 Merge pull request #1818 from Fr1ngg/bedolaga/add-health-and-metrics-endpoints-to-fastapi
Add database health and pool metrics endpoints
2025-11-09 09:17:35 +03:00
Egor cefb8cba78 Add database health and pool metrics endpoints 2025-11-09 09:17:18 +03:00
Egor e869655028 Update database.py 2025-11-09 09:11:47 +03:00
Egor ae03ede209 Merge pull request #1817 from Fr1ngg/dev4
Dev
2025-11-09 08:34:49 +03:00
Egor a72bb25ea2 Update database.py 2025-11-09 08:30:38 +03:00
Egor 0b22bc7056 Merge pull request #1815 from Fr1ngg/bedolaga/-nullpool-queuepool-wh494i
Handle optional Pal24 metadata fields
2025-11-09 08:24:26 +03:00
Egor cde84ff20b Handle optional Pal24 metadata fields 2025-11-09 08:23:01 +03:00
Egor f76114d7dc Update database.py 2025-11-09 08:17:43 +03:00
Egor 9708a56fd7 Update database.py 2025-11-09 08:14:41 +03:00
Egor a360337660 Merge pull request #1813 from Fr1ngg/dev4
фикс бекапов/восстановления
2025-11-09 07:39:18 +03:00
Egor 9f78335651 Merge pull request #1812 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-1wazcg
Avoid duplicate association errors during restore
2025-11-09 07:31:17 +03:00
Egor ef8142a99b Skip duplicate squad promo associations during restore 2025-11-09 07:30:54 +03:00
Egor 06d51f9fcf Merge pull request #1811 from Fr1ngg/revert-1810-bedolaga/improve-backup-and-restore-mechanism-gn1h90
Revert "Handle PostgreSQL backups without pg_dump"
2025-11-09 07:30:41 +03:00
Egor 4d94347179 Revert "Handle PostgreSQL backups without pg_dump" 2025-11-09 07:30:31 +03:00
Egor 2f6aa0b1e6 Merge pull request #1810 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism-gn1h90
Handle PostgreSQL backups without pg_dump
2025-11-09 07:23:11 +03:00
Egor 536a58d4d3 Handle PostgreSQL backups without pg_dump 2025-11-09 07:22:50 +03:00
Egor 62f7c07059 Merge pull request #1809 from Fr1ngg/revert-1808-bedolaga/improve-backup-and-restore-mechanism
Revert "Refactor backup pipeline to use archive dumps"
2025-11-09 07:22:10 +03:00
Egor 0648edb3c4 Revert "Refactor backup pipeline to use archive dumps" 2025-11-09 07:21:56 +03:00
Egor 9b6c235424 Merge pull request #1808 from Fr1ngg/bedolaga/improve-backup-and-restore-mechanism
Refactor backup pipeline to use archive dumps
2025-11-09 07:12:18 +03:00
Egor 2fc35950cf Refactor backup system with archive dumps 2025-11-09 07:12:01 +03:00
Egor d2a5d74239 Merge pull request #1807 from Fr1ngg/dev4
Dev4
2025-11-09 07:06:24 +03:00
Egor 6b04c6748e Merge pull request #1806 from Fr1ngg/bedolaga/add-trial-activation-funds-check
Prefill top-up amount for paid trial activation
2025-11-09 06:58:03 +03:00
Egor 10b44cbdb7 Prefill top-up amount for paid trial activation 2025-11-09 06:51:50 +03:00
Egor dde04c716c Merge pull request #1805 from Fr1ngg/bedolaga/fix-variable-loading-for-minimum-sum
Adjust Platega top-up prompt to show configured limits
2025-11-09 06:44:55 +03:00
Egor 819f19a7ea Update Platega prompt to use configured limits 2025-11-09 06:44:23 +03:00
Egor e7201597ab Merge pull request #1804 from Fr1ngg/bedolaga/fix-pall24-payment-method-issue
Fix Pal24 SBP link handling after status checks
2025-11-09 06:35:54 +03:00
Egor ea61aa7053 Fix Pal24 SBP link handling after status checks 2025-11-09 06:31:02 +03:00
Egor 14546a8485 Merge pull request #1803 from Fr1ngg/bedolaga/add-ip-validation-for-yookassa-webhooks
Handle Cloudflare proxied YooKassa webhooks
2025-11-09 06:22:17 +03:00
Egor f55455761f Trust Cloudflare headers for YooKassa webhooks 2025-11-09 06:21:40 +03:00
Egor b1a7767827 Merge pull request #1802 from Fr1ngg/revert-1801-bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Revert "Fix server category button routing in admin panel"
2025-11-09 05:55:16 +03:00
Egor 43c04b7e64 Revert "Fix server category button routing in admin panel" 2025-11-09 05:55:07 +03:00
Egor add679cf6f Merge pull request #1801 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-ltnwjl
Fix server category button routing in admin panel
2025-11-09 05:49:00 +03:00
Egor ee173190a0 Fix category edit menu callback 2025-11-09 05:48:45 +03:00
Egor a6e21b47f6 Merge pull request #1800 from Fr1ngg/revert-1799-bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Revert "Fix category pricing to ignore full squads"
2025-11-09 05:40:56 +03:00
Egor cd627e5840 Revert "Fix category pricing to ignore full squads" 2025-11-09 05:40:47 +03:00
Egor fc096e6867 Merge pull request #1799 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel-fwofd2
Fix category pricing to ignore full squads
2025-11-09 05:33:29 +03:00
Egor b591844c4e Fix category pricing to ignore full squads 2025-11-09 05:33:13 +03:00
Egor 9557acb546 Merge pull request #1798 from Fr1ngg/revert-1797-bedolaga/add-server-category-feature-to-admin-panel
Revert "Ensure server category selection skips full squads"
2025-11-09 05:31:20 +03:00
Egor 1fa6e75bd3 Revert "Ensure server category selection skips full squads" 2025-11-09 05:31:09 +03:00
Egor 8965e43a55 Merge pull request #1797 from Fr1ngg/bedolaga/add-server-category-feature-to-admin-panel
Ensure server category selection skips full squads
2025-11-09 05:24:50 +03:00
Egor 8e7a6beece Ensure category auto-selection respects capacity 2025-11-09 05:23:58 +03:00
Egor e9f4fd8007 Merge pull request #1796 from Fr1ngg/revert-1793-bedolaga/implement-server-grouping-system-for-vpn-bot
Revert "Add server group backend selection logic"
2025-11-09 04:54:55 +03:00
Egor a991c59d58 Revert "Add server group backend selection logic" 2025-11-09 04:54:47 +03:00
Egor dd0624adf8 Merge pull request #1795 from Fr1ngg/revert-1794-bedolaga/check-functionality-for-server-grouping
Revert "Fix autopurchase server selection and RemnaWave stats handling"
2025-11-09 04:54:31 +03:00
Egor ab51cbe96a Revert "Fix autopurchase server selection and RemnaWave stats handling" 2025-11-09 04:54:24 +03:00
Egor 2f7a08deb0 Merge pull request #1794 from Fr1ngg/bedolaga/check-functionality-for-server-grouping
Fix autopurchase server selection and RemnaWave stats handling
2025-11-09 04:50:50 +03:00
Egor a0fa7f986b Fix autopurchase server selection and RemnaWave stats handling 2025-11-09 04:50:32 +03:00
Egor b2d3eebe39 Merge pull request #1793 from Fr1ngg/bedolaga/implement-server-grouping-system-for-vpn-bot
Add server group backend selection logic
2025-11-09 04:39:54 +03:00
Egor a043fc0e46 feat: add backend support for server groups 2025-11-09 04:36:33 +03:00
Egor d6eec8787e Merge pull request #1792 from Fr1ngg/bedolaga/fix-payment-methods-display-for-platega.io
Add Platega payment option to balance top-up list
2025-11-09 03:31:21 +03:00
Egor 95eee42047 Добавить Platega в список пополнения 2025-11-09 03:30:49 +03:00
Egor c673d6db4f Merge pull request #1790 from Fr1ngg/dev4
Dev4
2025-11-08 18:58:33 +03:00
Egor bb32a63d48 Merge pull request #1789 from Fr1ngg/bedolaga/fix-null-value-constraint-in-promo_groups
Fix default promo group creation with priority column
2025-11-08 18:58:13 +03:00
Egor af5acb272c Fix default promo group creation with priority column 2025-11-08 18:57:53 +03:00
Egor 5cd5ac8710 Merge pull request #1788 from Fr1ngg/revert-1787-dev4
Revert "Dev4"
2025-11-08 12:05:27 +03:00
Egor 8bb58b44b3 Revert "Dev4" 2025-11-08 12:05:12 +03:00
Egor 511ecd506f Merge pull request #1787 from Fr1ngg/dev4
Dev4
2025-11-08 11:38:41 +03:00
Egor 62fc014bc3 Restore json serialization helper for YooKassa webhook tests 2025-11-08 11:38:18 +03:00
Egor 961f564274 Merge pull request #1786 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-lcydgf
Remove YooKassa IP filtering from webhooks
2025-11-08 11:27:53 +03:00
Egor 984870c78c Remove YooKassa IP filtering from webhooks 2025-11-08 11:27:37 +03:00
Egor 6196c135d2 Merge pull request #1785 from Fr1ngg/revert-1782-bedolaga/fix-webhook-ip-verification-for-w66vvd
Revert "Verify YooKassa webhooks against API"
2025-11-08 11:27:23 +03:00
Egor bf1b8315a8 Revert "Verify YooKassa webhooks against API" 2025-11-08 11:27:13 +03:00
Egor b7eabcf7e7 Merge pull request #1784 from Fr1ngg/revert-1783-dev4
Revert "Dev4"
2025-11-08 11:08:55 +03:00
Egor e83ebc4f02 Revert "Dev4" 2025-11-08 11:08:47 +03:00
Egor 1a4758f4cc Merge pull request #1783 from Fr1ngg/dev4
Dev4
2025-11-08 11:04:08 +03:00
Egor b80e8b3804 Merge pull request #1782 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for-w66vvd
Verify YooKassa webhooks against API
2025-11-08 11:00:49 +03:00
Egor e036173670 Verify YooKassa webhooks against API 2025-11-08 11:00:28 +03:00
Egor bd728c6466 Merge pull request #1781 from Fr1ngg/revert-1777-bedolaga/fix-webhook-ip-verification-for
Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks"
2025-11-08 10:58:48 +03:00
Egor e3aec310a3 Revert "Handle Cloudflare forwarded IPs for YooKassa webhooks" 2025-11-08 10:58:34 +03:00
Egor 1c59ce601e Merge pull request #1780 from Fr1ngg/revert-1779-dev4
Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu "
2025-11-08 10:58:21 +03:00
Egor 0563f7524f Revert "da eb tvoyu mat' kakoy pidoras delal etu ukassu " 2025-11-08 10:58:12 +03:00
Egor cedbe07759 Merge pull request #1779 from Fr1ngg/dev4
da eb tvoyu mat' kakoy pidoras delal etu ukassu
2025-11-08 10:53:02 +03:00
Egor 0746b2a5a7 Merge pull request #1778 from Fr1ngg/main
w
2025-11-08 10:52:15 +03:00
Egor e8a72860dc Merge pull request #1777 from Fr1ngg/bedolaga/fix-webhook-ip-verification-for
Handle Cloudflare forwarded IPs for YooKassa webhooks
2025-11-08 10:51:50 +03:00
Egor abbfe4a7d3 Handle Cloudflare forwarded IPs for YooKassa webhooks 2025-11-08 10:50:56 +03:00
Egor 1ad5d9123b Update Dockerfile 2025-11-08 09:05:30 +03:00
Egor 57031e4afe Update docker-registry.yml 2025-11-08 09:05:14 +03:00
Egor b54d2434c0 Update docker-hub.yml 2025-11-08 09:05:02 +03:00
Egor 7c2d7b6b4f Merge pull request #1776 from Fr1ngg/dev4
платный триал + фиксы вебхука ебучей юкассы(без негатива))(
2025-11-08 08:35:25 +03:00
Egor 9e731a5f18 Merge pull request #1775 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-fo5kxf
Bedolaga/add paid trial activation feature fo5kxf
2025-11-08 08:25:32 +03:00
Egor cd3832fef0 Handle trial provisioning failures by refunding charges 2025-11-08 08:14:49 +03:00
Egor cb6d51ed3d Remove trial squad override setting 2025-11-08 07:55:24 +03:00
Egor 8b75768296 Merge pull request #1773 from Fr1ngg/revert-1772-bedolaga/add-paid-trial-activation-feature-pws6p8
Revert "Rollback trial activation when charge errors occur"
2025-11-08 07:55:14 +03:00
Egor 3f0a26b1c7 Revert "Rollback trial activation when charge errors occur" 2025-11-08 07:55:04 +03:00
Egor de4f608d09 Merge pull request #1772 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-pws6p8
Rollback trial activation when charge errors occur
2025-11-08 07:42:33 +03:00
Egor 618327b9f1 Rollback trial trials when post-charge errors occur 2025-11-08 07:37:21 +03:00
Egor 54fdad8f0c Merge pull request #1771 from Fr1ngg/revert-1770-bedolaga/add-paid-trial-activation-feature-obw312
Revert "Delay trial activation charges until after subscription creation"
2025-11-08 07:27:03 +03:00
Egor e9bbd68aa1 Revert "Delay trial activation charges until after subscription creation" 2025-11-08 07:26:54 +03:00
Egor 5df4837087 Merge pull request #1770 from Fr1ngg/bedolaga/add-paid-trial-activation-feature-obw312
Delay trial activation charges until after subscription creation
2025-11-08 07:18:14 +03:00
Egor 025e74c462 Defer trial activation charges until subscription creation succeeds 2025-11-08 07:17:57 +03:00
Egor 3208303c23 Merge pull request #1767 from Fr1ngg/bedolaga/remove-webhook-secret-references-for-yukassa-n5vl0v
Handle YooKassa cancellations in FastAPI webhook
2025-11-08 05:59:35 +03:00
Egor 4cf0c5a971 Handle YooKassa cancellations in FastAPI webhook 2025-11-08 05:44:53 +03:00
Egor 7519352e79 Merge pull request #1765 from Fr1ngg/main
w
2025-11-08 04:47:01 +03:00
Egor e7f94f9661 Merge pull request #1763 from aleksandryants/patch-1
Add wata payment processing method
2025-11-08 04:46:16 +03:00
Egor cb68a612e9 Merge pull request #1764 from Gy9vin/main
Обновка!
2025-11-08 04:44:25 +03:00
Egor 44d4deb5a6 Update .gitignore 2025-11-08 04:43:58 +03:00
gy9vin 23c73d1f21 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-07 23:56:51 +03:00
gy9vin 607beca837 fix 2025-11-07 23:56:49 +03:00
Egor 31a5f8766f Delete QWEN.md 2025-11-07 23:21:06 +03:00
gy9vin cfdc829f48 update 2025-11-07 22:24:43 +03:00
aleksandryants c44cefb983 Add wata payment processing method
Добавить обработку платежа wata
2025-11-07 19:49:27 +03:00
Egor 79c2307c92 Merge pull request #1762 from Fr1ngg/dev4
ебучая юкасса
2025-11-07 10:55:04 +03:00
Egor 25f126a5cb Merge pull request #1761 from Fr1ngg/bedolaga/disable-webhook-secret-validation-and-check-ip-tr0szb
fix: allow trusted public proxies for YooKassa webhooks
2025-11-07 10:49:17 +03:00
Egor 45bd54e7b0 fix: allow trusted public proxies for YooKassa webhooks 2025-11-07 10:43:19 +03:00
Egor 4ce43f80b8 Merge pull request #1755 from Fr1ngg/dev4
Platega + small fixs
2025-11-07 08:40:52 +03:00
Egor f7a4cedbbf Fix missing logging import in Platega handler 2025-11-07 08:29:29 +03:00
Egor 6f83122e42 Merge pull request #1753 from Fr1ngg/bedolaga/update-readme-with-platega.io-info
docs: document Platega payment integration
2025-11-07 08:18:18 +03:00
Egor dbcf3541a8 docs: document Platega payment integration 2025-11-07 08:16:45 +03:00
Egor b7adf3e971 Merge pull request #1752 from Fr1ngg/main
w
2025-11-07 08:08:01 +03:00
Egor 037b1f876b Merge pull request #1751 from Fr1ngg/bedolaga/fix-display-issue-for-unlimited-traffic
Fix unlimited traffic display in payment methods
2025-11-07 08:01:35 +03:00
Egor 4495658fad Fix unlimited traffic display in payment methods 2025-11-07 07:58:54 +03:00
Egor 017b758e6c Merge pull request #1750 from Fr1ngg/bedolaga/move-platega-settings-to-payments-section
Add Platega payment tests
2025-11-07 07:52:37 +03:00
Egor edff6d5102 Add tests for Platega payment flows and method parsing 2025-11-07 07:49:38 +03:00
Egor 979fbe305a Merge pull request #1749 from Fr1ngg/bedolaga/fix-trial-access-issue-for-users
Handle missing devices placeholder in trial offer message
2025-11-07 07:24:34 +03:00
Egor d1edb8f892 Fix trial offer formatting for device limit 2025-11-07 07:17:53 +03:00
Egor 0eeda6b9ac Merge pull request #1748 from Fr1ngg/bedolaga/integrate-new-payment-method-for-bot
Integrate Platega balance top-ups
2025-11-07 07:05:45 +03:00
Egor 7c31c3616e Guard Platega finalization against duplicate callbacks 2025-11-07 07:05:22 +03:00
Egor fba80b1a0d Integrate Platega balance top-ups 2025-11-07 06:52:57 +03:00
Egor 86dbb317f3 Delete redirect directory 2025-11-07 05:10:03 +03:00
Egor 7432d43b55 Update README.md 2025-11-07 03:32:54 +03:00
Egor a61fb10784 Update Dockerfile 2025-11-07 03:04:36 +03:00
Egor 8d83881cf0 Update docker-registry.yml 2025-11-07 03:04:25 +03:00
Egor d624df5f65 Update docker-hub.yml 2025-11-07 03:04:06 +03:00
Egor a96a7de784 Update README.md 2025-11-07 02:55:42 +03:00
Egor fa188e04c5 Add files via upload 2025-11-07 02:32:46 +03:00
Egor e8c95ece8d Update README.md 2025-11-07 02:24:13 +03:00
Egor bc0f4adb7a Update README.md 2025-11-07 02:23:10 +03:00
Egor 3eb5a71e8d Merge pull request #1747 from Fr1ngg/dev4
Бот на Вебхуке Без смс и регистрации
2025-11-07 01:47:53 +03:00
Egor 37c97de5d6 Update README.md 2025-11-07 01:37:42 +03:00
Egor 3624832ed8 Update README.md 2025-11-07 01:37:06 +03:00
Egor 9ee39d5f71 Update README.md 2025-11-07 01:36:17 +03:00
Egor 3a0e558b6f Update README.md 2025-11-07 01:34:05 +03:00
Egor ca083c216e Merge pull request #1746 from Fr1ngg/bedolaga/fix-notification-sending-error
Fix trial expiration notification loading for monitoring service
2025-11-07 01:12:17 +03:00
Egor 8b9c1d1cc6 Fix trial notification promo group loading 2025-11-07 01:01:33 +03:00
Egor 78830a7884 Merge pull request #1745 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode-agf2n2
Expose unified health status alongside admin API
2025-11-06 18:09:19 +03:00
Egor 9fe7d0592f Expose unified health status alongside admin API 2025-11-06 18:02:08 +03:00
Egor 6a3e3c6fc4 Merge pull request #1741 from Fr1ngg/revert-1740-bedolaga/add-telegram-webhook-and-both-mode
Revert "Restore FastAPI docs endpoints for webhook mode"
2025-11-06 17:30:04 +03:00
Egor 54bfff0ed6 Revert "Restore FastAPI docs endpoints for webhook mode" 2025-11-06 17:28:06 +03:00
Egor 6ee305cbba Merge pull request #1740 from Fr1ngg/bedolaga/add-telegram-webhook-and-both-mode
Restore FastAPI docs endpoints for webhook mode
2025-11-06 17:27:56 +03:00
Egor 340041ddae Restore FastAPI docs endpoints for webhook mode 2025-11-06 17:27:39 +03:00
Egor 8a3628a6c5 Merge pull request #1738 from Fr1ngg/dev4
Dev4
2025-11-06 13:54:27 +03:00
Egor 8ac3350ac6 Merge pull request #1735 from reshifter1/main
Проверка промокода при вводе реферального кода + фикс ебучего lazy loading
2025-11-06 13:46:52 +03:00
Pavel Stryuk a507ef9966 Промокоды на место реферального кода 2025-11-06 11:03:50 +01:00
Pavel Stryuk 113405ed71 Промокоды на место реферального кода 2025-11-06 09:26:24 +01:00
Egor 1bdf1a0f4c Update README.md 2025-11-06 09:46:33 +03:00
Egor ff7883e33c Update Dockerfile 2025-11-06 09:09:50 +03:00
Egor 9bd909c1b6 Update docker-registry.yml 2025-11-06 09:09:33 +03:00
Egor 408b4e8a4d Update docker-hub.yml 2025-11-06 09:09:21 +03:00
Egor 0ee9708b4d Merge pull request #1734 from Fr1ngg/dev4
Dev4
2025-11-06 08:45:07 +03:00
Egor 44fadf6709 Merge pull request #1733 from Fr1ngg/bedolaga/fix-high-priority-bugs-in-remnawave_service.py
Reset queued UUID mutations after rollbacks
2025-11-06 08:36:45 +03:00
Egor 7384763466 Reset pending UUID mutations on rollback 2025-11-06 08:32:21 +03:00
Egor 9b6f3b10c6 Fix UUID mutation cleanup ordering 2025-11-06 07:48:00 +03:00
Egor 0e4007bde6 Merge pull request #1728 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot-4srv7j
Restore RemnaWave UUID map after sync rollbacks
2025-11-06 07:31:11 +03:00
Egor 9fcffc7df1 Merge pull request #1729 from Fr1ngg/revert-1726-bedolaga/fix-user-visibility-issue-in-remnawave-bot
Revert "Fix RemnaWave user identifier refresh during sync"
2025-11-06 07:21:21 +03:00
Egor 8e1a88c5cd Revert "Fix RemnaWave user identifier refresh during sync" 2025-11-06 07:21:12 +03:00
Egor 7bb21c9f77 Protect RemnaWave UUID map updates during rollbacks 2025-11-06 07:20:34 +03:00
Egor 7333379fcf Merge pull request #1726 from Fr1ngg/bedolaga/fix-user-visibility-issue-in-remnawave-bot
Fix RemnaWave user identifier refresh during sync
2025-11-06 04:14:49 +03:00
Egor fc040007b4 Fix remnawave user mapping after sync 2025-11-06 04:05:04 +03:00
Egor b30a3c7961 Merge pull request #1725 from Fr1ngg/dev4
fix
2025-11-06 03:45:46 +03:00
Egor 62e18156f5 Merge pull request #1724 from Fr1ngg/main
w
2025-11-06 03:40:19 +03:00
Egor 853666af6f Merge pull request #1723 from Fr1ngg/bedolaga/fix-typeerror-for-none-connected_squads
Guard payment methods handler against missing connected squads
2025-11-06 03:39:29 +03:00
Egor 3416f7b56c Guard payment methods handler against missing connected squads 2025-11-06 03:39:12 +03:00
Egor d78d82c6ce Update main.py 2025-11-06 03:22:16 +03:00
Egor 47bd1be714 Update system_settings_service.py 2025-11-06 03:21:48 +03:00
Egor 1eec919b88 Update main.py 2025-11-06 03:17:08 +03:00
Egor 777ad52bad Update system_settings_service.py 2025-11-06 03:02:31 +03:00
Egor 5fcadb5544 Update system_settings_service.py 2025-11-06 02:48:36 +03:00
Egor 0a8af20b58 Update system_settings_service.py 2025-11-06 02:23:32 +03:00
Egor 95163a0e35 Update monitoring_service.py 2025-11-06 01:59:53 +03:00
Egor ac58466c1a Update promo_offers.py 2025-11-06 01:45:26 +03:00
Egor 5ce14dcd9b Update promo_offers.py 2025-11-06 01:39:08 +03:00
Egor 21d700e16d Add files via upload 2025-11-06 01:35:18 +03:00
Egor 5f2d4a5e04 Update poll_service.py 2025-11-06 01:30:17 +03:00
Egor 7b90d73007 Update poll_service.py 2025-11-06 01:26:18 +03:00
Egor 68c90aec44 Update poll_service.py 2025-11-06 01:22:07 +03:00
Egor 9280c9a39a Update poll_service.py 2025-11-06 01:17:49 +03:00
Egor 245e31e262 Update purchase.py 2025-11-06 01:12:27 +03:00
Egor c00d7ef4d3 Update poll_service.py 2025-11-05 22:03:38 +03:00
Egor 2a652c562b Refactor promo offer sending with asyncio semaphore
Refactor promo offer sending to use asyncio semaphore for concurrent processing and batch sending.
2025-11-05 21:52:04 +03:00
Egor 1cc31729eb Implement semaphore and batch processing for broadcasts
Refactor broadcast message sending to use asyncio semaphore for concurrency control and batch processing.
2025-11-05 21:42:03 +03:00
Egor 34e5c20c1a Update broadcast_service.py 2025-11-05 21:33:48 +03:00
Egor c8545fe725 Refactor import path for SubscriptionService 2025-11-05 21:06:18 +03:00
Egor a8339d376d Update main.py 2025-11-05 20:59:42 +03:00
Egor 47150ccbd0 Update main.py 2025-11-05 20:52:28 +03:00
Egor 0a0890d8bb Update monitoring.py 2025-11-05 20:41:59 +03:00
Egor 6bcf24e3d8 Update welcome_text.py 2025-11-05 20:34:48 +03:00
Egor 898008a1bd Update remnawave_api.py 2025-11-05 20:21:52 +03:00
Egor 09af2e1d41 Update main.py 2025-11-05 20:21:21 +03:00
Egor 045d44f451 Update app.py 2025-11-05 20:09:34 +03:00
Egor 04b2325cb9 Update pricing.py 2025-11-05 19:57:39 +03:00
Egor 504581b0f8 Merge pull request #1721 from DOFER998/c0mrade/makefile-refactor
feat(makefile): refactor structure and add auto-generated help command
2025-11-05 19:45:55 +03:00
c0mrade 0572f414d4 feat(makefile): refactor structure and add auto-generated help command 2025-11-05 19:42:00 +03:00
Egor 50736dc005 Update price_display.py 2025-11-05 19:36:38 +03:00
Egor fe4bdb6a04 Merge pull request #1720 from Fr1ngg/dev4
Update user.py
2025-11-05 18:56:24 +03:00
Egor 7d7c5f3972 Update user.py 2025-11-05 18:51:35 +03:00
Egor 131a2d18cc Merge pull request #1719 from Fr1ngg/dev4
Dev4
2025-11-05 18:26:53 +03:00
Egor 47296c5c08 Merge pull request #1718 from Fr1ngg/bedolaga/fix-user-creation-integrity-issue
Ensure create_user_no_commit flushes new users
2025-11-05 18:22:10 +03:00
Egor c15b7a63fb Flush user before returning from create_user_no_commit 2025-11-05 18:19:22 +03:00
Egor e476afe208 Merge pull request #1716 from Fr1ngg/bedolaga/fix-remnawave-sync-error
Ensure RemnaWave service exposes create_user_no_commit
2025-11-05 18:13:45 +03:00
Egor b59d422fb0 Ensure RemnaWave service exposes create_user_no_commit 2025-11-05 18:13:31 +03:00
Egor 8b105c2396 Merge pull request #1715 from Fr1ngg/dev4
Раъебочка от Паши @reshifter1
2025-11-05 18:04:21 +03:00
Egor 9b6e0b26c3 Ensure autopurchase sets paid flag for converted trials 2025-11-05 18:02:07 +03:00
Egor 4ca038e186 Merge pull request #1714 from Fr1ngg/bedolaga/keep-user_promo_groups-in-sync-on-delete
Fix promo group deletion to maintain user_promo_groups
2025-11-05 17:55:28 +03:00
Egor 3e185e7ffe Reassign promo group memberships when deleting groups 2025-11-05 17:49:47 +03:00
Egor 5e27dcdcfb Merge pull request #1712 from Fr1ngg/bedolaga/fix-sync-for-legacy-promo_group_id-qofcyl
Recalculate primary promo group when reusing auto assignment
2025-11-05 17:39:09 +03:00
Egor 8730a68b40 Recalculate primary promo group instead of forcing target 2025-11-05 17:35:09 +03:00
Egor d88f13784a Merge pull request #1708 from reshifter1/main
Пачка правок
2025-11-05 17:19:32 +03:00
Pavel Stryuk 6e471c9094 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-05 13:45:54 +01:00
reshifter1 3201b60ff2 Merge branch 'dev4' into main 2025-11-04 22:57:38 +03:00
Pavel Stryuk d30d1e2a29 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:52:17 +01:00
Pavel Stryuk bd73ad069f 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:36:01 +01:00
Egor 2186780c32 Merge pull request #1706 from Fr1ngg/dev4
Dev4
2025-11-04 16:40:27 +03:00
Egor fbb2e561c5 Merge pull request #1705 from Fr1ngg/bedolaga/fix-mulenpay-sbp-callback-error
Fix MulenPay callback lazy loading error
2025-11-04 16:39:57 +03:00
Egor c8aba42d3d Reload user after MulenPay callback commit 2025-11-04 16:33:34 +03:00
Pavel Stryuk 05d495e874 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 14:13:21 +01:00
Pavel Stryuk 427011fe41 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
2025-11-04 13:05:02 +01:00
Egor 2feacccb13 Merge pull request #1703 from Fr1ngg/dev4
fix
2025-11-04 11:40:21 +03:00
Egor dc196fb478 Optimize user promo_group and subscription access
Refactor user attribute access to avoid lazy loading.
2025-11-04 09:30:21 +03:00
Egor 71c219e6ea Update user.py 2025-11-04 09:29:46 +03:00
Egor e250812463 Update simple_subscription.py 2025-11-04 08:02:03 +03:00
Egor 477b0b26c4 Update docker-compose.yml 2025-11-04 07:53:53 +03:00
Egor 06323f7168 Enhance search functionality for telegram_id
Added error handling for converting search input to int for telegram_id.
2025-11-04 07:39:12 +03:00
Egor 6b471caa22 Merge pull request #1700 from Fr1ngg/main
w
2025-11-04 06:21:51 +03:00
Egor 6fb28dd363 Merge pull request #1697 from remnawave-contrib/ivan-nginx-tg-stars-disabled-by-default
fix: update Telegram Stars rate & disable by default in `.env`
2025-11-04 06:21:08 +03:00
Ivan.Nginx 22dcff47d1 fix: update Telegram Stars rate & disable by default in .env 2025-11-03 23:42:21 +03:00
Egor 1d9b5ea3c6 Merge pull request #1689 from Fr1ngg/7iu0vl-bedolaga/expand-bot-api-for-server-management
Prevent partial server updates when promo groups are invalid
2025-11-03 07:19:25 +03:00
Egor 08e0b3a657 Validate promo groups before updating server 2025-11-03 07:19:03 +03:00
Egor 10b80981b2 Merge pull request #1688 from Fr1ngg/revert-1687-txzghv-bedolaga/expand-bot-api-for-server-management
Revert "Fix RemnaWave panel user creation defaults"
2025-11-03 07:18:33 +03:00
Egor 348f02f34c Revert "Fix RemnaWave panel user creation defaults" 2025-11-03 07:18:24 +03:00
Egor 9e9e984f36 Merge pull request #1687 from Fr1ngg/txzghv-bedolaga/expand-bot-api-for-server-management
Fix RemnaWave panel user creation defaults
2025-11-03 07:01:38 +03:00
Egor d47ca550ae Fix RemnaWave user sync tests by restoring create_user usage 2025-11-03 06:59:29 +03:00
Egor 16f4285948 Update README.md 2025-11-03 06:09:14 +03:00
Egor 23cb6dd8d0 Merge pull request #1685 from Fr1ngg/main
w
2025-11-02 22:25:17 +03:00
Egor dfbb6b0058 Merge pull request #1686 from Fr1ngg/revert-1684-qt986v-bedolaga/fix-user-search-logic-for-large-integers
Revert "Fix user search when numeric query exceeds INT32 range"
2025-11-02 22:25:10 +03:00
Egor 62f8fdd9bb Revert "Fix user search when numeric query exceeds INT32 range" 2025-11-02 22:24:56 +03:00
Egor fc114d15a0 Merge pull request #1684 from Fr1ngg/qt986v-bedolaga/fix-user-search-logic-for-large-integers
Fix user search when numeric query exceeds INT32 range
2025-11-02 22:22:11 +03:00
Egor a9fb8a069d Fix user search when query exceeds int32 2025-11-02 22:21:50 +03:00
Egor 9c6f738382 Merge pull request #1678 from Gy9vin/main
массовая синхронизация
2025-11-02 21:09:02 +03:00
gy9vin 1a7b2eb908 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-11-02 16:08:09 +03:00
gy9vin b108cc6b60 Фикс синхронизации 2025-11-02 16:08:05 +03:00
Mikhail 92cb1e3971 Merge branch 'Fr1ngg:main' into main 2025-11-02 15:08:01 +03:00
gy9vin 005fc95433 Массовая синхронизация пользователей с ремнем! 2025-11-02 15:07:28 +03:00
Egor f09771bc1c Merge pull request #1677 from Fr1ngg/dev4
Dev4
2025-11-02 06:31:39 +03:00
Egor d5b72198d7 Merge pull request #1676 from Fr1ngg/main
w
2025-11-02 06:30:31 +03:00
Egor 991bf1161c Merge pull request #1675 from Fr1ngg/sq2b4y-bedolaga/fix
Handle repeated return-to-cart callbacks without redundant edits
2025-11-02 06:13:27 +03:00
Egor 1a955d920a Handle repeated return-to-cart callbacks without redundant edits 2025-11-02 06:13:16 +03:00
Egor d575c914a6 Merge pull request #1674 from Fr1ngg/revert-1673-i0kn8d-bedolaga/fix
Revert "Prevent duplicate edits when resuming saved subscription carts"
2025-11-02 06:12:09 +03:00
Egor 90a054e942 Revert "Prevent duplicate edits when resuming saved subscription carts" 2025-11-02 06:11:59 +03:00
Egor aac5fabbca Merge pull request #1673 from Fr1ngg/i0kn8d-bedolaga/fix
Prevent duplicate edits when resuming saved subscription carts
2025-11-02 06:08:17 +03:00
Egor 3ce5d1650b Avoid redundant edits when reopening saved subscription cart 2025-11-02 06:08:05 +03:00
Egor 24879b9cf2 Merge pull request #1672 from Fr1ngg/revert-1671-eup68i-bedolaga/fix
Revert "Fix saved subscription cart resume button when returning from balance top-up"
2025-11-02 06:07:36 +03:00
Egor 37f27df062 Revert "Fix saved subscription cart resume button when returning from balance top-up" 2025-11-02 06:07:27 +03:00
Egor b78ed6797a Merge pull request #1671 from Fr1ngg/eup68i-bedolaga/fix
Fix saved subscription cart resume button when returning from balance top-up
2025-11-02 05:33:03 +03:00
Egor f73dcd6f66 Fix saved cart resume button for subscription checkout 2025-11-02 05:32:47 +03:00
Egor 0634d01336 Merge pull request #1670 from Fr1ngg/9xjrx8-bedolaga/update-user-status-logic-in-bot
Stop disabling RemnaWave users when subscriptions expire
2025-11-02 04:52:32 +03:00
Egor b43d6a83ee Stop disabling panel users when subscriptions expire 2025-11-02 04:52:00 +03:00
Egor 93144eea0e Merge pull request #1669 from Fr1ngg/ulngpf-bedolaga/fix-user-sync-timezone-issue
Normalize RemnaWave sync times to UTC
2025-11-02 03:13:29 +03:00
Egor 7d078a58e1 Normalize RemnaWave sync times to UTC 2025-11-02 03:13:13 +03:00
Egor 224c346bc0 Merge pull request #1657 from remnawave-contrib/ivan-nginx-timezone
patch: add missed timezone parameter to .env.example
2025-11-02 03:08:21 +03:00
Egor 51e937ae0c Merge pull request #1662 from Fr1ngg/dev4
Dev4
2025-11-01 16:41:24 +03:00
Egor 04073815ca Merge pull request #1661 from Fr1ngg/main
w
2025-11-01 16:41:05 +03:00
Egor ff556ba347 Merge pull request #1660 from Fr1ngg/qrn17i-bedolaga/deactivate-active-subscriptions
Fix monitoring to only deactivate trial subscriptions after channel unsubscribe
2025-11-01 16:39:21 +03:00
Egor 2d751c238c Fix trial subscription monitoring not to disable paid plans 2025-11-01 16:39:04 +03:00
Ivan.Nginx 5d27fa2963 patch: add missed timezone parameter to .env.example 2025-11-01 09:10:39 +03:00
Ivan.Nginx fe71935d58 Merge branch 'Fr1ngg:main' into main 2025-11-01 08:59:41 +03:00
Egor d0d1b7617a Update Python version to 2.5.7 in Dockerfile 2025-11-01 06:15:32 +03:00
Egor 310990ae32 Update versioning to 2.5.7 in workflow 2025-11-01 06:15:15 +03:00
Egor b565c32e77 Update version number to 2.5.7 in workflow 2025-11-01 06:14:58 +03:00
Egor f3edb4344b Merge pull request #1655 from Fr1ngg/dev4
Фиксы
2025-11-01 05:45:50 +03:00
Egor ccdcff6a7b Merge pull request #1654 from Fr1ngg/7sqsqp-bedolaga/fix-user-deletion-errors
Handle Heleket payments when removing users
2025-11-01 05:43:19 +03:00
Egor 1d4090ea7a Handle Heleket payments on user deletion 2025-11-01 05:43:03 +03:00
Egor 68c7d35165 Merge pull request #1653 from Fr1ngg/fifw2h-bedolaga/move-configuration-sections-for-the-bot
Group timezone and payment verification settings
2025-11-01 05:28:12 +03:00
Egor 221cc05af3 Group timezone and payment verification settings 2025-11-01 05:27:56 +03:00
Egor 1524970401 Merge pull request #1652 from Fr1ngg/revert-1651-bw734r-bedolaga/move-configuration-sections-for-the-bot
Revert "Reorganize admin configuration categories"
2025-11-01 05:27:47 +03:00
Egor 32a03c5e4b Revert "Reorganize admin configuration categories" 2025-11-01 05:27:37 +03:00
Egor a03036c099 Merge pull request #1651 from Fr1ngg/bw734r-bedolaga/move-configuration-sections-for-the-bot
Reorganize admin configuration categories
2025-11-01 05:18:46 +03:00
Egor 811e8a97b3 Reorganize bot configuration categories 2025-11-01 05:18:31 +03:00
Egor c7625643c6 Merge pull request #1650 from Fr1ngg/h9h52u-bedolaga/fix-log-time-discrepancy-with-server-time
Fix subscription timestamps to use configured timezone
2025-11-01 04:54:29 +03:00
Egor 5ce87238c8 Fix subscription timestamps to use configured timezone 2025-11-01 04:54:12 +03:00
Egor 7d12a327ca Merge pull request #1649 from Fr1ngg/revert-1648-es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Revert "Improve timezone-aware logging"
2025-11-01 04:54:03 +03:00
Egor 8c93637d79 Revert "Improve timezone-aware logging" 2025-11-01 04:53:53 +03:00
Egor 2d0ea02b4b Merge pull request #1648 from Fr1ngg/es7eok-bedolaga/fix-log-time-discrepancy-with-server-time
Improve timezone-aware logging
2025-11-01 04:31:10 +03:00
Egor 98692ac3bd Improve timezone-aware logging 2025-11-01 04:30:32 +03:00
Egor 940b6b93b2 Merge pull request #1647 from Fr1ngg/d76wg3-bedolaga/modify-parameter-priority-logic-in-bot
Respect .env configuration priority
2025-11-01 02:50:43 +03:00
Egor 17768c303f Ensure env configuration takes priority over DB overrides 2025-11-01 02:50:24 +03:00
Egor 441fcd2a36 Merge pull request #1646 from Fr1ngg/dev4
Фиксы и приколы
2025-11-01 02:27:46 +03:00
Egor c2b6b4e370 Merge pull request #1644 from Fr1ngg/w96px9-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:58:47 +03:00
Egor 2f8d27b1ec Merge pull request #1645 from Fr1ngg/revert-1643-81vlci-bedolaga/add-referrals-button-to-user-management
Revert "Add admin user referrals management"
2025-11-01 01:58:37 +03:00
Egor dc32f53585 Revert "Add admin user referrals management" 2025-11-01 01:58:29 +03:00
Egor 165691412b Clear referral edit state when returning to list 2025-11-01 01:58:08 +03:00
Egor 824d4b3a23 Merge pull request #1643 from Fr1ngg/81vlci-bedolaga/add-referrals-button-to-user-management
Add admin user referrals management
2025-11-01 01:35:36 +03:00
Egor 060f0ca199 Add admin user referrals management 2025-11-01 01:35:20 +03:00
Egor ac43f17a43 Merge pull request #1642 from Fr1ngg/zvatmo-bedolaga/add-referral-percentage-for-all-deposits
Allow referral commissions on any top-up amount
2025-11-01 01:19:53 +03:00
Egor 54fc1c029c Allow referral commissions on any top-up amount 2025-11-01 01:19:31 +03:00
Egor d259bcf169 Merge pull request #1641 from Fr1ngg/revert-1640-qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Revert "Add automatic Telegram Stars rate fetching and toggle"
2025-11-01 00:56:04 +03:00
Egor da46c18210 Revert "Add automatic Telegram Stars rate fetching and toggle" 2025-11-01 00:55:55 +03:00
Egor ace7121a4e Merge pull request #1640 from Fr1ngg/qbz9v3-bedolaga/add-parsing-for-current-telegram-stars-rate
Add automatic Telegram Stars rate fetching and toggle
2025-11-01 00:26:11 +03:00
Egor 61a93f86b3 Add dynamic Telegram Stars rate with auto refresh 2025-11-01 00:25:55 +03:00
Egor 49bcd18a9a Merge pull request #1638 from Fr1ngg/4vtqjj-bedolaga/fix-500-error-in-/promo-groups-due-to-date-validation
Fix promo group timestamp serialization issues
2025-10-31 22:52:24 +03:00
Egor 7df64d8edf Fix admin notification service f-string syntax 2025-10-31 22:52:11 +03:00
Egor ca63b4d758 Merge pull request #1637 from Fr1ngg/5j1ved-bedolaga/specify-existing-parameters-for-payment-verification
Add payment verification auto-check options to env template
2025-10-31 22:31:42 +03:00
Egor 3cbb304e9b Document payment verification auto-check settings 2025-10-31 22:31:22 +03:00
Egor ef5192234a Merge pull request #1636 from Fr1ngg/dev4
Dev4
2025-10-31 22:18:24 +03:00
Egor f7bb9ac4d9 Merge pull request #1635 from Fr1ngg/main
w
2025-10-31 22:17:49 +03:00
Egor b8bb4f3c3f Merge pull request #1633 from Gy9vin/main
СРОЧНЫЙ ФИКС
2025-10-31 22:15:52 +03:00
Egor e43ff27639 Merge pull request #1634 from Fr1ngg/cw91a0-bedolaga/add-attempts-parameter-to-maintenance-check
Add configurable retry attempts for maintenance panel checks
2025-10-31 22:15:25 +03:00
Egor e02123c1ed Add configurable retry attempts for maintenance checks 2025-10-31 22:15:05 +03:00
gy9vin f26df389b4 СРОЧНЫЙ ФИКС 2025-10-31 22:13:23 +03:00
Egor 10d36a829d Merge pull request #1632 from Fr1ngg/p7wjzx-bedolaga/fix-ad-campaign-notification-bug
Fix admin notification for first advertising campaign clicks
2025-10-31 22:04:04 +03:00
Egor 61e936dd49 Fix campaign visit notifications on initial link click 2025-10-31 22:01:03 +03:00
Egor d0f9a04d5f Merge pull request #1631 from Fr1ngg/ishx2m-bedolaga/fix-maintenance-mode-persistence-issue
Add configuration flag to disable automatic maintenance monitoring
2025-10-31 21:46:26 +03:00
Egor cf6bbffd9b Respect maintenance monitoring setting 2025-10-31 21:45:58 +03:00
Egor 5ad45addfa Merge pull request #1630 from Fr1ngg/s4pdi7-bedolaga/add-placeholder-for-user-name
Add configurable template for RemnaWave panel usernames
2025-10-31 21:32:13 +03:00
Egor 2af013b75b Add template support for RemnaWave usernames 2025-10-31 21:31:54 +03:00
Egor 74bda5c986 Merge pull request #1629 from Fr1ngg/l1utns-bedolaga/fix-pal24-payment-status-error
Fix Pal24 payment status retrieval crash
2025-10-31 21:17:57 +03:00
Egor b290ccfff2 Add Pal24 bill payments proxy and tests 2025-10-31 21:17:34 +03:00
Egor 904d2883e8 Merge pull request #1628 from Fr1ngg/gvkfjc-bedolaga/fix-duplicate-messages-in-operation-history
Fix duplicate MulenPay transactions in history
2025-10-31 21:08:29 +03:00
Egor 1a54beed17 Fix duplicate MulenPay transactions in history 2025-10-31 21:07:48 +03:00
Egor 701a8d33af Merge pull request #1627 from Fr1ngg/ibnw5b-bedolaga/remove-unused-referred_user_reward-parameter
Remove referred user reward setting
2025-10-31 20:38:58 +03:00
Egor aa03384012 Remove referred user reward setting 2025-10-31 20:38:38 +03:00
Egor d346c63184 Merge pull request #1626 from Fr1ngg/dev4
fix [BUG] Неправильное отображение статуса подписки #1585
2025-10-31 19:53:27 +03:00
Egor b8005aaad7 Merge pull request #1625 from Fr1ngg/3evicu-bedolaga/fix-subscription-status-display-issue
Improve FakeSession execute stub in payment webhook tests
2025-10-31 19:43:20 +03:00
Egor bfa9490425 Fix FakeSession execute stub for payment webhook tests 2025-10-31 19:42:53 +03:00
Egor 1f4af21a07 Merge pull request #1624 from Fr1ngg/dev4
Отключение лимита устройств
2025-10-31 19:28:58 +03:00
Egor f952c0e38d Merge pull request #1623 from Fr1ngg/main
w
2025-10-31 19:27:52 +03:00
Egor d7dab5d30f Merge pull request #1622 from Fr1ngg/plexxe-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:16:07 +03:00
Egor 8407f7cdb1 Restore device management button in disabled mode 2025-10-31 19:15:48 +03:00
Egor 6c72987201 Merge pull request #1621 from Fr1ngg/revert-1620-7irv2f-bedolaga/add-option-to-disable-device-selection
Revert "Align trial offer with disabled device selection defaults"
2025-10-31 19:15:32 +03:00
Egor 81b6d3f18f Revert "Align trial offer with disabled device selection defaults" 2025-10-31 19:15:20 +03:00
Egor 95754ab224 Merge pull request #1620 from Fr1ngg/7irv2f-bedolaga/add-option-to-disable-device-selection
Align trial offer with disabled device selection defaults
2025-10-31 19:08:36 +03:00
Egor 949d964a7f Fix trial messaging and limits for disabled device selection 2025-10-31 19:08:21 +03:00
Egor 8eab79434a Merge pull request #1619 from Fr1ngg/revert-1618-rfh1ig-bedolaga/add-option-to-disable-device-selection
Revert "Restore RemnaWave HWID caps when device selection is disabled"
2025-10-31 19:08:06 +03:00
Egor 8ab2351995 Revert "Restore RemnaWave HWID caps when device selection is disabled" 2025-10-31 19:07:58 +03:00
Egor e8802dde55 Merge pull request #1618 from Fr1ngg/rfh1ig-bedolaga/add-option-to-disable-device-selection
Restore RemnaWave HWID caps when device selection is disabled
2025-10-31 18:40:10 +03:00
Egor acc5ace234 Ensure disabled device mode keeps RemnaWave caps aligned 2025-10-31 18:39:45 +03:00
Egor 7b33721305 Merge pull request #1617 from Fr1ngg/revert-1616-c6i24i-bedolaga/add-option-to-disable-device-selection
Revert "Guard disabled-mode device caps when unset"
2025-10-31 18:39:33 +03:00
Egor e94f4598b5 Revert "Guard disabled-mode device caps when unset" 2025-10-31 18:39:21 +03:00
Egor c66c8b7933 Merge pull request #1616 from Fr1ngg/c6i24i-bedolaga/add-option-to-disable-device-selection
Guard disabled-mode device caps when unset
2025-10-31 18:27:08 +03:00
Egor 0b934efa09 Guard disabled-mode device caps when unset 2025-10-31 18:26:50 +03:00
Egor 79e644f6dd Merge pull request #1615 from Fr1ngg/revert-1613-el6wtr-bedolaga/add-option-to-disable-device-selection
Revert "Align simple subscription device caps with disabled mode"
2025-10-31 18:26:39 +03:00
Egor 5c68a1aca7 Revert "Align simple subscription device caps with disabled mode" 2025-10-31 18:26:27 +03:00
Egor bde613c4bf Merge pull request #1613 from Fr1ngg/el6wtr-bedolaga/add-option-to-disable-device-selection
Align simple subscription device caps with disabled mode
2025-10-31 18:05:59 +03:00
Egor 184d8c3ba7 Merge pull request #1614 from Fr1ngg/revert-1612-9j4uf1-bedolaga/add-option-to-disable-device-selection
Revert "Add configurable device cap for disabled selection mode"
2025-10-31 18:05:49 +03:00
Egor d48221d57f Revert "Add configurable device cap for disabled selection mode" 2025-10-31 18:05:40 +03:00
Egor 657fd6d07d Align simple subscription device caps with disabled mode 2025-10-31 18:05:20 +03:00
Egor 1211cd22ad Merge pull request #1612 from Fr1ngg/9j4uf1-bedolaga/add-option-to-disable-device-selection
Add configurable device cap for disabled selection mode
2025-10-31 17:43:01 +03:00
Egor 19ed88009d Add disabled-mode device limit controls 2025-10-31 17:42:40 +03:00
Egor 0f30ee87ab Merge pull request #1611 from Fr1ngg/revert-1610-dvh9qt-bedolaga/add-option-to-disable-device-selection
Revert "Preserve saved cart metadata when normalizing devices"
2025-10-31 17:42:17 +03:00
Egor 4cc6778e45 Revert "Preserve saved cart metadata when normalizing devices" 2025-10-31 17:42:04 +03:00
Egor ab00a0d136 Merge pull request #1610 from Fr1ngg/dvh9qt-bedolaga/add-option-to-disable-device-selection
Preserve saved cart metadata when normalizing devices
2025-10-31 14:01:24 +03:00
Egor 3d4f1af9a9 Preserve saved cart metadata during normalization 2025-10-31 14:01:06 +03:00
Egor 0302176fd2 Merge pull request #1609 from Fr1ngg/revert-1608-0ol61g-bedolaga/add-option-to-disable-device-selection
Revert "Normalize saved cart devices when selection disabled"
2025-10-31 14:00:37 +03:00
Egor 8910780332 Revert "Normalize saved cart devices when selection disabled" 2025-10-31 14:00:28 +03:00
Egor 8bea64179a Merge pull request #1608 from Fr1ngg/0ol61g-bedolaga/add-option-to-disable-device-selection
Normalize saved cart devices when selection disabled
2025-10-31 13:48:02 +03:00
Egor 777f90eab1 Normalize saved cart devices when selection disabled 2025-10-31 13:47:42 +03:00
Egor ad0b0f475a Merge pull request #1607 from Fr1ngg/revert-1606-3jxg23-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:47:01 +03:00
Egor 499292eab6 Revert "Hide device limits when device selection disabled" 2025-10-31 13:46:52 +03:00
Egor 21b2f1bd2a Merge pull request #1606 from Fr1ngg/3jxg23-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 13:21:58 +03:00
Egor 3c025b536e Preserve default devices when selection disabled 2025-10-31 13:21:14 +03:00
Egor bded6fe59f Merge pull request #1605 from Fr1ngg/revert-1603-ej06m7-bedolaga/add-option-to-disable-device-selection
Revert "Hide device limits when device selection disabled"
2025-10-31 13:21:01 +03:00
Egor 344ad8f3c0 Revert "Hide device limits when device selection disabled" 2025-10-31 13:20:52 +03:00
Egor b43dd3167f Merge pull request #1603 from Fr1ngg/ej06m7-bedolaga/add-option-to-disable-device-selection
Hide device limits when device selection disabled
2025-10-31 12:51:49 +03:00
Egor 79ed9dbab2 Merge pull request #1604 from Fr1ngg/revert-1602-n5lhq1-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection during subscription checkout"
2025-10-31 12:51:41 +03:00
Egor 38ff59f71f Revert "Add toggle to disable device selection during subscription checkout" 2025-10-31 12:51:29 +03:00
Egor 65c85e146d Hide device info when device selection disabled 2025-10-31 12:50:44 +03:00
Egor 467955e7b3 Merge pull request #1602 from Fr1ngg/n5lhq1-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection during subscription checkout
2025-10-31 12:19:29 +03:00
Egor 1a4250a7ed Add toggle to disable device selection 2025-10-31 12:19:10 +03:00
Egor e181e4325b Merge pull request #1601 from Fr1ngg/revert-1600-o0l191-bedolaga/add-option-to-disable-device-selection
Revert "Add toggle to disable device selection"
2025-10-31 12:18:34 +03:00
Egor 1a35237852 Revert "Add toggle to disable device selection" 2025-10-31 12:18:26 +03:00
Egor 90baf3da52 Merge pull request #1600 from Fr1ngg/o0l191-bedolaga/add-option-to-disable-device-selection
Add toggle to disable device selection
2025-10-31 12:13:32 +03:00
Egor 348e4dd4dc Add toggle to disable device selection 2025-10-31 12:13:11 +03:00
Egor d2cd96a45c Merge pull request #1599 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 06:32:35 +03:00
Ivan.Nginx 64238e3280 Merge pull request #1 from remnawave-contrib/ivan-nginx-issue-templates
Added issue templates
2025-10-31 05:02:44 +03:00
Ivan.Nginx fcfac2df1f Added issue templates
1. Bug Template — «Bug Bedolage»
2. Teature Template — «Feat Bedolage»
2025-10-31 05:02:00 +03:00
Egor a0734a6eb4 Merge pull request #1596 from Fr1ngg/dev4
Dev4
2025-10-31 03:32:04 +03:00
Egor 0d7a235715 Merge pull request #1595 from Fr1ngg/49rs9y-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket view for users without username
2025-10-31 03:28:11 +03:00
Egor 180788bc7b Fix admin ticket view for users without username 2025-10-31 03:27:55 +03:00
Egor 922681891a Merge pull request #1594 from Fr1ngg/revert-1593-jy8fc9-bedolaga/fix-ticket-system-telegram-error
Revert "Fix admin ticket DM links to support ID-only users"
2025-10-31 03:27:47 +03:00
Egor 257c628cc8 Revert "Fix admin ticket DM links to support ID-only users" 2025-10-31 03:27:38 +03:00
Egor dcee842c92 Merge pull request #1593 from Fr1ngg/jy8fc9-bedolaga/fix-ticket-system-telegram-error
Fix admin ticket DM links to support ID-only users
2025-10-31 03:20:12 +03:00
Egor 5f02471fd5 Fix admin ticket DM links to support ID-only users 2025-10-31 03:19:54 +03:00
Egor 82f0f7ce17 Merge pull request #1575 from Fr1ngg/dev4
Dev4
2025-10-30 03:10:21 +03:00
Egor b805b7eef9 Merge pull request #1574 from Fr1ngg/3fc7wf-bedolaga/update-subscription-period-menu-pricing
Hide zero price in subscription period options
2025-10-30 03:06:34 +03:00
Egor ad03fa070a Hide zero price in subscription period options 2025-10-30 03:06:16 +03:00
Egor 9c354d971f Merge pull request #1573 from Fr1ngg/dev4
Dev4
2025-10-30 01:15:58 +03:00
Egor 5cc70c4bb8 Merge pull request #1572 from Fr1ngg/soqxxz-bedolaga/fix-duplicate-telegram-id-handling
Handle Telegram ID uniqueness during RemnaWave sync
2025-10-30 01:15:36 +03:00
Egor 9db806d387 Handle Telegram ID uniqueness during RemnaWave sync 2025-10-30 01:15:19 +03:00
Egor b7f7621d28 Merge pull request #1571 from Fr1ngg/revert-1569-hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Revert "Deduplicate RemnaWave sync users with freshest subscriptions"
2025-10-30 01:14:40 +03:00
Egor 822ac2417d Revert "Deduplicate RemnaWave sync users with freshest subscriptions" 2025-10-30 01:14:32 +03:00
Egor b3c80a2a82 Merge pull request #1570 from Fr1ngg/dev4
Dev4
2025-10-30 01:04:37 +03:00
Egor 3e9d606647 Merge pull request #1569 from Fr1ngg/hyyncd-bedolaga/fix-duplicate-telegram-id-handling
Deduplicate RemnaWave sync users with freshest subscriptions
2025-10-30 01:04:13 +03:00
Egor 0ab89f4956 Deduplicate RemnaWave sync users by telegram id 2025-10-30 01:03:58 +03:00
Egor 0090f1c409 Merge pull request #1568 from Fr1ngg/revert-1567-tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Revert "Handle duplicate RemnaWave panel users during sync"
2025-10-30 01:03:44 +03:00
Egor 1cc37a57c1 Revert "Handle duplicate RemnaWave panel users during sync" 2025-10-30 01:03:35 +03:00
Egor 7809efa556 Merge pull request #1567 from Fr1ngg/tfx50u-bedolaga/fix-duplicate-telegram-id-handling
Handle duplicate RemnaWave panel users during sync
2025-10-30 01:03:10 +03:00
Egor b4a69e3c85 Filter RemnaWave sync duplicates by latest subscription 2025-10-30 01:02:53 +03:00
Egor 67c975d9c2 Merge pull request #1566 from Fr1ngg/main
w
2025-10-30 00:55:05 +03:00
Egor c0812a7ee9 Merge pull request #1565 from Gy9vin/main
Очередной фикс юкассы
2025-10-29 11:32:10 +03:00
gy9vin 8ed755a339 Очередной фикс юкассы 2025-10-29 11:13:55 +03:00
Egor 1aae8783ca Update Dockerfile 2025-10-26 22:09:13 +03:00
Egor 5997a741fc Update docker-registry.yml 2025-10-26 22:09:01 +03:00
Egor c5d522e6b8 Update docker-hub.yml 2025-10-26 22:08:51 +03:00
Egor fa1d128d76 Merge pull request #1560 from Fr1ngg/dev4
Clear saved cart when cancelling subscription checkout
2025-10-26 21:36:44 +03:00
Egor f30516a6c2 Merge pull request #1559 from Fr1ngg/dh2k0d-bedolaga/fix-button-visibility-after-order-cancellation
Clear saved cart when cancelling subscription checkout
2025-10-26 21:32:30 +03:00
Egor 1feb0538cf Clear saved cart after subscription cancellation 2025-10-26 21:32:16 +03:00
Egor 3a791d624f Merge pull request #1558 from Fr1ngg/dev4
Dev4
2025-10-26 20:28:12 +03:00
Egor fe0f1f7641 Merge pull request #1557 from Fr1ngg/9scuix-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle YooKassa auto-purchase subscription refresh
2025-10-26 20:27:34 +03:00
Egor 212783ae3d Fix auto-purchase subscription refresh after YooKassa top-up 2025-10-26 20:26:35 +03:00
Egor 7d72fc9555 Merge pull request #1556 from Fr1ngg/revert-1554-0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle auto-purchase for subscription extensions after top-up"
2025-10-26 20:26:25 +03:00
Egor fb4df5cee1 Revert "Handle auto-purchase for subscription extensions after top-up" 2025-10-26 20:26:12 +03:00
Egor 45cd847b44 Merge pull request #1555 from Fr1ngg/dev4
Автопокупка и продление после пополнения баланса
2025-10-26 19:51:25 +03:00
Egor 98fd97dea2 Merge pull request #1554 from Fr1ngg/0ru012-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle auto-purchase for subscription extensions after top-up
2025-10-26 19:40:50 +03:00
Egor e334086ad1 Fix auto-purchase for subscription extensions 2025-10-26 19:40:31 +03:00
Egor 2e792911f1 Merge pull request #1553 from Fr1ngg/revert-1552-hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Handle simple subscription star purchase flow"
2025-10-26 19:40:00 +03:00
Egor 878b666a0b Revert "Handle simple subscription star purchase flow" 2025-10-26 19:39:51 +03:00
Egor 69c3239469 Merge pull request #1552 from Fr1ngg/hq8vy3-bedolaga/fix-subscription-auto-purchase-after-top-up
Handle simple subscription star purchase flow
2025-10-26 19:15:25 +03:00
Egor b8d48745c5 Handle simple subscription stars purchases 2025-10-26 19:15:02 +03:00
Egor 6b64e73d6e Merge pull request #1551 from Fr1ngg/revert-1550-yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Revert "Fix auto-purchase after top-up to use miniapp purchase service"
2025-10-26 19:14:21 +03:00
Egor b433a96cd6 Revert "Fix auto-purchase after top-up to use miniapp purchase service" 2025-10-26 19:14:12 +03:00
Egor 0e4c83299d Merge pull request #1550 from Fr1ngg/yp0t6x-bedolaga/fix-subscription-auto-purchase-after-top-up
Fix auto-purchase after top-up to use miniapp purchase service
2025-10-26 18:51:54 +03:00
Egor c4d1f3eccc Fix auto-purchase to use miniapp service submit 2025-10-26 18:50:13 +03:00
Egor 22dcf425cb Merge pull request #1547 from Fr1ngg/revert-1544-revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "Revert "feat: auto purchase subscription after top-up""
2025-10-26 18:27:10 +03:00
Egor edf4132c2d Revert "Revert "feat: auto purchase subscription after top-up"" 2025-10-26 18:27:01 +03:00
Egor a56e14c736 Merge pull request #1546 from Fr1ngg/revert-1545-67lhig-bedolaga/add-automatic-subscription-purchase-feature
Revert "Add automatic subscription purchase after balance top-up"
2025-10-26 18:26:41 +03:00
Egor 2e3c707745 Revert "Add automatic subscription purchase after balance top-up" 2025-10-26 18:26:32 +03:00
Egor ebd8a10fd9 Merge pull request #1545 from Fr1ngg/67lhig-bedolaga/add-automatic-subscription-purchase-feature
Add automatic subscription purchase after balance top-up
2025-10-26 18:04:30 +03:00
Egor a681a9ff1b Add automatic subscription purchase after balance top-up 2025-10-26 18:04:13 +03:00
Egor d11458c4a8 Merge pull request #1544 from Fr1ngg/revert-1543-tfkufc-bedolaga/add-automatic-subscription-purchase-feature
Revert "feat: auto purchase subscription after top-up"
2025-10-26 18:03:58 +03:00
Egor 9d6e17b609 Revert "feat: auto purchase subscription after top-up" 2025-10-26 18:03:49 +03:00
Egor cb27487021 Merge pull request #1543 from Fr1ngg/tfkufc-bedolaga/add-automatic-subscription-purchase-feature
feat: auto purchase subscription after top-up
2025-10-26 17:56:15 +03:00
Egor e1e6b40a19 feat: auto purchase subscription after top-up 2025-10-26 17:55:51 +03:00
Egor 2616292f97 Merge pull request #1542 from Fr1ngg/revert-1541-bedolaga-oew5ch
Revert "Add automatic subscription purchase after top-ups"
2025-10-26 17:39:13 +03:00
Egor 2e1dfa6638 Revert "Add automatic subscription purchase after top-ups" 2025-10-26 17:39:04 +03:00
Egor 3a730492cb Merge pull request #1541 from Fr1ngg/bedolaga-oew5ch
Add automatic subscription purchase after top-ups
2025-10-26 17:35:16 +03:00
Egor c35da4b98d Add automatic subscription purchase after top-ups 2025-10-26 17:35:01 +03:00
Egor 5e98c25020 Merge pull request #1539 from Fr1ngg/dev4
Сервис автопроверки транзакций update
2025-10-26 10:47:42 +03:00
Egor 11a4d5b436 Merge pull request #1538 from Fr1ngg/revert-1536-revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Revert "Improve Pal24 and Heleket verification coverage""
2025-10-26 10:46:48 +03:00
Egor 93c733aac0 Revert "Revert "Improve Pal24 and Heleket verification coverage"" 2025-10-26 10:46:38 +03:00
Egor 5b03e7ab8c Merge pull request #1537 from Fr1ngg/main
w
2025-10-26 10:46:24 +03:00
Egor 33912331af Merge pull request #1536 from Fr1ngg/revert-1533-4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:45:44 +03:00
Egor aacc07835e Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:45:35 +03:00
Egor 86df2a1408 Update README.md 2025-10-26 10:44:32 +03:00
Egor 018de17c5b Merge pull request #1534 from Fr1ngg/74yls6-bedolaga/fix-back-button-error-during-payment
Handle back navigation from YooKassa SBP payments
2025-10-26 10:38:11 +03:00
Egor 66afa2b924 Handle back navigation from YooKassa SBP payments 2025-10-26 10:37:49 +03:00
Egor 4ee32fe0de Merge pull request #1533 from Fr1ngg/4i58ws-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:28:57 +03:00
Egor 5b59ca3c4f Display transaction numbers in admin payment list 2025-10-26 10:28:32 +03:00
Egor 0108b0fc22 Merge pull request #1532 from Fr1ngg/revert-1531-ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Revert "Improve Pal24 and Heleket verification coverage"
2025-10-26 10:28:18 +03:00
Egor 2e575f82af Revert "Improve Pal24 and Heleket verification coverage" 2025-10-26 10:28:10 +03:00
Egor e8af09041d Merge pull request #1531 from Fr1ngg/ue9uqd-bedolaga/add-auto-check-service-for-user-deposits
Improve Pal24 and Heleket verification coverage
2025-10-26 10:15:51 +03:00
Egor 1cf041fb86 Improve Pal24 and Heleket verification coverage 2025-10-26 10:15:31 +03:00
Egor 63074cb009 Merge pull request #1529 from Fr1ngg/revert-1528-3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix CryptoBot admin status checks"
2025-10-26 09:58:22 +03:00
Egor 89b9a6e04f Revert "Fix CryptoBot admin status checks" 2025-10-26 09:58:13 +03:00
Egor 4efa344e43 Merge pull request #1528 from Fr1ngg/3wrea3-bedolaga/add-auto-check-service-for-user-deposits
Fix CryptoBot admin status checks
2025-10-26 09:47:04 +03:00
Egor 2d3d1ba5d3 Fix CryptoBot admin status checks 2025-10-26 09:46:46 +03:00
Egor 849d781e92 Merge pull request #1527 from Fr1ngg/revert-1524-utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:46:33 +03:00
Egor 48a988aa43 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:46:25 +03:00
Egor ec71f733ad Merge pull request #1526 from Fr1ngg/revert-1525-dev4
Revert "Сервис проверки платежей"
2025-10-26 09:46:04 +03:00
Egor 051121a366 Revert "Сервис проверки платежей" 2025-10-26 09:45:56 +03:00
Egor e2c2b9127c Merge pull request #1525 from Fr1ngg/dev4
Сервис проверки платежей
2025-10-26 09:32:29 +03:00
Egor fdf7263bb0 Merge pull request #1524 from Fr1ngg/utdzdq-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:25:40 +03:00
Egor c8dcde0919 Guard auto verification commits 2025-10-26 09:25:14 +03:00
Egor 9db0cd6328 Merge pull request #1523 from Fr1ngg/revert-1522-z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Revert "Guard YooKassa manual checks from duplicate processing"
2025-10-26 09:24:57 +03:00
Egor 2375bf51b3 Revert "Guard YooKassa manual checks from duplicate processing" 2025-10-26 09:24:49 +03:00
Egor 3631b49d6d Merge pull request #1522 from Fr1ngg/z30p3c-bedolaga/add-auto-check-service-for-user-deposits
Guard YooKassa manual checks from duplicate processing
2025-10-26 09:15:50 +03:00
Egor 3938b56802 Guard YooKassa manual checks from duplicate processing 2025-10-26 09:15:35 +03:00
Egor 3c3b793a9f Merge pull request #1521 from Fr1ngg/revert-1520-vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Revert "Log payment verification service activation during startup"
2025-10-26 09:09:55 +03:00
Egor 8985a89a9f Revert "Log payment verification service activation during startup" 2025-10-26 09:09:46 +03:00
Egor 5750e6a4d2 Merge pull request #1520 from Fr1ngg/vcxme6-bedolaga/add-auto-check-service-for-user-deposits
Log payment verification service activation during startup
2025-10-26 08:54:28 +03:00
Egor 8d7f73edec Log payment verification service activation 2025-10-26 08:54:05 +03:00
Egor 4853d5b19b Merge pull request #1519 from Fr1ngg/revert-1518-x45isk-bedolaga/add-auto-check-service-for-user-deposits
Revert "Prevent admin payment detail handler from handling manual check callbacks"
2025-10-26 08:52:53 +03:00
Egor 901f5fd90d Revert "Prevent admin payment detail handler from handling manual check callbacks" 2025-10-26 08:52:46 +03:00
Egor 89e3865019 Merge pull request #1518 from Fr1ngg/x45isk-bedolaga/add-auto-check-service-for-user-deposits
Prevent admin payment detail handler from handling manual check callbacks
2025-10-26 08:36:43 +03:00
Egor 538b2b52d3 Avoid admin payment detail handler catching manual checks 2025-10-26 08:36:27 +03:00
Egor f1c9df8471 Merge pull request #1517 from Fr1ngg/revert-1516-kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Revert "Show CryptoBot and Stars invoices in admin payments"
2025-10-26 08:32:39 +03:00
Egor 9e09e97ed8 Revert "Show CryptoBot and Stars invoices in admin payments" 2025-10-26 08:32:30 +03:00
Egor e50355be23 Merge pull request #1516 from Fr1ngg/kvif2x-bedolaga/add-auto-check-service-for-user-deposits
Show CryptoBot and Stars invoices in admin payments
2025-10-26 08:27:40 +03:00
Egor bd036fb4a9 Show CryptoBot and Stars invoices in admin payments 2025-10-26 08:27:22 +03:00
Egor 0181f8e5fb Merge pull request #1515 from Fr1ngg/revert-1514-vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars invoices in admin checks"
2025-10-26 08:27:12 +03:00
Egor f5eea6798a Revert "Include CryptoBot and Stars invoices in admin checks" 2025-10-26 08:27:04 +03:00
Egor 7c8d0254b2 Merge pull request #1514 from Fr1ngg/vpe5pb-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars invoices in admin checks
2025-10-26 08:26:31 +03:00
Egor 49c3c1c0c9 Include CryptoBot and Stars invoices in admin checks 2025-10-26 08:25:43 +03:00
Egor 38522442d1 Merge pull request #1513 from Fr1ngg/revert-1512-6709dc-bedolaga/add-auto-check-service-for-user-deposits
Revert "Include CryptoBot and Stars in admin payment verification"
2025-10-26 08:25:11 +03:00
Egor 0dacd79598 Revert "Include CryptoBot and Stars in admin payment verification" 2025-10-26 08:25:03 +03:00
Egor 2022654055 Merge pull request #1512 from Fr1ngg/6709dc-bedolaga/add-auto-check-service-for-user-deposits
Include CryptoBot and Stars in admin payment verification
2025-10-26 08:18:48 +03:00
Egor f3a1de6335 Include CryptoBot and Stars in admin top-up checks 2025-10-26 08:18:30 +03:00
Egor 0b5b89ea9a Merge pull request #1511 from Fr1ngg/revert-1510-k70a53-bedolaga/add-auto-check-service-for-user-deposits
Revert "Fix admin YooKassa manual check lookup"
2025-10-26 08:18:16 +03:00
Egor 8e21c34c8c Revert "Fix admin YooKassa manual check lookup" 2025-10-26 08:18:07 +03:00
Egor 400f3b01d6 Merge pull request #1510 from Fr1ngg/k70a53-bedolaga/add-auto-check-service-for-user-deposits
Fix admin YooKassa manual check lookup
2025-10-26 07:38:34 +03:00
Egor 6a0b2aabaf Add payment service wrapper for YooKassa local lookup 2025-10-26 07:38:17 +03:00
Egor 13a1ddad11 Merge pull request #1509 from Fr1ngg/revert-1508-ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up verification tools"
2025-10-26 07:37:32 +03:00
Egor ed404a0e54 Revert "Add admin top-up verification tools" 2025-10-26 07:37:15 +03:00
Egor 05acfc0733 Merge pull request #1508 from Fr1ngg/ur8a6k-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up verification tools
2025-10-26 07:24:23 +03:00
Egor 969cd6ed90 Add admin top-up verification tools 2025-10-26 07:24:03 +03:00
Egor 939c9b9b09 Merge pull request #1507 from Fr1ngg/revert-1506-6evcye-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin pending payment verification endpoints"
2025-10-26 07:23:33 +03:00
Egor 051c0db106 Revert "Add admin pending payment verification endpoints" 2025-10-26 07:23:24 +03:00
Egor e5fbe005f8 Merge pull request #1506 from Fr1ngg/6evcye-bedolaga/add-auto-check-service-for-user-deposits
Add admin pending payment verification endpoints
2025-10-26 07:20:38 +03:00
Egor 63efb31785 Add admin APIs for pending payment checks 2025-10-26 07:20:23 +03:00
Egor 32586e177b Merge pull request #1505 from Fr1ngg/revert-1504-ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Revert "Add admin top-up monitoring and manual payment checks"
2025-10-26 07:19:49 +03:00
Egor 651dba2648 Revert "Add admin top-up monitoring and manual payment checks" 2025-10-26 07:19:41 +03:00
Egor 1bbcf2a95c Merge pull request #1504 from Fr1ngg/ig0xdj-bedolaga/add-auto-check-service-for-user-deposits
Add admin top-up monitoring and manual payment checks
2025-10-26 07:16:53 +03:00
Egor 82af176018 Add admin top-up monitoring and manual payment checks 2025-10-26 07:16:35 +03:00
Egor c10a4e9f8a Merge pull request #1503 from Fr1ngg/dev4
Fix mulen/юкасса webhooks
2025-10-26 06:56:37 +03:00
Egor 9727e07464 Merge pull request #1502 from Fr1ngg/9cf17y-bedolaga/improve-webhook-handling-for-payments
Improve YooKassa webhook recovery
2025-10-26 06:52:39 +03:00
Egor b780fe2278 Improve YooKassa webhook recovery 2025-10-26 06:52:18 +03:00
Egor 947d61f2a8 Merge pull request #1501 from Fr1ngg/revert-1500-pfpi1q-bedolaga/improve-webhook-handling-for-payments
Revert "Improve MulenPay hook resilience"
2025-10-26 06:47:15 +03:00
Egor 27725ec1a7 Revert "Improve MulenPay hook resilience" 2025-10-26 06:47:08 +03:00
Egor 93a29463f4 Merge pull request #1500 from Fr1ngg/pfpi1q-bedolaga/improve-webhook-handling-for-payments
Improve MulenPay hook resilience
2025-10-26 06:07:12 +03:00
Egor 84cfcf45fa Improve MulenPay hook resilience 2025-10-26 06:04:16 +03:00
Egor 6651e59f63 Merge pull request #1499 from Fr1ngg/main
ц
2025-10-26 05:55:27 +03:00
Egor ed46b7c919 Update miniapp-setup.md 2025-10-24 13:23:23 +03:00
Egor 88cd9fbc5b Update README.md 2025-10-24 13:14:17 +03:00
Egor 46fc5827ca Merge pull request #1497 from Fr1ngg/dev4
Dev4
2025-10-24 10:05:03 +03:00
Egor 65d386f3b2 Merge pull request #1496 from Fr1ngg/0x48r9-bedolaga/fix-datetime-error-in-payment-status-check
Normalize Heleket payment timestamps before saving
2025-10-24 10:01:40 +03:00
Egor 9db640931e Normalize Heleket paid_at timestamps to UTC 2025-10-24 10:01:23 +03:00
Egor 53e4dec12d Merge pull request #1495 from Fr1ngg/dev4
w
2025-10-24 09:45:03 +03:00
Egor 174d9f9b84 Merge pull request #1494 from Fr1ngg/ur3x81-bedolaga/expand-bot-api-for-surveys
Optimize poll listing response counts
2025-10-24 09:39:57 +03:00
Egor 30ec07f7fe Avoid loading poll responses in list endpoint 2025-10-24 09:39:39 +03:00
Egor dbf1fd8085 Merge pull request #1493 from Fr1ngg/revert-1492-yc9gsm-bedolaga/expand-bot-api-for-surveys
Revert "Document poll management endpoints in admin guide"
2025-10-24 09:36:37 +03:00
Egor 32e1f231d3 Revert "Document poll management endpoints in admin guide" 2025-10-24 09:36:28 +03:00
Egor 96b788693d Merge pull request #1492 from Fr1ngg/yc9gsm-bedolaga/expand-bot-api-for-surveys
Document poll management endpoints in admin guide
2025-10-24 09:32:52 +03:00
Egor a0e00c3735 Document poll administration API 2025-10-24 09:32:38 +03:00
Egor 51e68cf215 Merge pull request #1491 from Fr1ngg/wugnye-bedolaga/expand-bot-api-for-system-logs
Expose system log through admin API
2025-10-24 09:20:48 +03:00
Egor fa3ed3af81 Add system log endpoints to admin API 2025-10-24 09:20:34 +03:00
Egor 6e72cbb5b0 Merge pull request #1490 from Fr1ngg/u14a7y-bedolaga/expand-api-for-bot-logs-visibility
feat: expose monitoring logs through web api
2025-10-24 09:08:07 +03:00
Egor 5f316f85b3 feat: expose monitoring logs through web api 2025-10-24 09:06:08 +03:00
Egor 2abd1fd9df Merge pull request #1489 from Fr1ngg/main
w
2025-10-24 08:56:20 +03:00
Egor fcb5fedf9f Update README.md 2025-10-23 13:31:36 +03:00
Egor d35c9ad87e Merge pull request #1487 from Fr1ngg/dbunp5-bedolaga/fix-double-reward-issue-in-poll-system
Prevent double payout when rewarding poll
2025-10-23 08:21:19 +03:00
Egor 0cc0368b0b Prevent double payout when rewarding poll 2025-10-23 08:17:18 +03:00
Egor 00fb154cb9 Merge pull request #1485 from Fr1ngg/main
w
2025-10-23 08:12:14 +03:00
Egor 4441cf79ff Update Dockerfile 2025-10-23 08:02:41 +03:00
Egor 7428bd5cf7 Update docker-registry.yml 2025-10-23 08:02:29 +03:00
Egor 76c524e980 Update docker-hub.yml 2025-10-23 08:02:18 +03:00
Egor 5a9fa167c2 Merge pull request #1484 from Fr1ngg/dev4
Опросы
2025-10-23 07:52:15 +03:00
Egor c61e9c78ba Merge pull request #1483 from Fr1ngg/bcb1c4-bedolaga/fix-poll-sending-error-in-bot
Refresh poll answers after recording selections
2025-10-23 07:44:34 +03:00
Egor e5e3a9e4b5 Refresh poll answers after recording selections 2025-10-23 07:44:20 +03:00
Egor a1f47e13e3 Merge pull request #1482 from Fr1ngg/revert-1481-rnz085-bedolaga/fix-poll-sending-error-in-bot
Revert "Prevent duplicate taps when answering polls"
2025-10-23 07:43:51 +03:00
Egor 7ff0ed1248 Revert "Prevent duplicate taps when answering polls" 2025-10-23 07:43:43 +03:00
Egor 5eef24fb91 Merge pull request #1481 from Fr1ngg/rnz085-bedolaga/fix-poll-sending-error-in-bot
Prevent duplicate taps when answering polls
2025-10-23 07:40:19 +03:00
Egor 05ba26d3ac Avoid redundant poll edits and acknowledge answers 2025-10-23 07:39:57 +03:00
Egor ef68ce19ce Merge pull request #1480 from Fr1ngg/revert-1479-bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Revert "Handle redundant poll message edits gracefully"
2025-10-23 07:39:30 +03:00
Egor 2fa84f6dc9 Revert "Handle redundant poll message edits gracefully" 2025-10-23 07:39:20 +03:00
Egor 32409d7471 Merge pull request #1479 from Fr1ngg/bqfm5n-bedolaga/fix-poll-sending-error-in-bot
Handle redundant poll message edits gracefully
2025-10-23 07:19:12 +03:00
Egor 270282a340 Gracefully handle poll message updates 2025-10-23 07:18:59 +03:00
Egor 87eaa06652 Merge pull request #1478 from Fr1ngg/revert-1477-9bx38j-bedolaga/fix-poll-sending-error-in-bot
Revert "Streamline poll creation prompts and skip unreachable chats"
2025-10-23 07:18:12 +03:00
Egor 845f03e873 Revert "Streamline poll creation prompts and skip unreachable chats" 2025-10-23 07:18:04 +03:00
Egor 97eabb0604 Merge pull request #1477 from Fr1ngg/9bx38j-bedolaga/fix-poll-sending-error-in-bot
Streamline poll creation prompts and skip unreachable chats
2025-10-23 07:10:27 +03:00
Egor f0d714d787 Improve poll creation prompts and poll delivery 2025-10-23 07:10:13 +03:00
Egor 27a34d6e9f Merge pull request #1476 from Fr1ngg/revert-1475-revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Stabilize poll creation UI and delivery loop"""
2025-10-23 07:10:00 +03:00
Egor a4e97e0317 Revert "Revert "Revert "Stabilize poll creation UI and delivery loop""" 2025-10-23 07:09:51 +03:00
Egor 97dcd0b7cc Merge pull request #1475 from Fr1ngg/revert-1471-revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Stabilize poll creation UI and delivery loop""
2025-10-23 06:58:40 +03:00
Egor 9b57904cf4 Revert "Revert "Stabilize poll creation UI and delivery loop"" 2025-10-23 06:58:29 +03:00
Egor 6ebdae0cae Merge pull request #1474 from Fr1ngg/revert-1469-56bjws-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation flow and poll delivery"
2025-10-23 06:57:45 +03:00
Egor b9d4a8f8e9 Revert "Stabilize poll creation flow and poll delivery" 2025-10-23 06:57:36 +03:00
Egor 707c3c5daf Merge pull request #1469 from Fr1ngg/56bjws-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation flow and poll delivery
2025-10-23 06:55:37 +03:00
Egor d075abfeea Merge pull request #1473 from Fr1ngg/revert-1472-1yvk15-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll creation wizard updates and poll delivery"
2025-10-23 06:55:14 +03:00
Egor d3399c569c Revert "Fix poll creation wizard updates and poll delivery" 2025-10-23 06:55:06 +03:00
Egor e36e3f7f8f Merge pull request #1472 from Fr1ngg/1yvk15-bedolaga/fix-poll-sending-error-in-bot
Fix poll creation wizard updates and poll delivery
2025-10-23 06:52:24 +03:00
Egor 583e01f5e6 Stabilize poll creation prompts and delivery 2025-10-23 06:51:48 +03:00
Egor 784b5e0af6 Merge pull request #1471 from Fr1ngg/revert-1470-xvg3de-bedolaga/fix-poll-sending-error-in-bot
Revert "Stabilize poll creation UI and delivery loop"
2025-10-23 06:51:28 +03:00
Egor cc42d33fbe Revert "Stabilize poll creation UI and delivery loop" 2025-10-23 06:51:19 +03:00
Egor 9c3d8fd701 Merge pull request #1470 from Fr1ngg/xvg3de-bedolaga/fix-poll-sending-error-in-bot
Stabilize poll creation UI and delivery loop
2025-10-23 06:47:12 +03:00
Egor d281eeb69d Stabilize poll creation UI and delivery loop 2025-10-23 06:46:46 +03:00
Egor b510ffb7d8 Stabilize poll creation UI and sending 2025-10-23 06:46:11 +03:00
Egor 6855b7765c Merge pull request #1468 from Fr1ngg/revert-1467-revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts"""
2025-10-23 06:45:44 +03:00
Egor 9ed1129268 Revert "Revert "Revert "Fix poll sending flow and tidy poll creation prompts""" 2025-10-23 06:45:35 +03:00
Egor 0c4ba3c0d0 Merge pull request #1467 from Fr1ngg/revert-1462-revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Revert "Fix poll sending flow and tidy poll creation prompts""
2025-10-23 06:32:55 +03:00
Egor 70337ac310 Revert "Revert "Fix poll sending flow and tidy poll creation prompts"" 2025-10-23 06:32:40 +03:00
Egor e981082713 Merge pull request #1466 from Fr1ngg/revert-1465-bh9v34-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll delivery session usage and tidy poll creation prompts"
2025-10-23 06:32:01 +03:00
Egor 104d98542e Revert "Fix poll delivery session usage and tidy poll creation prompts" 2025-10-23 06:31:20 +03:00
Egor fe63ef5fc5 Merge pull request #1465 from Fr1ngg/bh9v34-bedolaga/fix-poll-sending-error-in-bot
Fix poll delivery session usage and tidy poll creation prompts
2025-10-23 06:30:12 +03:00
Egor b1ff964dd6 Fix poll sending session access and clean poll creation chat 2025-10-23 06:29:59 +03:00
Egor aba1d5dbe5 Merge pull request #1464 from Fr1ngg/revert-1463-hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending DB session usage and clean poll creation chat"
2025-10-23 06:29:35 +03:00
Egor 0af7e4ac68 Revert "Fix poll sending DB session usage and clean poll creation chat" 2025-10-23 06:29:23 +03:00
Egor 7d9a572e6e Merge pull request #1463 from Fr1ngg/hg0g0v-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending DB session usage and clean poll creation chat
2025-10-23 06:27:12 +03:00
Egor de6557d7e7 Fix poll sending session usage and clean creation prompts 2025-10-23 06:26:59 +03:00
Egor a052c7bb80 Merge pull request #1462 from Fr1ngg/revert-1461-ahggij-bedolaga/fix-poll-sending-error-in-bot
Revert "Fix poll sending flow and tidy poll creation prompts"
2025-10-23 06:26:13 +03:00
Egor 8c47f3c49b Revert "Fix poll sending flow and tidy poll creation prompts" 2025-10-23 06:26:04 +03:00
Egor 323ad903e7 Merge pull request #1461 from Fr1ngg/ahggij-bedolaga/fix-poll-sending-error-in-bot
Fix poll sending flow and tidy poll creation prompts
2025-10-23 06:22:46 +03:00
Egor 8105fe285d Fix poll sending flow and tidy poll creation prompts 2025-10-23 06:22:31 +03:00
Egor 82a19025f1 Merge pull request #1460 from Fr1ngg/revert-1455-revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Revert "Add poll management and delivery system""
2025-10-23 06:03:49 +03:00
Egor e592b3e5c4 Revert "Revert "Add poll management and delivery system"" 2025-10-23 06:03:41 +03:00
Egor 2da12354a1 Merge pull request #1459 from Fr1ngg/revert-1458-itqeed-bedolaga/add-survey-functionality-to-bot
Revert "Fix aiogram Bot usage in poll handlers"
2025-10-23 06:03:17 +03:00
Egor fc65b62d65 Revert "Fix aiogram Bot usage in poll handlers" 2025-10-23 06:03:09 +03:00
Egor 28603a87e1 Merge pull request #1458 from Fr1ngg/itqeed-bedolaga/add-survey-functionality-to-bot
Fix aiogram Bot usage in poll handlers
2025-10-23 05:56:02 +03:00
Egor 8956aafc9a Fix poll handlers Bot type annotations 2025-10-23 05:55:48 +03:00
Egor 2ee9b263b8 Merge pull request #1457 from Fr1ngg/revert-1456-9v7qqh-bedolaga/add-survey-functionality-to-bot
Revert "Add admin-managed polls with user rewards"
2025-10-23 05:55:15 +03:00
Egor 3a5d838be3 Revert "Add admin-managed polls with user rewards" 2025-10-23 05:55:06 +03:00
Egor bf25fcfda6 Merge pull request #1456 from Fr1ngg/9v7qqh-bedolaga/add-survey-functionality-to-bot
Add admin-managed polls with user rewards
2025-10-23 05:38:12 +03:00
Egor ecdf2fcae4 feat: add admin polls with rewards and delivery 2025-10-23 05:38:01 +03:00
Egor 6fec14de4b Merge pull request #1455 from Fr1ngg/revert-1454-k4r4kv-bedolaga/add-survey-functionality-to-bot
Revert "Add poll management and delivery system"
2025-10-23 05:37:48 +03:00
Egor 7b4cbb27b7 Revert "Add poll management and delivery system" 2025-10-23 05:37:40 +03:00
Egor d7d7795257 Merge pull request #1454 from Fr1ngg/k4r4kv-bedolaga/add-survey-functionality-to-bot
Add poll management and delivery system
2025-10-23 05:28:28 +03:00
Egor 93554c7034 Add poll management and delivery system 2025-10-23 05:28:13 +03:00
Egor fbe7febdde Merge pull request #1453 from Fr1ngg/g16e9c-bedolaga/check-referral-income-on-balance-top-up
Ensure MulenPay top-ups trigger referral rewards
2025-10-23 05:05:31 +03:00
Egor f091b3ea5e Add referral rewards for MulenPay top-ups 2025-10-23 05:05:16 +03:00
Egor aae469853e Merge pull request #1452 from Fr1ngg/main
ц
2025-10-23 04:55:09 +03:00
Egor dac68ccd8c Merge pull request #1451 from Gy9vin/main
ФИКС ПО ЮКАССЕ
2025-10-23 03:13:21 +03:00
gy9vin fb140e82f6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-22 13:59:24 +03:00
gy9vin 8cac442de1 ФИКС ПРОСТОЙ ПОКУПКИ 2025-10-22 13:59:20 +03:00
Egor 8d8a465c43 Merge pull request #1450 from Gy9vin/main
фикс простой покупки кнопка всегда отображается
2025-10-22 10:01:50 +03:00
gy9vin 51f2c660e8 фикс простой покупки кнопка все отображается 2025-10-22 09:35:22 +03:00
Egor 39ecca306d Merge pull request #1449 from Fr1ngg/dev4
Dev4
2025-10-22 03:06:09 +03:00
Egor 7896a24e13 Merge pull request #1448 from Fr1ngg/beb27u-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:36:40 +03:00
Egor c21325f260 Skip unlimited traffic surcharge for simple plans 2025-10-22 02:36:23 +03:00
Egor 18df7395a2 Merge pull request #1447 from Fr1ngg/revert-1446-qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:36:12 +03:00
Egor f139251333 Revert "Fix simple subscription price calculations" 2025-10-22 02:36:04 +03:00
Egor 369e384b7d Merge pull request #1446 from Fr1ngg/qg2f9r-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:23:05 +03:00
Egor 008f62e02b Add async test runner shim and fix Pal24 adapter test 2025-10-22 02:22:50 +03:00
Egor 6e8981a245 Merge pull request #1445 from Fr1ngg/revert-1444-z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Revert "Fix simple subscription price calculations"
2025-10-22 02:22:41 +03:00
Egor bb17cf7361 Revert "Fix simple subscription price calculations" 2025-10-22 02:22:33 +03:00
Egor d13c6767e6 Merge pull request #1444 from Fr1ngg/z1xs4t-bedolaga/fix-quick-purchase-price-calculation
Fix simple subscription price calculations
2025-10-22 02:12:40 +03:00
Egor bf2ee37f33 Fix simple subscription pricing to include squad costs 2025-10-22 02:12:23 +03:00
Egor b82929ac3d Merge pull request #1443 from Fr1ngg/dev4
Dev4
2025-10-21 20:23:34 +03:00
Egor 9b0ea9bf2a Merge pull request #1442 from Fr1ngg/0opqnu-bedolaga/fix-balance-update-issue-with-heleket
Improve Heleket payment status synchronisation
2025-10-21 20:05:17 +03:00
Egor 5bde7196dc Improve Heleket payment status synchronisation 2025-10-21 20:04:59 +03:00
Egor 71c3e7fe0d Merge pull request #1441 from Fr1ngg/dev4
Dev4
2025-10-21 19:29:43 +03:00
Egor 201543c263 Merge pull request #1440 from Fr1ngg/revert-1439-revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Revert "Fix YooKassa webhook paid flag persistence""
2025-10-21 19:28:16 +03:00
Egor 5ce7417c80 Revert "Revert "Fix YooKassa webhook paid flag persistence"" 2025-10-21 19:28:01 +03:00
Egor e98564b5ab Merge pull request #1438 from Fr1ngg/main
ww
2025-10-21 19:27:13 +03:00
Egor 37dd48cc20 Merge pull request #1439 from Fr1ngg/revert-1437-tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Revert "Fix YooKassa webhook paid flag persistence"
2025-10-21 19:26:28 +03:00
Egor 9dff6e8045 Revert "Fix YooKassa webhook paid flag persistence" 2025-10-21 19:26:18 +03:00
Egor 572094050e Merge pull request #1434 from Gy9vin/main
Доработки
2025-10-21 19:25:27 +03:00
Egor 2b41337bbf Merge pull request #1437 from Fr1ngg/tnkwdf-bedolaga/fix-webhook-not-receiving-notifications
Fix YooKassa webhook paid flag persistence
2025-10-21 19:20:17 +03:00
Egor ca94c402bd Fix YooKassa webhook paid flag 2025-10-21 19:19:50 +03:00
gy9vin 6fdc46c3c6 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-21 15:24:38 +03:00
gy9vin 34fc568f97 простую покупку можно сделать с активированной подпиской добавит n дней к текущему количеству 2025-10-21 15:24:35 +03:00
Egor 38664ff853 Merge pull request #1433 from Fr1ngg/dev4
Dev4
2025-10-21 12:01:56 +03:00
Egor e44284d4ba Merge pull request #1432 from Fr1ngg/main
w
2025-10-21 12:01:10 +03:00
Egor d54dc822cc Merge pull request #1431 from Fr1ngg/uum2gm-bedolaga/fix-unresolved-resolved_squad_uuid-error
Ensure resolved squad uuid is set for simple subscription payments
2025-10-21 11:59:59 +03:00
Egor dbe9c2d5e9 Ensure resolved squad is set for simple subscription payments 2025-10-21 11:57:41 +03:00
Egor b036d090c6 Update Dockerfile 2025-10-21 11:31:44 +03:00
Egor d44b303dd3 Update docker-registry.yml 2025-10-21 11:31:34 +03:00
Egor e69bfdac20 Update docker-hub.yml 2025-10-21 11:31:20 +03:00
Egor 53f6c4e71b Merge pull request #1429 from Fr1ngg/dev4
Heleket
2025-10-21 11:22:48 +03:00
Egor c67223a332 Merge pull request #1428 from Fr1ngg/7mhcv8-bedolaga/move-heleket-to-payment-systems
Move Heleket settings into payment systems group
2025-10-21 11:21:57 +03:00
Egor 2c253382d5 Move Heleket settings into payment systems group 2025-10-21 11:21:37 +03:00
Egor 02b65cb1c9 Merge pull request #1427 from Fr1ngg/kbxpuv-bedolaga/add-heleket-balance-top-up-method
Polish Heleket payment integration
2025-10-21 11:13:09 +03:00
Egor 49ace78a68 Polish Heleket payment integration 2025-10-21 11:12:47 +03:00
Egor 0669086742 Merge pull request #1426 from Fr1ngg/k9cky4-bedolaga/add-balance-refill-method-for-heleket
Add Heleket payment provider integration
2025-10-21 10:59:36 +03:00
Egor 2f2d9bc1af Add Heleket payment provider integration 2025-10-21 10:59:19 +03:00
Egor 9b2b11ee44 Merge pull request #1425 from Fr1ngg/1n7xv6-bedolaga/update-simple-subscription-logic
Fix simple subscription start state data usage
2025-10-21 10:09:57 +03:00
Egor d318738596 Fix state data lookup during simple subscription start 2025-10-21 10:09:43 +03:00
Egor 6ae41369e9 Merge pull request #1424 from Fr1ngg/revert-1423-v20afv-bedolaga/update-simple-subscription-logic
Revert "Fix simple subscription button visibility and random squad assignment"
2025-10-21 10:09:19 +03:00
Egor ce9f2e0d41 Revert "Fix simple subscription button visibility and random squad assignment" 2025-10-21 10:09:08 +03:00
Egor aa7bd42375 Merge pull request #1423 from Fr1ngg/v20afv-bedolaga/update-simple-subscription-logic
Fix simple subscription button visibility and random squad assignment
2025-10-21 10:01:17 +03:00
Egor a6b19e4b95 Adjust simple subscription menu and squad assignment 2025-10-21 10:01:01 +03:00
Egor 7a21518ce2 Merge pull request #1422 from Fr1ngg/dev4
Dev4
2025-10-21 09:45:23 +03:00
Egor ba92f7da6c Merge pull request #1421 from Fr1ngg/52n798-bedolaga/escape-referrer-names-in-html-report
Escape referrer names in HTML report
2025-10-21 09:36:43 +03:00
Egor f62f37581f Escape referrer names in HTML report 2025-10-21 09:36:30 +03:00
Egor 0d08c2c7f0 Merge pull request #1420 from Fr1ngg/8jof80-bedolaga/fix-syntaxerror-in-purchase.py
Fix syntax issues in subscription purchase handler
2025-10-21 09:31:32 +03:00
Egor 078e59f09c Fix syntax issues in subscription purchase handler 2025-10-21 09:31:18 +03:00
Egor ab708a78a8 Merge pull request #1419 from Fr1ngg/mjma6t-bedolaga/integrate-reporting-service-code
feat: enhance reporting statistics
2025-10-21 09:26:09 +03:00
Egor 630688ecf5 feat: enhance reporting statistics 2025-10-21 09:25:53 +03:00
Egor 2b6901e87a Merge pull request #1418 from Fr1ngg/xfpbps-bedolaga/fix-button-in-subscription-renewal-section
Fix autopay day button label
2025-10-21 09:10:41 +03:00
Egor 0e43daf48b Fix autopay day button label 2025-10-21 09:10:26 +03:00
Egor e9badff1d8 Merge pull request #1417 from Fr1ngg/x4oldj-bedolaga/fix-subscription_config_back-button-behavior
Fix back navigation during subscription confirmation
2025-10-21 09:06:03 +03:00
Egor f588191931 Fix subscription back navigation restoring configuration 2025-10-21 09:05:44 +03:00
Egor e17dfbff35 Merge pull request #1415 from Fr1ngg/uq97y3-bedolaga/fix-back-button-issue-in-subscription-purchase
Handle back navigation after simple subscription QR message
2025-10-21 09:00:24 +03:00
Egor b7e9770588 Handle back navigation after simple subscription QR 2025-10-21 09:00:08 +03:00
Egor 9371524257 Merge pull request #1414 from Fr1ngg/main
w
2025-10-21 07:42:49 +03:00
Egor c1f8fe69cf Update requirements.txt 2025-10-21 00:04:51 +03:00
Egor 87e3449444 Update Dockerfile 2025-10-21 00:04:31 +03:00
Egor e8ef1bc255 Update requirements.txt 2025-10-21 00:00:50 +03:00
Egor fe3a593da0 Update requirements.txt 2025-10-20 23:58:29 +03:00
Egor bf96d85edb Update requirements.txt 2025-10-20 23:56:30 +03:00
Egor 700d718da6 Update Dockerfile 2025-10-20 23:52:48 +03:00
Egor 0c9ad3cec8 Merge pull request #1413 from Fr1ngg/revert-1295-dependabot/pip/python-dependencies-e109b03380
Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates"
2025-10-20 23:48:46 +03:00
Egor 73d789d3f7 Revert "deps(deps): bump the python-dependencies group across 1 directory with 10 updates" 2025-10-20 23:48:35 +03:00
Egor 0a09ae4290 Update requirements.txt 2025-10-20 23:44:04 +03:00
Egor 0758988b5f Update requirements.txt 2025-10-20 23:42:11 +03:00
Egor 089f668e14 Merge pull request #1295 from Fr1ngg/dependabot/pip/python-dependencies-e109b03380
deps(deps): bump the python-dependencies group across 1 directory with 10 updates
2025-10-20 23:37:14 +03:00
dependabot[bot] 1e5bf143c4 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: sqlalchemy
  dependency-version: 2.0.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: alembic
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pyyaml
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: fastapi
  dependency-version: 0.119.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: yookassa
  dependency-version: 3.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: flask
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-20 07:03:13 +00:00
Egor d1132c82f8 Merge pull request #1309 from Fr1ngg/dependabot/docker/python-3.14-slim
docker(deps): bump python from 3.13-slim to 3.14-slim
2025-10-20 00:40:11 +03:00
Egor 62545276e4 Merge pull request #1296 from Fr1ngg/dependabot/pip/aiofiles-25.1.0
deps(deps): bump aiofiles from 23.2.1 to 25.1.0
2025-10-20 00:40:01 +03:00
Egor 2878bc3c3d Merge pull request #1412 from Gy9vin/main
Локализация и перенос кнопки простая покупка в главное меню на постоя…
2025-10-20 00:38:41 +03:00
gy9vin eed0fe39e7 Фикс расположения кнопок в главном меню 2025-10-19 16:23:40 +03:00
Mikhail 6bbee5586f Merge branch 'Fr1ngg:main' into main 2025-10-19 16:09:12 +03:00
gy9vin 7ac2354197 Локализация и перенос кнопки простая покупка в главное меню на постоянку! 2025-10-19 16:08:23 +03:00
Egor 3463f08162 Merge pull request #1411 from Gy9vin/main
Фимк мули в простой подписке
2025-10-19 16:01:14 +03:00
gy9vin 4b0aaa02c7 Фимк мули в простой подписке 2025-10-19 15:54:01 +03:00
Egor a946f7b501 Merge pull request #1410 from Gy9vin/main
Фикс простой покупки
2025-10-19 15:53:44 +03:00
gy9vin 7f2e4615ee Фикс простой покупки 2025-10-19 15:52:10 +03:00
Egor 28ab7719f5 Merge pull request #1409 from Gy9vin/main
Добовление всех платежек у простую покупку
2025-10-19 15:37:01 +03:00
gy9vin 31b5101f64 Исправление локализации 2025-10-19 15:23:09 +03:00
gy9vin 62777f8995 Возможность настройки простой покупки 2025-10-19 15:10:22 +03:00
gy9vin f9cff3e169 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-10-19 13:38:28 +03:00
gy9vin 04c255d748 Добовление оставшихся платежек в простую подписку 2025-10-19 13:33:56 +03:00
dependabot[bot] aae771764e docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-18 23:42:42 +00:00
dependabot[bot] 7be8757964 deps(deps): bump aiofiles from 23.2.1 to 25.1.0
Bumps [aiofiles](https://github.com/Tinche/aiofiles) from 23.2.1 to 25.1.0.
- [Release notes](https://github.com/Tinche/aiofiles/releases)
- [Changelog](https://github.com/Tinche/aiofiles/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Tinche/aiofiles/compare/v23.2.1...v25.1.0)

---
updated-dependencies:
- dependency-name: aiofiles
  dependency-version: 25.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-13 07:22:49 +00:00
271 changed files with 59052 additions and 6367 deletions
+121 -10
View File
@@ -19,10 +19,25 @@ 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 # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -67,6 +82,14 @@ REMNAWAVE_SECRET_KEY=
# {telegram_id} — ID Telegram
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
# Шаблон имени пользователя в панели Remnawave
# Доступные плейсхолдеры аналогичны описанию выше
# {full_name} — Имя, Фамилия из Telegram
# {username} — @логин из Telegram (c @)
# {username_clean} — логин из Telegram (без @)
# {telegram_id} — ID Telegram
REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# Режим удаления пользователей из панели RemnaWave
# delete - полностью удалить пользователя из панели
# disable - только деактивировать пользователя
@@ -77,7 +100,8 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
@@ -113,9 +137,8 @@ FIXED_TRAFFIC_LIMIT_GB=100
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
SIMPLE_SUBSCRIPTION_ENABLED=true
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
@@ -146,6 +169,12 @@ TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
# Включить выбор количества устройств при покупке и продлении
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
@@ -154,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
@@ -167,8 +197,8 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
TELEGRAM_STARS_ENABLED=false
TELEGRAM_STARS_RATE_RUB=1.79
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
@@ -176,6 +206,8 @@ TRIBUTE_API_KEY=
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_HOST=0.0.0.0
# Примечание: индивидуальные *_WEBHOOK_PORT используются только для старой схемы запуска.
# В режиме единого FastAPI сервера оставьте эти значения по умолчанию.
TRIBUTE_WEBHOOK_PORT=8081
# YooKassa (https://yookassa.ru)
@@ -194,7 +226,7 @@ YOOKASSA_VAT_CODE=1
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 4 - НДС 20%/22%
# 5 - НДС 10/110
# 6 - НДС 20/120
@@ -228,7 +260,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -239,9 +270,25 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
NALOGO_RECEIPTS_ENABLED=false
NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
PAYMENT_SERVICE_NAME=Интернет-сервис
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
@@ -256,11 +303,29 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
CRYPTOBOT_TESTNET=false
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
# Эти порты используются только при ручном запуске старого aiohttp сервера.
# В новой конфигурации все вебхуки обрабатываются через порт 8080.
CRYPTOBOT_WEBHOOK_PORT=8081
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# HELEKET
HELEKET_ENABLED=false
HELEKET_MERCHANT_ID=
HELEKET_API_KEY=
HELEKET_BASE_URL=https://api.heleket.com/v1
HELEKET_DEFAULT_CURRENCY=USDT
HELEKET_DEFAULT_NETWORK=
HELEKET_INVOICE_LIFETIME=3600
HELEKET_MARKUP_PERCENT=0
HELEKET_WEBHOOK_PATH=/heleket-webhook
HELEKET_WEBHOOK_HOST=0.0.0.0
HELEKET_WEBHOOK_PORT=8086
HELEKET_CALLBACK_URL=
HELEKET_RETURN_URL=
HELEKET_SUCCESS_URL=
# MULENPAY
MULENPAY_ENABLED=false
MULENPAY_API_KEY=
@@ -294,6 +359,22 @@ PAL24_SBP_BUTTON_VISIBLE=true
# Отображать кнопку оплаты картой в PayPalych (true - отображать, false - скрывать)
PAL24_CARD_BUTTON_VISIBLE=true
# PLATEGA
PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -316,6 +397,7 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -367,14 +449,18 @@ SERVER_STATUS_ITEMS_PER_PAGE=10
MAINTENANCE_MODE=false
MAINTENANCE_CHECK_INTERVAL=30
MAINTENANCE_AUTO_ENABLE=true
MAINTENANCE_MONITORING_ENABLED=true
MAINTENANCE_RETRY_ATTEMPTS=1
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
@@ -412,3 +498,28 @@ LOG_FILE=logs/bot.log
DEBUG=false
WEBHOOK_URL=
WEBHOOK_PATH=/webhook
WEBHOOK_SECRET_TOKEN=
WEBHOOK_DROP_PENDING_UPDATES=true
WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
MINIAPP_STATIC_PATH=miniapp
+42
View File
@@ -0,0 +1,42 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем текущую проблему.
Указываем настройки из `.env` конфига (если таковые имеются):
```yml
```
## Шаги для воспроизведения
1.
2.
3.
### Фактический результат
*
*
*
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Ожидаемое поведение
Опишите, как должно работать.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+32
View File
@@ -0,0 +1,32 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
+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.5.3-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-$(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.5.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-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.5.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-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.5.3-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.5.3-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.5.3-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+3
View File
@@ -1,5 +1,6 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
!.dockerignore
@@ -11,6 +12,8 @@
!requirements.txt
!docs/
!docs/**
!migrations/
!migrations/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
+3 -3
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.5.3"
ARG VERSION="v2.9.2"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8080 8081 8082
EXPOSE 8080
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
@@ -57,6 +57,6 @@ LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.vendor="fr1ngg"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["python", "main.py"]
+25
View File
@@ -1,3 +1,28 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights
under the License will not include, and the License does not grant to
you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of
the rights granted to you under the License to provide to third parties,
for a fee or other consideration (including without limitation fees for
hosting or consulting/support services related to the Software), a
product or service whose value derives, entirely or substantially, from
the functionality of the Software.
Any license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Software: remnawave-bedolaga-telegram-bot
License: MIT
Licensor: Fr1ngg
---
MIT License
Copyright (c) 2025 Fr1ngg
+39
View File
@@ -0,0 +1,39 @@
.PHONY: up
up: ## Поднять контейнеры (detached)
@echo "🚀 Поднимаем контейнеры (detached)..."
docker compose up -d --build
.PHONY: up-follow
up-follow: ## Поднять контейнеры с логами
@echo "📡 Поднимаем контейнеры (в консоли)..."
docker compose up --build
.PHONY: down
down: ## Остановить и удалить контейнеры
@echo "🛑 Останавливаем и удаляем контейнеры..."
docker compose down
.PHONY: reload
reload: ## Перезапустить контейнеры (detached)
@$(MAKE) down
@$(MAKE) up
.PHONY: reload-follow
reload-follow: ## Перезапустить контейнеры с логами
@$(MAKE) down
@$(MAKE) up-follow
.PHONY: test
test: ## Запустить тесты
@echo "🧪 Запускаем тесты..."
pytest -v
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
+888 -562
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -136,8 +136,7 @@ logger.debug(f"Webhook payload: {webhook_data}")
# Открыть только необходимые порты
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
ufw deny 8082/tcp
ufw deny 8080/tcp # Unified FastAPI сервер доступен только из внутренней сети
```
### 📊 Мониторинг безопасности
@@ -239,7 +238,6 @@ REMNAWAVE_API_KEY=your_api_key
ADMIN_IDS=123456789,987654321
# Webhook секреты - генерируйте случайные значения
YOOKASSA_WEBHOOK_SECRET=random_secret_here
TRIBUTE_WEBHOOK_SECRET=another_random_secret
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
```
@@ -283,7 +281,7 @@ services:
# Не пробрасывайте порты наружу без необходимости
ports:
- "127.0.0.1:8081:8081" # Только localhost
- "127.0.0.1:8080:8080" # Только localhost
# Read-only root filesystem
read_only: true
+43 -7
View File
@@ -12,16 +12,28 @@ from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
start, menu, subscription, balance, promocode,
referral, support, server_status, common, tickets
start,
menu,
subscription,
balance,
promocode,
referral,
support,
server_status,
common,
tickets,
)
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,
@@ -31,10 +43,13 @@ from app.handlers.admin import (
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
contests as admin_contests,
daily_contests as admin_daily_contests,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
@@ -48,7 +63,10 @@ from app.handlers.admin import (
privacy_policy as admin_privacy_policy,
public_offer as admin_public_offer,
faq as admin_faq,
payments as admin_payments,
trials as admin_trials,
)
from app.handlers import contests as user_contests
from app.handlers.stars_payments import register_stars_handlers
from app.utils.message_patch import patch_message_methods
@@ -110,6 +128,12 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.pre_checkout_query.middleware(display_name_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
button_stats_middleware = ButtonStatsMiddleware()
dp.callback_query.middleware(button_stats_middleware)
logger.info("📊 ButtonStatsMiddleware активирован")
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
@@ -145,8 +169,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_contests.register_handlers(dp)
admin_daily_contests.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
@@ -161,18 +188,27 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_privacy_policy.register_handlers(dp)
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_contests.register_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
else:
logger.info("Мониторинг техработ отключен настройками")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
+504 -66
View File
@@ -1,12 +1,15 @@
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
from pathlib import Path
@@ -17,6 +20,11 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS = [
"joingroup",
]
USER_TAG_PATTERN = re.compile(r"^[A-Z0-9_]{1,16}$")
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
@@ -46,6 +54,7 @@ class Settings(BaseSettings):
CHANNEL_SUB_ID: Optional[str] = None
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
DATABASE_URL: Optional[str] = None
@@ -57,6 +66,8 @@ class Settings(BaseSettings):
SQLITE_PATH: str = "./data/bot.db"
LOCALES_PATH: str = "./locales"
TIMEZONE: str = Field(default_factory=lambda: os.getenv("TZ", "UTC"))
DATABASE_MODE: str = "auto"
@@ -70,6 +81,7 @@ class Settings(BaseSettings):
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
REMNAWAVE_AUTO_SYNC_ENABLED: bool = False
REMNAWAVE_AUTO_SYNC_TIMES: str = "03:00"
@@ -78,9 +90,11 @@ class Settings(BaseSettings):
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
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
TRIAL_SQUAD_UUID: Optional[str] = None
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
@@ -110,6 +124,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
@@ -120,10 +135,12 @@ class Settings(BaseSettings):
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
@@ -139,20 +156,51 @@ class Settings(BaseSettings):
REFERRAL_PROGRAM_ENABLED: bool = True
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
# Конкурсы (глобальный флаг, будет расширяться под разные типы)
CONTESTS_ENABLED: bool = False
CONTESTS_BUTTON_VISIBLE: bool = False
# Для обратной совместимости со старыми конфигами
REFERRAL_CONTESTS_ENABLED: bool = False
BLACKLIST_CHECK_ENABLED: bool = False
BLACKLIST_GITHUB_URL: Optional[str] = None
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
BLACKLIST_IGNORE_ADMINS: bool = True
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = True
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
# Настройки конструктора меню (API)
MENU_LAYOUT_ENABLED: bool = False # Включить управление меню через API
# Настройки мониторинга трафика
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"
ENABLE_AUTOPAY: bool = False
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
MAINTENANCE_MODE: bool = False
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_CHECK_INTERVAL: int = 30
MAINTENANCE_AUTO_ENABLE: bool = True
MAINTENANCE_MONITORING_ENABLED: bool = True
MAINTENANCE_RETRY_ATTEMPTS: int = 1
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
@@ -177,18 +225,28 @@ class Settings(BaseSettings):
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_HOST: str = "0.0.0.0"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
YOOKASSA_TRUSTED_PROXY_NETWORKS: str = ""
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
DISABLE_TOPUP_BUTTONS: bool = False
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: int = 1
SIMPLE_SUBSCRIPTION_TRAFFIC_GB: int = 0 # 0 означает безлимит
SIMPLE_SUBSCRIPTION_SQUAD_UUID: Optional[str] = None
SUPPORT_TOPUP_ENABLED: bool = True
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: bool = False
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
NALOGO_ENABLED: bool = False
NALOGO_RECEIPTS_ENABLED: bool = False
NALOGO_INN: Optional[str] = None
NALOGO_PASSWORD: Optional[str] = None
NALOGO_DEVICE_ID: Optional[str] = None
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
ACTIVATE_BUTTON_VISIBLE: bool = False
ACTIVATE_BUTTON_TEXT: str = "активировать"
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
@@ -206,6 +264,21 @@ class Settings(BaseSettings):
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
HELEKET_ENABLED: bool = False
HELEKET_MERCHANT_ID: Optional[str] = None
HELEKET_API_KEY: Optional[str] = None
HELEKET_BASE_URL: str = "https://api.heleket.com/v1"
HELEKET_DEFAULT_CURRENCY: str = "USDT"
HELEKET_DEFAULT_NETWORK: Optional[str] = None
HELEKET_INVOICE_LIFETIME: int = 3600
HELEKET_MARKUP_PERCENT: float = 0.0
HELEKET_WEBHOOK_PATH: str = "/heleket-webhook"
HELEKET_WEBHOOK_HOST: str = "0.0.0.0"
HELEKET_WEBHOOK_PORT: int = 8086
HELEKET_CALLBACK_URL: Optional[str] = None
HELEKET_RETURN_URL: Optional[str] = None
HELEKET_SUCCESS_URL: Optional[str] = None
MULENPAY_ENABLED: bool = False
MULENPAY_API_KEY: Optional[str] = None
MULENPAY_SECRET_KEY: Optional[str] = None
@@ -224,6 +297,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
@@ -241,6 +315,20 @@ class Settings(BaseSettings):
PAL24_SBP_BUTTON_VISIBLE: bool = True
PAL24_CARD_BUTTON_VISIBLE: bool = True
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
PLATEGA_CURRENCY: str = "RUB"
PLATEGA_ACTIVE_METHODS: str = "2,10,11,12,13"
PLATEGA_MIN_AMOUNT_KOPEKS: int = 10000
PLATEGA_MAX_AMOUNT_KOPEKS: int = 100000000
PLATEGA_WEBHOOK_PATH: str = "/platega-webhook"
PLATEGA_WEBHOOK_HOST: str = "0.0.0.0"
PLATEGA_WEBHOOK_PORT: int = 8086
WATA_ENABLED: bool = False
WATA_BASE_URL: str = "https://api.wata.pro/api/h2h"
WATA_ACCESS_TOKEN: Optional[str] = None
@@ -262,6 +350,7 @@ class Settings(BaseSettings):
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
MINIAPP_STATIC_PATH: str = "miniapp"
MINIAPP_PURCHASE_URL: str = ""
MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN"
MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN"
@@ -290,6 +379,13 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEBHOOK_SECRET_TOKEN: Optional[str] = None
WEBHOOK_DROP_PENDING_UPDATES: bool = True
WEBHOOK_MAX_QUEUE_SIZE: int = 1024
WEBHOOK_WORKERS: int = 4
WEBHOOK_ENQUEUE_TIMEOUT: float = 0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT: float = 30.0
BOT_RUN_MODE: str = "polling"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
@@ -528,6 +624,34 @@ class Settings(BaseSettings):
description = re.sub(r'\s+', ' ', description).strip()
return description
def format_remnawave_username(
self,
*,
full_name: str,
username: Optional[str],
telegram_id: int
) -> str:
template = self.REMNAWAVE_USER_USERNAME_TEMPLATE or "user_{telegram_id}"
username_clean = (username or "").lstrip("@")
full_name_value = full_name or ""
values = defaultdict(str, {
"full_name": full_name_value,
"username": username_clean,
"username_clean": username_clean,
"telegram_id": str(telegram_id),
})
raw_username = template.format_map(values).strip()
sanitized_username = re.sub(r"[^0-9A-Za-z._-]+", "_", raw_username)
sanitized_username = re.sub(r"_+", "_", sanitized_username).strip("._-")
if not sanitized_username:
sanitized_username = f"user_{telegram_id}"
return sanitized_username[:64]
@staticmethod
def parse_daily_time_list(raw_value: Optional[str]) -> List[time]:
if not raw_value:
@@ -606,17 +730,51 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def is_auto_purchase_after_topup_enabled(self) -> bool:
value = getattr(self, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", False)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
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))
@@ -659,13 +817,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:
@@ -766,14 +959,66 @@ class Settings(BaseSettings):
def is_traffic_fixed(self) -> bool:
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
def get_devices_selection_disabled_amount(self) -> Optional[int]:
raw_value = self.DEVICES_SELECTION_DISABLED_AMOUNT
if raw_value in (None, ""):
return None
try:
value = int(raw_value)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s",
raw_value,
)
return None
if value < 0:
return 0
return value
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_trial_paid_activation_enabled(self) -> bool:
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
try:
value = int(self.TRIAL_ACTIVATION_PRICE)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение TRIAL_ACTIVATION_PRICE: %s",
self.TRIAL_ACTIVATION_PRICE,
)
return 0
if value < 0:
return 0
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_nalogo_enabled(self) -> bool:
return (self.NALOGO_ENABLED and
self.NALOGO_INN is not None and
self.NALOGO_PASSWORD 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:
@@ -786,6 +1031,13 @@ class Settings(BaseSettings):
return (self.CRYPTOBOT_ENABLED and
self.CRYPTOBOT_API_TOKEN is not None)
def is_heleket_enabled(self) -> bool:
return (
self.HELEKET_ENABLED
and self.HELEKET_MERCHANT_ID is not None
and self.HELEKET_API_KEY is not None
)
def is_mulenpay_enabled(self) -> bool:
return (
self.MULENPAY_ENABLED
@@ -803,6 +1055,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
@@ -810,6 +1076,74 @@ class Settings(BaseSettings):
and self.PAL24_SHOP_ID is not None
)
def is_platega_enabled(self) -> bool:
return (
self.PLATEGA_ENABLED
and self.PLATEGA_MERCHANT_ID is not None
and self.PLATEGA_SECRET is not None
)
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return None
def get_platega_failed_url(self) -> Optional[str]:
if self.PLATEGA_FAILED_URL:
return self.PLATEGA_FAILED_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-failed"
return None
def get_platega_active_methods(self) -> List[int]:
raw_value = str(self.PLATEGA_ACTIVE_METHODS or "")
normalized = raw_value.replace(";", ",")
methods: list[int] = []
seen: set[int] = set()
for part in normalized.split(","):
part = part.strip()
if not part:
continue
try:
method_code = int(part)
except ValueError:
logger.warning("Некорректный код метода Platega: %s", part)
continue
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
methods.append(method_code)
seen.add(method_code)
if not methods:
return [2]
return methods
@staticmethod
def get_platega_method_definitions() -> Dict[int, Dict[str, str]]:
return {
2: {"name": "СБП (QR)", "title": "🏦 СБП (QR)"},
10: {"name": "Банковские карты (RUB)", "title": "💳 Карты (RUB)"},
11: {"name": "Банковские карты", "title": "💳 Банковские карты"},
12: {"name": "Международные карты", "title": "🌍 Международные карты"},
13: {"name": "Криптовалюта", "title": "🪙 Криптовалюта"},
}
def get_platega_method_display_name(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if info and info.get("name"):
return info["name"]
return f"Метод {method_code}"
def get_platega_method_display_title(self, method_code: int) -> str:
definitions = self.get_platega_method_definitions()
info = definitions.get(method_code)
if not info:
return f"Platega {method_code}"
return info.get("title") or info.get("name") or f"Platega {method_code}"
def is_wata_enabled(self) -> bool:
return (
self.WATA_ENABLED
@@ -817,6 +1151,24 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
def get_payment_verification_auto_check_interval(self) -> int:
try:
minutes = int(self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES)
except (TypeError, ValueError): # pragma: no cover - защитная проверка конфигурации
minutes = 10
if minutes <= 0:
logger.warning(
"Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.",
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
)
return 10
return minutes
def get_cryptobot_base_url(self) -> str:
if self.CRYPTOBOT_TESTNET:
return "https://testnet-pay.crypt.bot"
@@ -834,6 +1186,26 @@ class Settings(BaseSettings):
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def get_heleket_markup_percent(self) -> float:
try:
return float(self.HELEKET_MARKUP_PERCENT)
except (TypeError, ValueError):
return 0.0
def get_heleket_lifetime(self) -> int:
try:
value = int(self.HELEKET_INVOICE_LIFETIME)
except (TypeError, ValueError):
value = 3600
return max(300, min(43200, value))
def get_heleket_callback_url(self) -> Optional[str]:
if self.HELEKET_CALLBACK_URL:
return self.HELEKET_CALLBACK_URL
if self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}{self.HELEKET_WEBHOOK_PATH}"
return None
def is_happ_cryptolink_mode(self) -> bool:
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
@@ -847,6 +1219,16 @@ class Settings(BaseSettings):
return False
return self.HIDE_SUBSCRIPTION_LINK
def is_contests_enabled(self) -> bool:
if getattr(self, "CONTESTS_ENABLED", False):
return True
# legacy fallback
return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False))
def is_referral_contests_enabled(self) -> bool:
# kept for backward compatibility
return self.is_contests_enabled()
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
return template or None
@@ -878,6 +1260,13 @@ class Settings(BaseSettings):
def get_maintenance_check_interval(self) -> int:
return self.MAINTENANCE_CHECK_INTERVAL
def get_maintenance_retry_attempts(self) -> int:
try:
attempts = int(self.MAINTENANCE_RETRY_ATTEMPTS)
except (TypeError, ValueError):
attempts = 1
return max(1, attempts)
def is_base_promo_group_period_discount_enabled(self) -> bool:
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
@@ -920,6 +1309,9 @@ class Settings(BaseSettings):
def is_maintenance_auto_enable(self) -> bool:
return self.MAINTENANCE_AUTO_ENABLE
def is_maintenance_monitoring_enabled(self) -> bool:
return self.MAINTENANCE_MONITORING_ENABLED
def get_available_subscription_periods(self) -> List[int]:
try:
periods_str = self.AVAILABLE_SUBSCRIPTION_PERIODS
@@ -958,10 +1350,18 @@ class Settings(BaseSettings):
except (ValueError, AttributeError):
return [30, 90, 180]
def get_balance_payment_description(self, amount_kopeks: int) -> str:
def get_balance_payment_description(self, amount_kopeks: int, telegram_user_id: Optional[int] = None) -> str:
# Базовое описание
description = f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
# Если передан user_id, добавляем его
if telegram_user_id is not None:
description += f" (ID {telegram_user_id})"
# Формируем финальную строку по шаблону
return self.PAYMENT_BALANCE_TEMPLATE.format(
service_name=self.PAYMENT_SERVICE_NAME,
description=f"{self.PAYMENT_BALANCE_DESCRIPTION} на {self.format_price(amount_kopeks)}"
description=description
)
def get_subscription_payment_description(self, period_days: int, amount_kopeks: int) -> str:
@@ -983,7 +1383,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:
@@ -1011,34 +1411,7 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_referred_user_reward_kopeks(self) -> int:
"""Return the referred user reward normalized to kopeks.
Historically the value was stored in kopeks, however some
installations provide it in rubles. To keep backward compatibility we
treat any value greater than or equal to one thousand as already being
in kopeks (≥ 10 ₽). Smaller positive values are assumed to be provided
in rubles and therefore converted to kopeks.
"""
raw_value = getattr(self, "REFERRED_USER_REWARD", 0)
try:
value = int(raw_value)
except (TypeError, ValueError):
return 0
if value <= 0:
return 0
if value >= 1000:
return value
return value * 100
def get_referral_settings(self) -> Dict:
referred_reward_kopeks = self.get_referred_user_reward_kopeks()
return {
"program_enabled": self.is_referral_program_enabled(),
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
@@ -1046,8 +1419,6 @@ class Settings(BaseSettings):
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": referred_reward_kopeks,
"referred_user_reward_kopeks": referred_reward_kopeks,
}
def is_referral_program_enabled(self) -> bool:
@@ -1064,13 +1435,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()
@@ -1094,7 +1465,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:
@@ -1305,15 +1676,82 @@ class Settings(BaseSettings):
def is_support_contact_enabled(self) -> bool:
return self.get_support_system_mode() in {"contact", "both"}
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or "polling").strip().lower()
if mode not in {"polling", "webhook", "both"}:
return "polling"
return mode
def get_telegram_webhook_path(self) -> str:
raw_path = (self.WEBHOOK_PATH or "/webhook").strip()
if not raw_path:
raw_path = "/webhook"
if not raw_path.startswith("/"):
raw_path = "/" + raw_path
return raw_path
def get_webhook_queue_maxsize(self) -> int:
try:
size = int(self.WEBHOOK_MAX_QUEUE_SIZE)
except (TypeError, ValueError):
size = 1024
return max(1, size)
def get_webhook_worker_count(self) -> int:
try:
workers = int(self.WEBHOOK_WORKERS)
except (TypeError, ValueError):
workers = 1
return max(1, workers)
def get_webhook_enqueue_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_ENQUEUE_TIMEOUT)
except (TypeError, ValueError):
timeout = 0.0
return max(0.0, timeout)
def get_webhook_shutdown_timeout(self) -> float:
try:
timeout = float(self.WEBHOOK_WORKER_SHUTDOWN_TIMEOUT)
except (TypeError, ValueError):
timeout = 30.0
return max(1.0, timeout)
def get_telegram_webhook_url(self) -> Optional[str]:
base_url = (self.WEBHOOK_URL or "").strip()
if not base_url:
return None
path = self.get_telegram_webhook_path()
return f"{base_url.rstrip('/')}{path}"
def get_miniapp_static_path(self) -> Path:
raw_path = (self.MINIAPP_STATIC_PATH or "miniapp").strip()
if not raw_path:
raw_path = "miniapp"
return Path(raw_path)
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"extra": "ignore"
"extra": "ignore"
}
@field_validator("TIMEZONE")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except Exception as exc: # pragma: no cover - defensive validation
raise ValueError(
f"Некорректный идентификатор часового пояса: {value}"
) from exc
return value
settings = Settings()
ENV_OVERRIDE_KEYS = set(settings.model_fields_set)
_PERIOD_PRICE_FIELDS: Dict[int, str] = {
14: "PRICE_14_DAYS",
+23
View File
@@ -0,0 +1,23 @@
"""Database package exports."""
from .database import (
DatabaseManager,
batch_ops,
close_db,
db_manager,
get_db,
get_db_read_only,
get_pool_metrics,
init_db,
)
__all__ = [
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+210
View File
@@ -0,0 +1,210 @@
import logging
from datetime import datetime
from typing import List, Optional, Sequence, Tuple
from sqlalchemy import and_, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User
logger = logging.getLogger(__name__)
# Templates
async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]:
result = await db.execute(
select(ContestTemplate).where(ContestTemplate.slug == slug)
)
return result.scalar_one_or_none()
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]:
query = select(ContestTemplate).order_by(ContestTemplate.id)
if enabled_only:
query = query.where(ContestTemplate.is_enabled.is_(True))
result = await db.execute(query)
return list(result.scalars().all())
async def upsert_template(
db: AsyncSession,
*,
slug: str,
name: str,
description: str = "",
prize_days: int = 1,
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
schedule_times: Optional[str] = None,
cooldown_hours: int = 24,
payload: Optional[dict] = None,
is_enabled: Optional[bool] = None,
) -> ContestTemplate:
template = await get_template_by_slug(db, slug)
if not template:
template = ContestTemplate(slug=slug)
db.add(template)
template.name = name
template.description = description
template.prize_days = prize_days
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
template.schedule_times = schedule_times
template.cooldown_hours = cooldown_hours
template.payload = payload or {}
if is_enabled is not None:
template.is_enabled = is_enabled
await db.commit()
await db.refresh(template)
return template
async def update_template_fields(
db: AsyncSession,
template: ContestTemplate,
**fields: object,
) -> ContestTemplate:
for key, value in fields.items():
if hasattr(template, key):
setattr(template, key, value)
await db.commit()
await db.refresh(template)
return template
# Rounds
async def create_round(
db: AsyncSession,
*,
template: ContestTemplate,
starts_at: datetime,
ends_at: datetime,
payload: dict,
) -> ContestRound:
round_obj = ContestRound(
template_id=template.id,
starts_at=starts_at,
ends_at=ends_at,
status="active",
payload=payload,
max_winners=template.max_winners,
attempts_per_user=template.attempts_per_user,
)
db.add(round_obj)
await db.commit()
await db.refresh(round_obj)
return round_obj
async def get_active_rounds(db: AsyncSession) -> List[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.status == "active",
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(ContestRound.starts_at)
)
return list(result.scalars().all())
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.template_id == template_id,
ContestRound.status == "active",
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(desc(ContestRound.starts_at))
)
return result.scalars().first()
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.status = "finished"
await db.commit()
await db.refresh(round_obj)
return round_obj
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.winners_count += 1
await db.commit()
await db.refresh(round_obj)
return round_obj
# Attempts
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]:
result = await db.execute(
select(ContestAttempt).where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.user_id == user_id,
)
)
)
return result.scalar_one_or_none()
async def create_attempt(
db: AsyncSession,
*,
round_id: int,
user_id: int,
answer: Optional[str],
is_winner: bool,
) -> ContestAttempt:
attempt = ContestAttempt(
round_id=round_id,
user_id=user_id,
answer=answer,
is_winner=is_winner,
)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
return attempt
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
await db.commit()
return deleted_count
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]:
result = await db.execute(
select(User, ContestAttempt)
.join(ContestAttempt, ContestAttempt.user_id == User.id)
.where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.is_winner.is_(True),
)
)
)
return result.all()
+176
View File
@@ -0,0 +1,176 @@
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
async def create_heleket_payment(
db: AsyncSession,
*,
user_id: int,
uuid: str,
order_id: str,
amount: str,
currency: str,
status: str,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
uuid=uuid,
order_id=order_id,
amount=amount,
currency=currency,
status=status,
payer_amount=payer_amount,
payer_currency=payer_currency,
exchange_rate=exchange_rate,
discount_percent=discount_percent,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=metadata or {},
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
currency,
user_id,
)
return payment
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Heleket платеж с uuid=%s не найден", uuid)
return None
if status is not None:
payment.status = status
if payer_amount is not None:
payment.payer_amount = payer_amount
if payer_currency is not None:
payment.payer_currency = payer_currency
if exchange_rate is not None:
payment.exchange_rate = exchange_rate
if discount_percent is not None:
payment.discount_percent = discount_percent
if payment_url is not None:
payment.payment_url = payment_url
if metadata is not None:
existing = dict(payment.metadata_json or {})
existing.update(metadata)
payment.metadata_json = existing
if paid_at is not None:
payment.paid_at = paid_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
payment.payer_currency,
)
return payment
async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
return payment
+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
+156
View File
@@ -0,0 +1,156 @@
"""CRUD-операции для платежей Platega."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
async def create_platega_payment(
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
currency: str,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
payment_method_code=payment_method_code,
correlation_id=correlation_id,
platega_transaction_id=platega_transaction_id,
redirect_url=redirect_url,
return_url=return_url,
failed_url=failed_url,
payload=payload,
metadata_json=metadata or {},
expires_at=expires_at,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
user_id,
)
return payment
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
if is_paid is not None:
payment.is_paid = is_paid
if paid_at is not None:
payment.paid_at = paid_at
if platega_transaction_id and not payment.platega_transaction_id:
payment.platega_transaction_id = platega_transaction_id
if redirect_url is not None:
payment.redirect_url = redirect_url
if callback_payload is not None:
payment.callback_payload = callback_payload
if metadata is not None:
payment.metadata_json = metadata
if expires_at is not None:
payment.expires_at = expires_at
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_platega_payment_to_transaction(
db: AsyncSession,
*,
payment: PlategaPayment,
transaction_id: int,
) -> PlategaPayment:
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
+297
View File
@@ -0,0 +1,297 @@
import logging
from typing import Iterable, Sequence
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
)
logger = logging.getLogger(__name__)
async def create_poll(
db: AsyncSession,
*,
title: str,
description: str | None,
reward_enabled: bool,
reward_amount_kopeks: int,
created_by: int | None,
questions: Sequence[dict[str, Iterable[str]]],
) -> Poll:
poll = Poll(
title=title,
description=description,
reward_enabled=reward_enabled,
reward_amount_kopeks=reward_amount_kopeks if reward_enabled else 0,
created_by=created_by,
)
db.add(poll)
await db.flush()
for order, question_data in enumerate(questions, start=1):
question_text = question_data.get("text", "").strip()
if not question_text:
continue
question = PollQuestion(
poll_id=poll.id,
text=question_text,
order=order,
)
db.add(question)
await db.flush()
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
option_text = option_text.strip()
if not option_text:
continue
option = PollOption(
question_id=question.id,
text=option_text,
order=option_order,
)
db.add(option)
await db.commit()
await db.refresh(
poll,
attribute_names=["questions"],
)
return poll
async def list_polls(db: AsyncSession) -> list[Poll]:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
)
.order_by(Poll.created_at.desc())
)
return result.scalars().all()
async def get_poll_by_id(db: AsyncSession, poll_id: int) -> Poll | None:
result = await db.execute(
select(Poll)
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options)),
selectinload(Poll.responses),
)
.where(Poll.id == poll_id)
)
return result.scalar_one_or_none()
async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
poll = await db.get(Poll, poll_id)
if not poll:
return False
await db.delete(poll)
await db.commit()
logger.info("🗑️ Удалён опрос %s", poll_id)
return True
async def create_poll_response(
db: AsyncSession,
poll_id: int,
user_id: int,
) -> PollResponse:
result = await db.execute(
select(PollResponse)
.where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_id,
)
)
)
response = result.scalar_one_or_none()
if response:
return response
response = PollResponse(
poll_id=poll_id,
user_id=user_id,
)
db.add(response)
await db.commit()
await db.refresh(response)
return response
async def get_poll_response_by_id(
db: AsyncSession,
response_id: int,
) -> PollResponse | None:
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
selectinload(PollResponse.answers),
selectinload(PollResponse.user),
)
.where(PollResponse.id == response_id)
)
return result.scalar_one_or_none()
async def record_poll_answer(
db: AsyncSession,
*,
response_id: int,
question_id: int,
option_id: int,
) -> PollAnswer:
result = await db.execute(
select(PollAnswer)
.where(
and_(
PollAnswer.response_id == response_id,
PollAnswer.question_id == question_id,
)
)
)
answer = result.scalar_one_or_none()
if answer:
answer.option_id = option_id
await db.commit()
await db.refresh(answer)
return answer
answer = PollAnswer(
response_id=response_id,
question_id=question_id,
option_id=option_id,
)
db.add(answer)
await db.commit()
await db.refresh(answer)
return answer
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
await db.execute(
delete(PollAnswer).where(PollAnswer.response_id == response_id)
)
await db.commit()
async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
totals_result = await db.execute(
select(
func.count(PollResponse.id),
func.count(PollResponse.completed_at),
func.coalesce(func.sum(PollResponse.reward_amount_kopeks), 0),
).where(PollResponse.poll_id == poll_id)
)
total_responses, completed_responses, reward_sum = totals_result.one()
option_counts_result = await db.execute(
select(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
func.count(PollAnswer.id),
)
.join(PollOption, PollOption.question_id == PollQuestion.id)
.outerjoin(
PollAnswer,
and_(
PollAnswer.question_id == PollQuestion.id,
PollAnswer.option_id == PollOption.id,
),
)
.where(PollQuestion.poll_id == poll_id)
.group_by(
PollQuestion.id,
PollQuestion.text,
PollQuestion.order,
PollOption.id,
PollOption.text,
PollOption.order,
)
.order_by(PollQuestion.order.asc(), PollOption.order.asc())
)
questions_map: dict[int, dict] = {}
for (
question_id,
question_text,
question_order,
option_id,
option_text,
option_order,
answer_count,
) in option_counts_result:
question_entry = questions_map.setdefault(
question_id,
{
"id": question_id,
"text": question_text,
"order": question_order,
"options": [],
},
)
question_entry["options"].append(
{
"id": option_id,
"text": option_text,
"count": answer_count,
}
)
questions = sorted(questions_map.values(), key=lambda item: item["order"])
return {
"total_responses": total_responses,
"completed_responses": completed_responses,
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
async def get_poll_responses_with_answers(
db: AsyncSession,
poll_id: int,
*,
limit: int,
offset: int,
) -> tuple[list[PollResponse], int]:
total_result = await db.execute(
select(func.count()).select_from(PollResponse).where(PollResponse.poll_id == poll_id)
)
total = int(total_result.scalar_one() or 0)
if total == 0:
return [], 0
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.user),
selectinload(PollResponse.answers).selectinload(PollAnswer.question),
selectinload(PollResponse.answers).selectinload(PollAnswer.option),
)
.where(PollResponse.poll_id == poll_id)
.order_by(PollResponse.sent_at.asc())
.offset(offset)
.limit(limit)
)
responses = result.scalars().unique().all()
return responses, total
+39 -3
View File
@@ -1,11 +1,11 @@
import logging
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Set, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User
from app.database.models import PromoGroup, User, UserPromoGroup
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
@@ -38,7 +38,7 @@ async def get_promo_groups_with_counts(
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
.order_by(PromoGroup.priority.desc(), PromoGroup.name)
)
if offset:
@@ -89,6 +89,7 @@ async def create_promo_group(
db: AsyncSession,
name: str,
*,
priority: int = 0,
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
@@ -110,6 +111,7 @@ async def create_promo_group(
promo_group = PromoGroup(
name=name.strip(),
priority=max(0, priority),
server_discount_percent=max(0, min(100, server_discount_percent)),
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
device_discount_percent=max(0, min(100, device_discount_percent)),
@@ -152,6 +154,7 @@ async def update_promo_group(
group: PromoGroup,
*,
name: Optional[str] = None,
priority: Optional[int] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
@@ -162,6 +165,8 @@ async def update_promo_group(
) -> PromoGroup:
if name is not None:
group.name = name.strip()
if priority is not None:
group.priority = max(0, priority)
if server_discount_percent is not None:
group.server_discount_percent = max(0, min(100, server_discount_percent))
if traffic_discount_percent is not None:
@@ -228,11 +233,42 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
return False
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: Set[int] = set()
user_ids_result = await db.execute(
select(User.id).where(User.promo_group_id == group.id)
)
affected_user_ids.update(user_ids_result.scalars().all())
promo_group_links_result = await db.execute(
select(UserPromoGroup.user_id).where(UserPromoGroup.promo_group_id == group.id)
)
affected_user_ids.update(promo_group_links_result.scalars().all())
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
if affected_user_ids:
existing_defaults_result = await db.execute(
select(UserPromoGroup.user_id)
.where(UserPromoGroup.promo_group_id == default_group.id)
.where(UserPromoGroup.user_id.in_(affected_user_ids))
)
existing_default_user_ids = set(existing_defaults_result.scalars().all())
for user_id in affected_user_ids - existing_default_user_ids:
db.add(
UserPromoGroup(
user_id=user_id,
promo_group_id=default_group.id,
assigned_by="system",
)
)
await db.delete(group)
await db.commit()
+58 -12
View File
@@ -13,12 +13,50 @@ logger = logging.getLogger(__name__)
async def get_promocode_by_code(db: AsyncSession, code: str) -> Optional[PromoCode]:
result = await db.execute(
select(PromoCode)
.options(selectinload(PromoCode.uses))
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.code == code.upper())
)
return result.scalar_one_or_none()
async def get_promocode_by_id(db: AsyncSession, promo_id: int) -> Optional[PromoCode]:
"""
Получает промокод по ID с eager loading всех связанных данных.
Используется для избежания lazy loading в async контексте.
"""
result = await db.execute(
select(PromoCode)
.options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
.where(PromoCode.id == promo_id)
)
return result.scalar_one_or_none()
async def check_promocode_validity(db: AsyncSession, code: str) -> dict:
"""
Проверяет существование и валидность промокода без активации.
Возвращает словарь с информацией о промокоде.
"""
promocode = await get_promocode_by_code(db, code)
if not promocode:
return {"valid": False, "error": "not_found", "promocode": None}
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {"valid": False, "error": "used", "promocode": None}
else:
return {"valid": False, "error": "expired", "promocode": None}
return {"valid": True, "error": None, "promocode": promocode}
async def create_promocode(
db: AsyncSession,
code: str,
@@ -27,7 +65,8 @@ async def create_promocode(
subscription_days: int = 0,
max_uses: int = 1,
valid_until: Optional[datetime] = None,
created_by: Optional[int] = None
created_by: Optional[int] = None,
promo_group_id: Optional[int] = None
) -> PromoCode:
promocode = PromoCode(
@@ -37,14 +76,18 @@ async def create_promocode(
subscription_days=subscription_days,
max_uses=max_uses,
valid_until=valid_until,
created_by=created_by
created_by=created_by,
promo_group_id=promo_group_id
)
db.add(promocode)
await db.commit()
await db.refresh(promocode)
logger.info(f"✅ Создан промокод: {code}")
if promo_group_id:
logger.info(f"✅ Создан промокод: {code} с промогруппой ID {promo_group_id}")
else:
logger.info(f"✅ Создан промокод: {code}")
return promocode
@@ -53,9 +96,9 @@ async def use_promocode(
promocode_id: int,
user_id: int
) -> bool:
try:
promocode = await db.get(PromoCode, promocode_id)
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
return False
@@ -143,14 +186,17 @@ async def get_promocodes_list(
limit: int = 50,
is_active: Optional[bool] = None
) -> List[PromoCode]:
query = select(PromoCode).options(selectinload(PromoCode.uses))
query = select(PromoCode).options(
selectinload(PromoCode.uses),
selectinload(PromoCode.promo_group)
)
if is_active is not None:
query = query.where(PromoCode.is_active == is_active)
query = query.order_by(PromoCode.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
+299
View File
@@ -0,0 +1,299 @@
import logging
from datetime import datetime, date, time, timezone
from typing import List, Optional, Sequence, Tuple
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, aliased
from app.database.models import (
ReferralContest,
ReferralContestEvent,
User,
Transaction,
TransactionType,
)
logger = logging.getLogger(__name__)
async def create_referral_contest(
db: AsyncSession,
*,
title: str,
description: Optional[str],
prize_text: Optional[str],
contest_type: str,
start_at: datetime,
end_at: datetime,
daily_summary_time: time,
daily_summary_times: Optional[str] = None,
timezone_name: str,
created_by: Optional[int] = None,
) -> ReferralContest:
contest = ReferralContest(
title=title,
description=description,
prize_text=prize_text,
contest_type=contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=daily_summary_time,
daily_summary_times=daily_summary_times,
timezone=timezone_name or "UTC",
created_by=created_by,
)
db.add(contest)
await db.commit()
await db.refresh(contest)
return contest
async def list_referral_contests(
db: AsyncSession,
*,
limit: int = 10,
offset: int = 0,
contest_type: Optional[str] = None,
) -> List[ReferralContest]:
query = (
select(ReferralContest)
.options(selectinload(ReferralContest.creator))
.order_by(desc(ReferralContest.start_at))
.offset(offset)
.limit(limit)
)
if contest_type:
query = query.where(ReferralContest.contest_type == contest_type)
result = await db.execute(query)
return list(result.scalars().all())
async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int:
query = select(func.count(ReferralContest.id))
if contest_type:
query = query.where(ReferralContest.contest_type == contest_type)
result = await db.execute(query)
return int(result.scalar_one())
async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]:
result = await db.execute(
select(ReferralContest)
.options(
selectinload(ReferralContest.creator),
selectinload(ReferralContest.events),
)
.where(ReferralContest.id == contest_id)
)
return result.scalar_one_or_none()
async def update_referral_contest(
db: AsyncSession,
contest: ReferralContest,
**fields: object,
) -> ReferralContest:
for key, value in fields.items():
if hasattr(contest, key):
setattr(contest, key, value)
await db.commit()
await db.refresh(contest)
return contest
async def toggle_referral_contest(
db: AsyncSession,
contest: ReferralContest,
is_active: bool,
) -> ReferralContest:
contest.is_active = is_active
return await update_referral_contest(db, contest)
async def get_contests_for_events(
db: AsyncSession,
now_utc: datetime,
*,
contest_types: Optional[List[str]] = None,
) -> List[ReferralContest]:
query = select(ReferralContest).where(
and_(
ReferralContest.is_active.is_(True),
ReferralContest.start_at <= now_utc,
ReferralContest.end_at >= now_utc,
)
)
if contest_types:
query = query.where(ReferralContest.contest_type.in_(contest_types))
result = await db.execute(query)
return list(result.scalars().all())
async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]:
result = await db.execute(
select(ReferralContest).where(ReferralContest.is_active.is_(True))
)
return list(result.scalars().all())
async def add_contest_event(
db: AsyncSession,
*,
contest_id: int,
referrer_id: int,
referral_id: int,
amount_kopeks: int = 0,
event_type: str = "subscription_purchase",
) -> Optional[ReferralContestEvent]:
existing = await db.execute(
select(ReferralContestEvent).where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.referral_id == referral_id,
)
)
)
if existing.scalar_one_or_none():
return None
event = ReferralContestEvent(
contest_id=contest_id,
referrer_id=referrer_id,
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.utcnow(),
)
db.add(event)
await db.commit()
await db.refresh(event)
return event
async def get_contest_leaderboard(
db: AsyncSession,
contest_id: int,
*,
limit: Optional[int] = None,
) -> Sequence[Tuple[User, int, int]]:
contest = await get_referral_contest(db, contest_id)
if not contest:
return []
query = (
select(
User,
func.count(ReferralContestEvent.id).label("referral_count"),
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
)
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.group_by(User.id)
.order_by(desc("referral_count"), desc("total_amount"), User.id)
)
if limit:
query = query.limit(limit)
result = await db.execute(query)
leaderboard = result.all()
return leaderboard
async def get_contest_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[Tuple[User, int]]:
result = await db.execute(
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.group_by(User.id)
)
return result.all()
async def get_referrer_score(
db: AsyncSession,
contest_id: int,
referrer_id: int,
*,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
query = select(func.count(ReferralContestEvent.id)).where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.referrer_id == referrer_id,
)
)
if start:
query = query.where(ReferralContestEvent.occurred_at >= start)
if end:
query = query.where(ReferralContestEvent.occurred_at < end)
result = await db.execute(query)
return int(result.scalar_one())
async def get_contest_events_count(
db: AsyncSession,
contest_id: int,
*,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
) -> int:
query = select(func.count(ReferralContestEvent.id)).where(
ReferralContestEvent.contest_id == contest_id
)
if start:
query = query.where(ReferralContestEvent.occurred_at >= start)
if end:
query = query.where(ReferralContestEvent.occurred_at < end)
result = await db.execute(query)
return int(result.scalar_one())
async def get_contest_events(
db: AsyncSession,
contest_id: int,
) -> List[ReferralContestEvent]:
result = await db.execute(
select(ReferralContestEvent).where(ReferralContestEvent.contest_id == contest_id)
)
return list(result.scalars().all())
async def mark_daily_summary_sent(
db: AsyncSession,
contest: ReferralContest,
summary_date: date,
summary_dt_utc: Optional[datetime] = None,
) -> ReferralContest:
contest.last_daily_summary_date = summary_date
if summary_dt_utc:
contest.last_daily_summary_at = summary_dt_utc
await db.commit()
await db.refresh(contest)
return contest
async def mark_final_summary_sent(
db: AsyncSession,
contest: ReferralContest,
) -> ReferralContest:
contest.final_summary_sent = True
contest.is_active = False
await db.commit()
await db.refresh(contest)
return contest
async def delete_referral_contest(
db: AsyncSession,
contest: ReferralContest,
) -> None:
await db.delete(contest)
await db.commit()
+64 -4
View File
@@ -47,6 +47,7 @@ async def create_server_squad(
max_users: int = None,
is_available: bool = True,
is_trial_eligible: bool = False,
sort_order: int = 0,
promo_group_ids: Optional[Iterable[int]] = None,
) -> ServerSquad:
@@ -80,6 +81,7 @@ async def create_server_squad(
max_users=max_users,
is_available=is_available,
is_trial_eligible=is_trial_eligible,
sort_order=sort_order,
allowed_promo_groups=promo_groups,
)
@@ -167,6 +169,58 @@ async def get_available_server_squads(
return result.scalars().unique().all()
async def get_active_server_squads(db: AsyncSession) -> List[ServerSquad]:
"""Возвращает список активных серверов, доступных для подключения."""
squads = await get_available_server_squads(db)
if not squads:
return []
eligible: List[ServerSquad] = []
for squad in squads:
max_users = squad.max_users
current_users = squad.current_users or 0
if max_users is not None and current_users >= max_users:
continue
eligible.append(squad)
if eligible:
return eligible
return squads
async def choose_random_active_server_squad(
db: AsyncSession,
) -> Optional[ServerSquad]:
"""Возвращает случайный активный сервер."""
squads = await get_active_server_squads(db)
if not squads:
return None
return random.choice(squads)
async def get_random_active_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
"""Возвращает UUID случайного активного сервера или запасной UUID."""
squad = await choose_random_active_server_squad(db)
if squad:
return squad.squad_uuid
return fallback_uuid
async def update_server_squad_promo_groups(
db: AsyncSession, server_id: int, promo_group_ids: Iterable[int]
) -> Optional[ServerSquad]:
@@ -208,8 +262,15 @@ async def update_server_squad(
) -> Optional[ServerSquad]:
valid_fields = {
'display_name', 'country_code', 'price_kopeks', 'description',
'max_users', 'is_available', 'sort_order', 'is_trial_eligible'
"display_name",
"original_name",
"country_code",
"price_kopeks",
"description",
"max_users",
"is_available",
"sort_order",
"is_trial_eligible",
}
filtered_updates = {k: v for k, v in updates.items() if k in valid_fields}
@@ -454,7 +515,6 @@ async def choose_random_trial_server_squad(
async def get_random_trial_squad_uuid(
db: AsyncSession,
fallback_uuid: Optional[str] = None,
) -> Optional[str]:
squad = await choose_random_trial_server_squad(db)
@@ -462,7 +522,7 @@ async def get_random_trial_squad_uuid(
if squad:
return squad.squad_uuid
return fallback_uuid
return None
def _generate_display_name(original_name: str) -> str:
+303 -47
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
@@ -11,10 +11,12 @@ from app.database.models import (
User,
SubscriptionServer,
PromoGroup,
UserPromoGroup,
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
@@ -41,21 +43,19 @@ async def create_trial_subscription(
user_id: int,
duration_days: int = None,
traffic_limit_gb: int = None,
device_limit: int = None,
device_limit: Optional[int] = None,
squad_uuid: str = None
) -> Subscription:
duration_days = duration_days or settings.TRIAL_DURATION_DAYS
traffic_limit_gb = traffic_limit_gb or settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = device_limit or settings.TRIAL_DEVICE_LIMIT
if device_limit is None:
device_limit = settings.TRIAL_DEVICE_LIMIT
if not squad_uuid:
try:
from app.database.crud.server_squad import get_random_trial_squad_uuid
squad_uuid = await get_random_trial_squad_uuid(
db,
settings.TRIAL_SQUAD_UUID,
)
squad_uuid = await get_random_trial_squad_uuid(db)
if squad_uuid:
logger.debug(
@@ -69,7 +69,6 @@ async def create_trial_subscription(
user_id,
error,
)
squad_uuid = settings.TRIAL_SQUAD_UUID
end_date = datetime.utcnow() + timedelta(days=duration_days)
@@ -126,13 +125,16 @@ async def create_paid_subscription(
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -184,16 +186,118 @@ 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,
days: int
) -> Subscription:
current_time = datetime.utcnow()
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
# НОВОЕ: Вычисляем бонусные дни от триала ДО изменения end_date
bonus_days = 0
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
# Вычисляем остаток триала
if subscription.end_date and subscription.end_date > current_time:
remaining = subscription.end_date - current_time
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
logger.info(
"🎁 Обнаружен остаток триала: %s дней для подписки %s",
bonus_days,
subscription.id,
)
# Применяем продление с учетом бонусных дней
total_days = days + bonus_days
if days < 0:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(
@@ -202,27 +306,15 @@ async def extend_subscription(
subscription.end_date,
)
elif subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
subscription.end_date = subscription.end_date + timedelta(days=total_days)
logger.info(f"📅 Подписка активна, добавляем {total_days} дней ({days} + {bonus_days} бонус) к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
subscription.end_date = current_time + timedelta(days=total_days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней ({days} + {bonus_days} бонус)")
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
# УДАЛЕНО: Автоматическая конвертация триала по длительности
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
# Логируем статус подписки перед проверкой
logger.info(f"🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}")
@@ -584,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,
@@ -793,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}"
@@ -803,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}"
@@ -812,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}"
@@ -821,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
@@ -910,7 +1105,7 @@ async def get_subscription_renewal_cost(
result = await db.execute(
select(Subscription)
.options(
selectinload(Subscription.user).selectinload(User.promo_group),
selectinload(Subscription.user).selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
.where(Subscription.id == subscription_id)
)
@@ -1127,7 +1322,13 @@ async def check_and_update_subscription_status(
current_time = datetime.utcnow()
logger.info(f"🔍 Проверка статуса подписки {subscription.id}, текущий статус: {subscription.status}, дата окончания: {subscription.end_date}, текущее время: {current_time}")
logger.info(
"🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s",
subscription.id,
subscription.status,
format_local_datetime(subscription.end_date),
format_local_datetime(current_time),
)
if (subscription.status == SubscriptionStatus.ACTIVE.value and
subscription.end_date <= current_time):
@@ -1144,6 +1345,63 @@ async def check_and_update_subscription_status(
return subscription
async def create_subscription_no_commit(
db: AsyncSession,
user_id: int,
status: str = "trial",
is_trial: bool = True,
end_date: datetime = None,
traffic_limit_gb: int = 10,
traffic_used_gb: float = 0.0,
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = "",
subscription_crypto_link: str = "",
autopay_enabled: Optional[bool] = None,
autopay_days_before: Optional[int] = None,
) -> Subscription:
"""
Создает подписку без немедленного коммита для пакетной обработки
"""
if end_date is None:
end_date = datetime.utcnow() + timedelta(days=3)
if connected_squads is None:
connected_squads = []
subscription = Subscription(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link,
autopay_enabled=(
settings.is_autopay_enabled_by_default()
if autopay_enabled is None
else autopay_enabled
),
autopay_days_before=(
settings.DEFAULT_AUTOPAY_DAYS_BEFORE
if autopay_days_before is None
else autopay_days_before
),
)
db.add(subscription)
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(f"✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)")
return subscription
async def create_subscription(
db: AsyncSession,
user_id: int,
@@ -1309,12 +1567,10 @@ async def activate_pending_subscription(
# Если указан период, обновляем дату окончания
if period_days is not None:
if pending_subscription.end_date <= current_time:
# Если текущая дата окончания уже прошла, устанавливаем новую
pending_subscription.end_date = current_time + timedelta(days=period_days)
else:
# Если дата окончания в будущем, продляем её
pending_subscription.end_date = pending_subscription.end_date + timedelta(days=period_days)
effective_start = pending_subscription.start_date or current_time
if effective_start < current_time:
effective_start = current_time
pending_subscription.end_date = effective_start + timedelta(days=period_days)
# Обновляем дату начала, если она не установлена или в прошлом
if not pending_subscription.start_date or pending_subscription.start_date < current_time:
+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)
+29 -5
View File
@@ -333,18 +333,42 @@ class TicketCRUD:
*,
limit: int = 50,
offset: int = 0,
action: Optional[str] = None,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
query = select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at))
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query.offset(offset).limit(limit))
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
async def count_support_audit(db: AsyncSession, action: Optional[str] = None) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
query = select(func.count()).select_from(SupportAuditLog)
if action:
query = query.where(SupportAuditLog.action == action)
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def list_support_audit_actions(db: AsyncSession) -> List[str]:
from sqlalchemy import select
result = await db.execute(
select(SupportAuditLog.action)
.where(SupportAuditLog.action.isnot(None))
.distinct()
.order_by(SupportAuditLog.action)
)
return [row[0] for row in result.fetchall()]
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
+15
View File
@@ -50,6 +50,21 @@ async def create_transaction(
user_id,
exc,
)
if type == TransactionType.SUBSCRIPTION_PAYMENT:
try:
from app.services.referral_contest_service import referral_contest_service
await referral_contest_service.on_subscription_payment(
db,
user_id,
amount_kopeks,
)
except Exception as exc:
logger.debug(
"Не удалось записать событие конкурса для пользователя %s: %s",
user_id,
exc,
)
return transaction
+203 -27
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
from typing import Optional, List, Dict
from sqlalchemy import select, and_, or_, func, case, nullslast, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.exc import IntegrityError
from app.database.models import (
@@ -15,6 +15,7 @@ from app.database.models import (
SubscriptionStatus,
Transaction,
PromoGroup,
UserPromoGroup,
PaymentMethod,
TransactionType,
)
@@ -38,14 +39,16 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.id == user_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
@@ -56,26 +59,83 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
if not username:
return None
normalized = username.lower()
result = await db.execute(
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(func.lower(User.username) == normalized)
)
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return user
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User)
.options(selectinload(User.promo_group))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.referral_code == referral_code)
)
return result.scalar_one_or_none()
user = result.scalar_one_or_none()
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
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:
@@ -122,6 +182,57 @@ async def _get_or_create_default_promo_group(db: AsyncSession) -> PromoGroup:
return default_group
async def create_user_no_commit(
db: AsyncSession,
telegram_id: int,
username: str = None,
first_name: str = None,
last_name: str = None,
language: str = "ru",
referred_by_id: int = None,
referral_code: str = None
) -> User:
"""
Создает пользователя без немедленного коммита для пакетной обработки
"""
if not referral_code:
referral_code = await create_unique_referral_code(db)
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=promo_group_id,
)
db.add(user)
# Обязательно выполняем flush, чтобы получить присвоенный первичный ключ
await db.flush()
# Сохраняем ссылку на группу, чтобы дальнейшие операции могли её использовать
user.promo_group = default_group
# Не коммитим сразу, оставляем для пакетной обработки
logger.info(
f"✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)"
)
return user
async def create_user(
db: AsyncSession,
telegram_id: int,
@@ -219,7 +330,8 @@ async def add_user_balance(
amount_kopeks: int,
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
@@ -228,12 +340,11 @@ async def add_user_balance(
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
)
@@ -253,9 +364,10 @@ async def add_user_balance(
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
telegram_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
@@ -263,7 +375,13 @@ async def add_user_balance_by_id(
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(db, user, amount_kopeks, description)
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
)
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
@@ -280,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:
@@ -493,7 +611,11 @@ async def get_users_list(
order_by_purchase_count: bool = False
) -> List[User]:
query = select(User).options(selectinload(User.subscription))
query = select(User).options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
if status:
query = query.where(User.status == status.value)
@@ -507,7 +629,14 @@ async def get_users_list(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
@@ -581,7 +710,15 @@ async def get_users_list(
query = query.offset(offset).limit(limit)
result = await db.execute(query)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_count(
@@ -604,7 +741,14 @@ async def get_users_count(
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
try:
search_int = int(search)
# Добавляем условие поиска по telegram_id, который является BigInteger
# и может содержать большие значения, в отличие от User.id (INTEGER)
conditions.append(User.telegram_id == search_int)
except ValueError:
# Если не удалось преобразовать в int, просто ищем по текстовым полям
pass
query = query.where(or_(*conditions))
@@ -673,12 +817,22 @@ async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[User]:
@@ -686,7 +840,11 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
base_query = (
select(User)
.options(selectinload(User.subscription))
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
selectinload(User.referrer),
)
.where(User.status == UserStatus.ACTIVE.value)
)
@@ -731,7 +889,15 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> List[Us
return []
result = await db.execute(query.order_by(User.id))
return result.scalars().unique().all()
users = result.scalars().unique().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
@@ -741,6 +907,8 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
select(User)
.options(
selectinload(User.subscription),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
selectinload(User.referrer),
selectinload(User.promo_group),
)
.where(
@@ -750,7 +918,15 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
)
)
)
return result.scalars().all()
users = result.scalars().all()
# Загружаем дополнительные зависимости для всех пользователей
for user in users:
if user and user.subscription:
# Загружаем дополнительные зависимости для subscription
_ = user.subscription.is_active
return users
async def delete_user(db: AsyncSession, user: User) -> bool:
+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:
+311
View File
@@ -0,0 +1,311 @@
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
import logging
from datetime import datetime
from typing import List, Optional
from sqlalchemy import select, and_, desc
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import UserPromoGroup, PromoGroup, User
logger = logging.getLogger(__name__)
async def _sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Синхронизирует колонку users.promo_group_id с primary промогруппой."""
try:
result = await db.execute(
select(UserPromoGroup.promo_group_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.where(UserPromoGroup.user_id == user_id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
first = result.first()
new_primary_id = first[0] if first else None
user = await db.get(User, user_id)
if not user:
return
if user.promo_group_id != new_primary_id:
user.promo_group_id = new_primary_id
user.updated_at = datetime.utcnow()
except Exception as error:
logger.error(
"Ошибка синхронизации primary промогруппы пользователя %s: %s",
user_id,
error,
)
async def sync_user_primary_promo_group(
db: AsyncSession,
user_id: int,
) -> None:
"""Публичная обертка для синхронизации primary промогруппы пользователя."""
await _sync_user_primary_promo_group(db, user_id)
async def add_user_to_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int,
assigned_by: str = "admin"
) -> Optional[UserPromoGroup]:
"""
Добавляет пользователю промогруппу.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
assigned_by: Кто назначил ('admin', 'system', 'auto', 'promocode')
Returns:
UserPromoGroup или None если уже существует
"""
try:
# Проверяем существование связи
existing = await has_user_promo_group(db, user_id, promo_group_id)
if existing:
logger.info(f"Пользователь {user_id} уже имеет промогруппу {promo_group_id}")
return None
# Создаем новую связь
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by,
)
db.add(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
await db.refresh(user_promo_group)
logger.info(f"Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})")
return user_promo_group
except Exception as error:
logger.error(f"Ошибка добавления промогруппы пользователю: {error}")
await db.rollback()
return None
async def remove_user_from_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Удаляет промогруппу у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если удалено, False если связи не было
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
user_promo_group = result.scalar_one_or_none()
if not user_promo_group:
logger.warning(f"Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена")
return False
await db.delete(user_promo_group)
await db.flush()
await _sync_user_primary_promo_group(db, user_id)
await db.commit()
logger.info(f"У пользователя {user_id} удалена промогруппа {promo_group_id}")
return True
except Exception as error:
logger.error(f"Ошибка удаления промогруппы у пользователя: {error}")
await db.rollback()
return False
async def get_user_promo_groups(
db: AsyncSession,
user_id: int
) -> List[UserPromoGroup]:
"""
Получает все промогруппы пользователя, отсортированные по приоритету.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Список UserPromoGroup с загруженными PromoGroup, отсортированный по приоритету DESC
"""
try:
result = await db.execute(
select(UserPromoGroup)
.options(selectinload(UserPromoGroup.promo_group))
.where(UserPromoGroup.user_id == user_id)
.join(PromoGroup, UserPromoGroup.promo_group_id == PromoGroup.id)
.order_by(desc(PromoGroup.priority), PromoGroup.id)
)
return list(result.scalars().all())
except Exception as error:
logger.error(f"Ошибка получения промогрупп пользователя {user_id}: {error}")
return []
async def get_primary_user_promo_group(
db: AsyncSession,
user_id: int
) -> Optional[PromoGroup]:
"""
Получает промогруппу пользователя с максимальным приоритетом.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
PromoGroup с максимальным приоритетом или None
"""
try:
user_promo_groups = await get_user_promo_groups(db, user_id)
if not user_promo_groups:
return None
# Первая в списке имеет максимальный приоритет (список уже отсортирован)
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
except Exception as error:
logger.error(f"Ошибка получения primary промогруппы пользователя {user_id}: {error}")
return None
async def has_user_promo_group(
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> bool:
"""
Проверяет наличие промогруппы у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_id: ID промогруппы
Returns:
True если пользователь уже имеет эту промогруппу
"""
try:
result = await db.execute(
select(UserPromoGroup).where(
and_(
UserPromoGroup.user_id == user_id,
UserPromoGroup.promo_group_id == promo_group_id
)
)
)
return result.scalar_one_or_none() is not None
except Exception as error:
logger.error(f"Ошибка проверки промогруппы пользователя: {error}")
return False
async def count_user_promo_groups(
db: AsyncSession,
user_id: int
) -> int:
"""
Подсчитывает количество промогрупп у пользователя.
Args:
db: Сессия БД
user_id: ID пользователя
Returns:
Количество промогрупп
"""
try:
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
return len(list(result.scalars().all()))
except Exception as error:
logger.error(f"Ошибка подсчета промогрупп пользователя: {error}")
return 0
async def replace_user_promo_groups(
db: AsyncSession,
user_id: int,
promo_group_ids: List[int],
assigned_by: str = "admin"
) -> bool:
"""
Заменяет все промогруппы пользователя на новый список.
Args:
db: Сессия БД
user_id: ID пользователя
promo_group_ids: Список ID промогрупп
assigned_by: Кто назначил
Returns:
True если успешно
"""
try:
# Удаляем все текущие промогруппы
await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
result = await db.execute(
select(UserPromoGroup).where(UserPromoGroup.user_id == user_id)
)
for upg in result.scalars().all():
await db.delete(upg)
# Добавляем новые
for promo_group_id in promo_group_ids:
user_promo_group = UserPromoGroup(
user_id=user_id,
promo_group_id=promo_group_id,
assigned_by=assigned_by
)
db.add(user_promo_group)
await db.commit()
logger.info(f"Промогруппы пользователя {user_id} заменены на {promo_group_ids}")
return True
except Exception as error:
logger.error(f"Ошибка замены промогрупп пользователя: {error}")
await db.rollback()
return False
+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 бесплатно! "
+360 -14
View File
@@ -1,31 +1,167 @@
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.pool import NullPool
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
poolclass = NullPool
pool_kwargs = {}
else:
poolclass = AsyncAdaptedQueuePool
pool_kwargs = {
"pool_size": 20,
"max_overflow": 30,
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
# ============================================================================
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
engine = create_async_engine(
settings.get_database_url(),
poolclass=NullPool,
poolclass=poolclass,
echo=settings.DEBUG,
future=True
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
)
# ============================================================================
# SESSION FACTORY WITH OPTIMIZATIONS
# ============================================================================
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
autoflush=False, # 🔥 Критично для производительности
autocommit=False,
)
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
if settings.DEBUG:
@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
conn.info.setdefault("query_start_time", []).append(time.time())
logger.debug(f"🔍 Executing query: {statement[:100]}...")
@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
total = time.time() - conn.info["query_start_time"].pop(-1)
if total > 0.1: # Логируем медленные запросы > 100ms
logger.warning(f"🐌 Slow query ({total:.3f}s): {statement[:100]}...")
else:
logger.debug(f"⚡ Query executed in {total:.3f}s")
# ============================================================================
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
try:
yield session
if not read_only:
await session.commit()
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
pool = self.engine.pool
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except Exception as e:
logger.error(f"❌ Database health check failed: {e}")
status = "unhealthy"
latency = None
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
# SESSION DEPENDENCY FOR FASTAPI/AIOGRAM
# ============================================================================
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Стандартная dependency для FastAPI"""
async with AsyncSessionLocal() as session:
try:
yield session
@@ -33,19 +169,229 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def get_db_read_only() -> AsyncGenerator[AsyncSession, None]:
"""Read-only dependency для тяжелых SELECT запросов"""
async with db_manager.session(read_only=True) as session:
yield session
# ============================================================================
# BATCH OPERATIONS FOR PERFORMANCE
# ============================================================================
class BatchOperations:
"""Утилиты для массовых операций"""
@staticmethod
async def bulk_insert(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовая вставка с чанками"""
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
session.add_all([model(**item) for item in chunk])
await session.flush()
await session.commit()
@staticmethod
async def bulk_update(session: AsyncSession, model, data: list[dict], chunk_size: int = 1000):
"""Массовое обновление с чанками"""
if not data:
return
primary_keys = [column.name for column in model.__table__.primary_key.columns]
if not primary_keys:
raise ValueError("Model must have a primary key for bulk_update")
updatable_columns = [
column.name
for column in model.__table__.columns
if column.name not in primary_keys
]
if not updatable_columns:
raise ValueError("No columns available for update in bulk_update")
stmt = (
model.__table__.update()
.where(
*[
getattr(model.__table__.c, pk) == bindparam(pk)
for pk in primary_keys
]
)
.values(
**{
column: bindparam(column, required=False)
for column in updatable_columns
}
)
)
for i in range(0, len(data), chunk_size):
chunk = data[i:i + chunk_size]
filtered_chunk = []
for item in chunk:
missing_keys = [pk for pk in primary_keys if pk not in item]
if missing_keys:
raise ValueError(
f"Missing primary key values {missing_keys} for bulk_update"
)
filtered_item = {
key: value
for key, value in item.items()
if key in primary_keys or key in updatable_columns
}
filtered_chunk.append(filtered_item)
await session.execute(stmt, filtered_chunk)
await session.commit()
batch_ops = BatchOperations()
# ============================================================================
# INITIALIZATION AND CLEANUP
# ============================================================================
async def init_db():
logger.info("Создание таблиц базы данных...")
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
("users", "CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)"),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id)",
),
(
"subscriptions",
"CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status) WHERE status = 'active'",
),
(
"payments",
"CREATE INDEX IF NOT EXISTS idx_payments_created_at ON payments(created_at DESC)",
),
]
for table_name, index_sql in indexes:
table_exists = await conn.run_sync(lambda sync_conn: inspect(sync_conn).has_table(table_name))
if not table_exists:
logger.debug(
"Пропускаем создание индекса %s: таблица %s отсутствует",
index_sql,
table_name,
)
continue
try:
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("✅ База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("✅ Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
# ============================================================================
def _pool_counters(pool):
"""Return basic pool counters or ``None`` when unsupported."""
required_methods = ("size", "checkedin", "checkedout", "overflow")
for method_name in required_methods:
method = getattr(pool, method_name, None)
if method is None or not callable(method):
return None
size = pool.size()
checked_in = pool.checkedin()
checked_out = pool.checkedout()
overflow = pool.overflow()
total_connections = size + overflow
return {
"size": size,
"checked_in": checked_in,
"checked_out": checked_out,
"overflow": overflow,
"total_connections": total_connections,
"utilization_percent": (checked_out / total_connections * 100) if total_connections else 0.0,
}
def _collect_health_pool_metrics(pool) -> dict:
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"size": 0,
"checked_in": 0,
"checked_out": 0,
"overflow": 0,
"total_connections": 0,
"utilization": "0.0%",
}
return {
"metrics_available": True,
"size": counters["size"],
"checked_in": counters["checked_in"],
"checked_out": counters["checked_out"],
"overflow": counters["overflow"],
"total_connections": counters["total_connections"],
"utilization": f"{counters['utilization_percent']:.1f}%",
}
async def get_pool_metrics() -> dict:
"""Детальные метрики пула для Prometheus/Grafana"""
pool = engine.pool
counters = _pool_counters(pool)
if counters is None:
return {
"metrics_available": False,
"pool_size": 0,
"checked_in_connections": 0,
"checked_out_connections": 0,
"overflow_connections": 0,
"total_connections": 0,
"max_possible_connections": 0,
"pool_utilization_percent": 0.0,
}
return {
"metrics_available": True,
"pool_size": counters["size"],
"checked_in_connections": counters["checked_in"],
"checked_out_connections": counters["checked_out"],
"overflow_connections": counters["overflow"],
"total_connections": counters["total_connections"],
"max_possible_connections": counters["total_connections"] + (getattr(pool, "_max_overflow", 0) or 0),
"pool_utilization_percent": round(counters["utilization_percent"], 2),
}
+493 -21
View File
@@ -1,5 +1,5 @@
from datetime import datetime, timedelta
from typing import Optional, List, Dict
from datetime import datetime, timedelta, time, date
from typing import Optional, List, Dict, Any
from enum import Enum
from sqlalchemy import (
@@ -7,6 +7,8 @@ from sqlalchemy import (
Integer,
String,
DateTime,
Date,
Time,
Boolean,
Text,
ForeignKey,
@@ -16,6 +18,7 @@ from sqlalchemy import (
UniqueConstraint,
Index,
Table,
SmallInteger,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -58,17 +61,19 @@ class SubscriptionStatus(Enum):
class TransactionType(Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
SUBSCRIPTION_PAYMENT = "subscription_payment"
REFUND = "refund"
REFERRAL_REWARD = "referral_reward"
POLL_REWARD = "poll_reward"
class PromoCodeType(Enum):
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
BALANCE = "balance"
SUBSCRIPTION_DAYS = "subscription_days"
TRIAL_SUBSCRIPTION = "trial_subscription"
PROMO_GROUP = "promo_group"
class PaymentMethod(Enum):
@@ -76,9 +81,11 @@ class PaymentMethod(Enum):
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
CRYPTOBOT = "cryptobot"
HELEKET = "heleket"
MULENPAY = "mulenpay"
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
MANUAL = "manual"
@@ -190,6 +197,72 @@ class CryptoBotPayment(Base):
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
class HeleketPayment(Base):
__tablename__ = "heleket_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
uuid = Column(String(255), unique=True, nullable=False, index=True)
order_id = Column(String(128), unique=True, nullable=False, index=True)
amount = Column(String(50), nullable=False)
currency = Column(String(10), nullable=False)
payer_amount = Column(String(50), nullable=True)
payer_currency = Column(String(10), nullable=True)
exchange_rate = Column(Float, nullable=True)
discount_percent = Column(Integer, nullable=True)
status = Column(String(50), nullable=False)
payment_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="heleket_payments")
transaction = relationship("Transaction", backref="heleket_payment")
@property
def amount_float(self) -> float:
try:
return float(self.amount)
except (TypeError, ValueError):
return 0.0
@property
def amount_kopeks(self) -> int:
return int(round(self.amount_float * 100))
@property
def payer_amount_float(self) -> float:
try:
return float(self.payer_amount) if self.payer_amount is not None else 0.0
except (TypeError, ValueError):
return 0.0
@property
def is_paid(self) -> bool:
return self.status in {"paid", "paid_over"}
def __repr__(self):
return (
"<HeleketPayment(id={id}, uuid={uuid}, order_id={order_id}, amount={amount}"
" {currency}, status={status})>"
).format(
id=self.id,
uuid=self.uuid,
order_id=self.order_id,
amount=self.amount,
currency=self.currency,
status=self.status,
)
class MulenPayPayment(Base):
__tablename__ = "mulenpay_payments"
@@ -345,11 +418,62 @@ class WataPayment(Base):
)
class PlategaPayment(Base):
__tablename__ = "platega_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
platega_transaction_id = Column(String(255), unique=True, nullable=True, index=True)
correlation_id = Column(String(64), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
payment_method_code = Column(Integer, nullable=False)
status = Column(String(50), nullable=False, default="PENDING")
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
redirect_url = Column(Text, nullable=True)
return_url = Column(Text, nullable=True)
failed_url = Column(Text, nullable=True)
payload = Column(String(255), nullable=True)
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
expires_at = Column(DateTime, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="platega_payments")
transaction = relationship("Transaction", backref="platega_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<PlategaPayment(id={0}, transaction_id={1}, amount={2}₽, status={3}, method={4})>".format(
self.id,
self.platega_transaction_id,
self.amount_rubles,
self.status,
self.payment_method_code,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
priority = Column(Integer, nullable=False, default=0, index=True)
server_discount_percent = Column(Integer, nullable=False, default=0)
traffic_discount_percent = Column(Integer, nullable=False, default=0)
device_discount_percent = Column(Integer, nullable=False, default=0)
@@ -361,6 +485,7 @@ class PromoGroup(Base):
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
users = relationship("User", back_populates="promo_group")
user_promo_groups = relationship("UserPromoGroup", back_populates="promo_group", cascade="all, delete-orphan")
server_squads = relationship(
"ServerSquad",
secondary=server_squad_promo_groups,
@@ -419,11 +544,31 @@ 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))
class UserPromoGroup(Base):
"""Таблица связи Many-to-Many между пользователями и промогруппами."""
__tablename__ = "user_promo_groups"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="CASCADE"), primary_key=True)
assigned_at = Column(DateTime, default=func.now())
assigned_by = Column(String(50), default="system")
user = relationship("User", back_populates="user_promo_groups")
promo_group = relationship("PromoGroup", back_populates="user_promo_groups")
def __repr__(self):
return f"<UserPromoGroup(user_id={self.user_id}, promo_group_id={self.promo_group_id}, assigned_by='{self.assigned_by}')>"
class User(Base):
__tablename__ = "users"
@@ -453,6 +598,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)
@@ -461,22 +607,51 @@ class User(Base):
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=True, index=True)
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
@property
def balance_rubles(self) -> float:
return self.balance_kopeks / 100
@property
def full_name(self) -> str:
parts = [self.first_name, self.last_name]
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
return getattr(self, "promo_group", None)
try:
# Сортируем по приоритету группы (убывание), затем по ID группы
# Используем getattr для защиты от ленивой загрузки
sorted_groups = sorted(
self.user_promo_groups,
key=lambda upg: (
getattr(upg.promo_group, 'priority', 0) if upg.promo_group else 0,
upg.promo_group_id
),
reverse=True
)
if sorted_groups and sorted_groups[0].promo_group:
return sorted_groups[0].promo_group
except Exception:
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
pass
# Fallback на старую связь если новая пустая или возникла ошибка
return getattr(self, "promo_group", None)
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
if not self.promo_group:
primary_group = self.get_primary_promo_group()
if not primary_group:
return 0
return self.promo_group.get_discount_percent(category, period_days)
return primary_group.get_discount_percent(category, period_days)
def add_balance(self, kopeks: int) -> None:
self.balance_kopeks += kopeks
@@ -724,13 +899,15 @@ class PromoCode(Base):
valid_until = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
uses = relationship("PromoCodeUse", back_populates="promocode")
promo_group = relationship("PromoGroup")
@property
def is_valid(self) -> bool:
@@ -783,6 +960,132 @@ class ReferralEarning(Base):
return self.amount_kopeks / 100
class ReferralContest(Base):
__tablename__ = "referral_contests"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
prize_text = Column(Text, nullable=True)
contest_type = Column(String(50), nullable=False, default="referral_paid")
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=False)
daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0))
daily_summary_times = Column(String(255), nullable=True) # CSV HH:MM
timezone = Column(String(64), nullable=False, default="UTC")
is_active = Column(Boolean, nullable=False, default=True)
last_daily_summary_date = Column(Date, nullable=True)
last_daily_summary_at = Column(DateTime, nullable=True)
final_summary_sent = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_referral_contests")
events = relationship(
"ReferralContestEvent",
back_populates="contest",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<ReferralContest id={self.id} title='{self.title}'>"
class ReferralContestEvent(Base):
__tablename__ = "referral_contest_events"
__table_args__ = (
UniqueConstraint(
"contest_id",
"referral_id",
name="uq_referral_contest_referral",
),
Index("idx_referral_contest_referrer", "contest_id", "referrer_id"),
)
id = Column(Integer, primary_key=True, index=True)
contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False)
referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
event_type = Column(String(50), nullable=False)
amount_kopeks = Column(Integer, nullable=False, default=0)
occurred_at = Column(DateTime, nullable=False, default=func.now())
contest = relationship("ReferralContest", back_populates="events")
referrer = relationship("User", foreign_keys=[referrer_id])
referral = relationship("User", foreign_keys=[referral_id])
def __repr__(self):
return (
f"<ReferralContestEvent contest={self.contest_id} "
f"referrer={self.referrer_id} referral={self.referral_id}>"
)
class ContestTemplate(Base):
__tablename__ = "contest_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
slug = Column(String(50), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
prize_days = Column(Integer, nullable=False, default=1)
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
times_per_day = Column(Integer, nullable=False, default=1)
schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ
cooldown_hours = Column(Integer, nullable=False, default=24)
payload = Column(JSON, nullable=True)
is_enabled = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
rounds = relationship("ContestRound", back_populates="template")
class ContestRound(Base):
__tablename__ = "contest_rounds"
__table_args__ = (
Index("idx_contest_round_status", "status"),
Index("idx_contest_round_template", "template_id"),
)
id = Column(Integer, primary_key=True, index=True)
template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False)
starts_at = Column(DateTime, nullable=False)
ends_at = Column(DateTime, nullable=False)
status = Column(String(20), nullable=False, default="active") # active, finished
payload = Column(JSON, nullable=True)
winners_count = Column(Integer, nullable=False, default=0)
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
message_id = Column(BigInteger, nullable=True)
chat_id = Column(BigInteger, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
template = relationship("ContestTemplate", back_populates="rounds")
attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan")
class ContestAttempt(Base):
__tablename__ = "contest_attempts"
__table_args__ = (
UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"),
Index("idx_contest_attempt_round", "round_id"),
)
id = Column(Integer, primary_key=True, index=True)
round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
answer = Column(Text, nullable=True)
is_winner = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
round = relationship("ContestRound", back_populates="attempts")
user = relationship("User")
class Squad(Base):
__tablename__ = "squads"
@@ -909,6 +1212,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__ = (
@@ -994,9 +1321,9 @@ class PromoOfferLog(Base):
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
@@ -1012,6 +1339,106 @@ class BroadcastHistory(Base):
completed_at = Column(DateTime(timezone=True), nullable=True)
admin = relationship("User", back_populates="broadcasts")
class Poll(Base):
__tablename__ = "polls"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
reward_enabled = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", backref="created_polls", foreign_keys=[created_by])
questions = relationship(
"PollQuestion",
back_populates="poll",
cascade="all, delete-orphan",
order_by="PollQuestion.order",
)
responses = relationship(
"PollResponse",
back_populates="poll",
cascade="all, delete-orphan",
)
class PollQuestion(Base):
__tablename__ = "poll_questions"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="questions")
options = relationship(
"PollOption",
back_populates="question",
cascade="all, delete-orphan",
order_by="PollOption.order",
)
answers = relationship("PollAnswer", back_populates="question")
class PollOption(Base):
__tablename__ = "poll_options"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(Text, nullable=False)
order = Column(Integer, nullable=False, default=0)
question = relationship("PollQuestion", back_populates="options")
answers = relationship("PollAnswer", back_populates="option")
class PollResponse(Base):
__tablename__ = "poll_responses"
id = Column(Integer, primary_key=True, index=True)
poll_id = Column(Integer, ForeignKey("polls.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
sent_at = Column(DateTime, default=func.now(), nullable=False)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
reward_given = Column(Boolean, nullable=False, default=False)
reward_amount_kopeks = Column(Integer, nullable=False, default=0)
poll = relationship("Poll", back_populates="responses")
user = relationship("User", back_populates="poll_responses")
answers = relationship(
"PollAnswer",
back_populates="response",
cascade="all, delete-orphan",
)
__table_args__ = (
UniqueConstraint("poll_id", "user_id", name="uq_poll_user"),
)
class PollAnswer(Base):
__tablename__ = "poll_answers"
id = Column(Integer, primary_key=True, index=True)
response_id = Column(Integer, ForeignKey("poll_responses.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("poll_questions.id", ondelete="CASCADE"), nullable=False, index=True)
option_id = Column(Integer, ForeignKey("poll_options.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime, default=func.now(), nullable=False)
response = relationship("PollResponse", back_populates="answers")
question = relationship("PollQuestion", back_populates="answers")
option = relationship("PollOption", back_populates="answers")
__table_args__ = (
UniqueConstraint("response_id", "question_id", name="uq_poll_answer_unique"),
)
class ServerSquad(Base):
__tablename__ = "server_squads"
@@ -1355,3 +1782,48 @@ class MainMenuButton(Base):
f"<MainMenuButton id={self.id} text='{self.text}' "
f"action={self.action_type} visibility={self.visibility} active={self.is_active}>"
)
class MenuLayoutHistory(Base):
"""История изменений конфигурации меню."""
__tablename__ = "menu_layout_history"
id = Column(Integer, primary_key=True, index=True)
config_json = Column(Text, nullable=False) # Полная конфигурация в JSON
action = Column(String(50), nullable=False) # update, reset, import
changes_summary = Column(Text, nullable=True) # Краткое описание изменений
user_info = Column(String(255), nullable=True) # Информация о пользователе/токене
created_at = Column(DateTime, default=func.now(), index=True)
__table_args__ = (
Index("ix_menu_layout_history_created", "created_at"),
)
def __repr__(self) -> str:
return f"<MenuLayoutHistory id={self.id} action='{self.action}' created_at={self.created_at}>"
class ButtonClickLog(Base):
"""Логи кликов по кнопкам меню."""
__tablename__ = "button_click_logs"
id = Column(Integer, primary_key=True, index=True)
button_id = Column(String(100), nullable=False, index=True) # ID кнопки
user_id = Column(BigInteger, ForeignKey("users.telegram_id", ondelete="SET NULL"), nullable=True, index=True)
callback_data = Column(String(255), nullable=True) # callback_data кнопки
clicked_at = Column(DateTime, default=func.now(), index=True)
# Дополнительная информация
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
__table_args__ = (
Index("ix_button_click_logs_button_date", "button_id", "clicked_at"),
Index("ix_button_click_logs_user_date", "user_id", "clicked_at"),
)
# Связи
user = relationship("User", foreign_keys=[user_id])
def __repr__(self) -> str:
return f"<ButtonClickLog id={self.id} button='{self.button_id}' user={self.user_id} at={self.clicked_at}>"
File diff suppressed because it is too large Load Diff
+34 -14
View File
@@ -19,10 +19,10 @@ class CryptoBotService:
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
async def _make_request(
self,
method: str,
endpoint: str,
data: Optional[Dict] = None
self,
method: str,
endpoint: str,
data: Optional[Dict] = None,
) -> Optional[Dict[str, Any]]:
if not self.api_token:
@@ -37,11 +37,18 @@ class CryptoBotService:
try:
async with aiohttp.ClientSession() as session:
request_kwargs: Dict[str, Any] = {"headers": headers}
if method.upper() == "GET":
if data:
request_kwargs["params"] = data
elif data:
request_kwargs["json"] = data
async with session.request(
method,
url,
headers=headers,
json=data if data else None
method,
url,
**request_kwargs,
) as response:
response_data = await response.json()
@@ -95,21 +102,34 @@ class CryptoBotService:
asset: Optional[str] = None,
status: Optional[str] = None,
offset: int = 0,
count: int = 100
count: int = 100,
invoice_ids: Optional[list] = None,
) -> Optional[list]:
data = {
'offset': offset,
'count': count
}
if asset:
data['asset'] = asset
if status:
data['status'] = status
return await self._make_request('GET', 'getInvoices', data)
if invoice_ids:
data['invoice_ids'] = invoice_ids
result = await self._make_request('GET', 'getInvoices', data)
if isinstance(result, dict):
items = result.get('items')
return items if isinstance(items, list) else []
if isinstance(result, list):
return result
return []
async def get_balance(self) -> Optional[list]:
return await self._make_request('GET', 'getBalance')
+174
View File
@@ -0,0 +1,174 @@
"""HTTP client for Heleket payment API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
from typing import Any, Dict, Optional
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class HeleketService:
"""Minimal wrapper around Heleket API endpoints."""
def __init__(self) -> None:
self.base_url = settings.HELEKET_BASE_URL.rstrip("/")
self.merchant_id = settings.HELEKET_MERCHANT_ID
self.api_key = settings.HELEKET_API_KEY
@property
def is_configured(self) -> bool:
return bool(self.merchant_id and self.api_key)
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:
api_key = self.api_key or ""
encoded = base64.b64encode(body.encode("utf-8")).decode("utf-8")
raw = f"{encoded}{api_key}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
async def _request(
self,
endpoint: str,
payload: Dict[str, Any],
*,
params: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not self.is_configured:
logger.error("Heleket сервис не настроен: merchant или api_key отсутствуют")
return None
body = self._prepare_body(payload, ignore_none=True, sort_keys=True)
signature = self._generate_signature(body)
url = f"{self.base_url}/{endpoint.lstrip('/')}"
headers = {
"merchant": self.merchant_id or "",
"sign": signature,
"Content-Type": "application/json",
}
try:
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
url,
data=body.encode("utf-8"),
headers=headers,
params=params,
) as response:
text = await response.text()
if response.content_type != "application/json":
logger.error("Ответ Heleket не JSON (%s): %s", response.content_type, text)
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
logger.error("Ошибка парсинга Heleket JSON: %s", text)
return None
if response.status >= 400:
logger.error("Heleket API %s вернул статус %s: %s", endpoint, response.status, data)
return None
if isinstance(data, dict) and data.get("state") == 0:
return data
logger.error("Heleket API вернул ошибку: %s", data)
return None
except Exception as error: # pragma: no cover - defensive
logger.error("Ошибка запроса к Heleket API: %s", error)
return None
async def create_payment(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return await self._request("payment", payload)
async def get_payment_info(
self,
*,
uuid: Optional[str] = None,
order_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not uuid and not order_id:
raise ValueError("Нужно указать uuid или order_id для Heleket payment/info")
payload: Dict[str, Any] = {}
if uuid:
payload["uuid"] = uuid
if order_id:
payload["order_id"] = order_id
return await self._request("payment/info", payload)
async def list_payments(
self,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
cursor: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {}
if date_from:
payload["date_from"] = date_from
if date_to:
payload["date_to"] = date_to
params = {"cursor": cursor} if cursor else None
return await self._request("payment/list", payload, params=params)
def verify_webhook_signature(self, payload: Dict[str, Any]) -> bool:
if not self.is_configured:
logger.warning("Heleket сервис не настроен, подпись пропускается")
return True
if not isinstance(payload, dict):
logger.error("Heleket webhook payload не dict: %s", payload)
return False
signature = payload.get("sign")
if not signature:
logger.error("Heleket webhook без подписи")
return False
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))
if not is_valid:
logger.error("Неверная подпись Heleket webhook: ожидается %s, получено %s", expected, signature)
return is_valid
+111
View File
@@ -0,0 +1,111 @@
import asyncio
import json
import logging
from typing import Any, Dict, Optional
from aiohttp import web
from app.config import settings
from app.database.database import get_db
from app.external.heleket import HeleketService
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
class HeleketWebhookHandler:
def __init__(self, payment_service: PaymentService) -> None:
self.payment_service = payment_service
self.service = HeleketService()
async def handle(self, request: web.Request) -> web.Response:
if not settings.is_heleket_enabled():
logger.warning("Получен Heleket webhook, но сервис отключен")
return web.json_response({"status": "error", "reason": "disabled"}, status=503)
try:
payload: Dict[str, Any] = await request.json()
except json.JSONDecodeError:
logger.error("Некорректный JSON Heleket webhook")
return web.json_response({"status": "error", "reason": "invalid_json"}, status=400)
if not self.service.verify_webhook_signature(payload):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=401)
processed: Optional[bool] = None
async for db in get_db():
processed = await self.payment_service.process_heleket_webhook(db, payload)
if processed:
return web.json_response({"status": "ok"}, status=200)
return web.json_response({"status": "error", "reason": "not_processed"}, status=400)
async def health_check(self, _: web.Request) -> web.Response:
return web.json_response(
{
"status": "ok",
"service": "heleket_webhook",
"enabled": settings.is_heleket_enabled(),
"path": settings.HELEKET_WEBHOOK_PATH,
}
)
async def options_handler(self, _: web.Request) -> web.Response:
return web.Response(
status=200,
headers={
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
)
def create_heleket_app(payment_service: PaymentService) -> web.Application:
handler = HeleketWebhookHandler(payment_service)
app = web.Application()
app.router.add_post(settings.HELEKET_WEBHOOK_PATH, handler.handle)
app.router.add_get("/heleket/health", handler.health_check)
app.router.add_get("/health", handler.health_check)
app.router.add_options(settings.HELEKET_WEBHOOK_PATH, handler.options_handler)
return app
async def start_heleket_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_heleket_enabled():
logger.info("Heleket отключен, webhook сервер не запускается")
return
app = create_heleket_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.HELEKET_WEBHOOK_HOST,
port=settings.HELEKET_WEBHOOK_PORT,
)
try:
await site.start()
logger.info(
"Heleket webhook сервер запущен на %s:%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
)
logger.info(
"Heleket webhook URL: http://%s:%s%s",
settings.HELEKET_WEBHOOK_HOST,
settings.HELEKET_WEBHOOK_PORT,
settings.HELEKET_WEBHOOK_PATH,
)
while True:
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info("Heleket webhook сервер остановлен по запросу")
finally:
await site.stop()
await runner.cleanup()
logger.info("Heleket webhook сервер корректно остановлен")
+4 -4
View File
@@ -1,4 +1,4 @@
"""Flask webhook server for PayPalych postbacks."""
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
@@ -65,7 +65,7 @@ def create_pal24_flask_app(
return jsonify({"status": "error", "reason": "empty_payload"}), 400
try:
parsed_payload = pal24_service.parse_postback(payload)
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error("Ошибка валидации Pal24 webhook: %s", error)
return jsonify({"status": "error", "reason": str(error)}), 400
@@ -73,7 +73,7 @@ def create_pal24_flask_app(
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_postback(db, parsed_payload)
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
@@ -112,7 +112,7 @@ def create_pal24_flask_app(
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 postbacks."""
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
+552 -53
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
@@ -97,6 +189,33 @@ class SubscriptionInfo:
happ_crypto_link: Optional[str] = None
@dataclass
class SubscriptionPageConfig:
"""Конфигурация страницы подписки"""
uuid: str
name: str
view_position: int
config: Optional[Dict[str, Any]] = None
@dataclass
class RemnaWaveExternalSquad:
"""Структура External Squad"""
uuid: str
name: str
view_position: int
members_count: int
templates: List[Dict[str, str]]
subscription_settings: Optional[Dict[str, Any]] = None
host_overrides: Optional[Dict[str, Any]] = None
response_headers: Optional[Dict[str, str]] = None
hwid_settings: Optional[Dict[str, Any]] = None
custom_remarks: Optional[Dict[str, Any]] = None
subpage_config_uuid: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class RemnaWaveAPIError(Exception):
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
self.message = message
@@ -163,7 +282,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
logger.debug(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = self._prepare_auth_headers()
@@ -299,12 +418,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 +437,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 +447,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 +492,48 @@ 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]:
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 +577,132 @@ 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']]
# ============== External Squads API ==============
async def get_external_squads(self) -> List[RemnaWaveExternalSquad]:
"""Получает список всех External Squads"""
response = await self._make_request('GET', '/api/external-squads')
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
async def get_external_squad_by_uuid(self, uuid: str) -> Optional[RemnaWaveExternalSquad]:
"""Получает External Squad по UUID"""
try:
response = await self._make_request('GET', f'/api/external-squads/{uuid}')
return self._parse_external_squad(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_external_squad(self, name: str) -> RemnaWaveExternalSquad:
data = {'name': name}
response = await self._make_request('POST', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def update_external_squad(
self,
uuid: str,
name: Optional[str] = None,
templates: Optional[List[Dict[str, str]]] = None,
subscription_settings: Optional[Dict[str, Any]] = None,
host_overrides: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, str]] = None,
hwid_settings: Optional[Dict[str, Any]] = None,
custom_remarks: Optional[Dict[str, Any]] = None,
subpage_config_uuid: Optional[str] = None
) -> RemnaWaveExternalSquad:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if templates is not None:
data['templates'] = templates
if subscription_settings is not None:
data['subscriptionSettings'] = subscription_settings
if host_overrides is not None:
data['hostOverrides'] = host_overrides
if response_headers is not None:
data['responseHeaders'] = response_headers
if hwid_settings is not None:
data['hwidSettings'] = hwid_settings
if custom_remarks is not None:
data['customRemarks'] = custom_remarks
if subpage_config_uuid is not None:
data['subpageConfigUuid'] = subpage_config_uuid
response = await self._make_request('PATCH', '/api/external-squads', data)
return self._parse_external_squad(response['response'])
async def delete_external_squad(self, uuid: str) -> bool:
"""Удаляет External Squad"""
response = await self._make_request('DELETE', f'/api/external-squads/{uuid}')
return response['response']['isDeleted']
async def add_users_to_external_squad(self, uuid: str) -> bool:
"""Добавляет всех пользователей в External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/add-users')
return response['response']['eventSent']
async def remove_users_from_external_squad(self, uuid: str) -> bool:
"""Удаляет всех пользователей из External Squad (bulk action)"""
response = await self._make_request('POST', f'/api/external-squads/{uuid}/bulk-actions/remove-users')
return response['response']['eventSent']
async def reorder_external_squads(self, items: List[Dict[str, Any]]) -> List[RemnaWaveExternalSquad]:
data = {'items': items}
response = await self._make_request('POST', '/api/external-squads/actions/reorder', data)
return [self._parse_external_squad(squad) for squad in response['response']['externalSquads']]
def _parse_external_squad(self, squad_data: Dict) -> RemnaWaveExternalSquad:
"""Парсит данные External Squad"""
info = squad_data.get('info', {})
return RemnaWaveExternalSquad(
uuid=squad_data['uuid'],
name=squad_data['name'],
view_position=squad_data.get('viewPosition', 0),
members_count=info.get('membersCount', 0),
templates=squad_data.get('templates', []),
subscription_settings=squad_data.get('subscriptionSettings'),
host_overrides=squad_data.get('hostOverrides'),
response_headers=squad_data.get('responseHeaders'),
hwid_settings=squad_data.get('hwidSettings'),
custom_remarks=squad_data.get('customRemarks'),
subpage_config_uuid=squad_data.get('subpageConfigUuid'),
created_at=self._parse_optional_datetime(squad_data.get('createdAt')),
updated_at=self._parse_optional_datetime(squad_data.get('updatedAt'))
)
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 +735,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:
@@ -532,10 +795,148 @@ class RemnaWaveAPI:
return response['response']
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/nodes/usage/realtime')
return await self.get_bandwidth_stats_nodes_realtime()
async def get_user_stats_usage(self, user_uuid: str, start_date: str, end_date: str) -> Dict[str, Any]:
return await self.get_bandwidth_stats_user_legacy(user_uuid, start_date, end_date)
# ============== Bandwidth Stats API ==============
async def get_bandwidth_stats_nodes(self, start_date: str, end_date: str) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', '/api/bandwidth-stats/nodes', params=params)
return response['response']
async def get_bandwidth_stats_nodes_realtime(self) -> List[Dict[str, Any]]:
response = await self._make_request('GET', '/api/bandwidth-stats/nodes/realtime')
return response['response']
async def get_bandwidth_stats_node_users(
self,
node_uuid: str,
start_date: str,
end_date: str,
top_users_limit: int = 10
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date,
'topUsersLimit': top_users_limit
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users', params=params)
return response['response']
async def get_bandwidth_stats_node_users_legacy(
self,
node_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/nodes/{node_uuid}/users/legacy', params=params)
return response['response']
async def get_bandwidth_stats_user(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}', params=params)
return response['response']
async def get_bandwidth_stats_user_legacy(
self,
user_uuid: str,
start_date: str,
end_date: str
) -> Dict[str, Any]:
params = {
'start': start_date,
'end': end_date
}
response = await self._make_request('GET', f'/api/bandwidth-stats/users/{user_uuid}/legacy', params=params)
return response
# ============== Subscription Page Configs API ==============
async def get_subscription_page_configs(self) -> List[SubscriptionPageConfig]:
response = await self._make_request('GET', '/api/subscription-page-configs')
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def get_subscription_page_config(self, uuid: str) -> Optional[SubscriptionPageConfig]:
try:
response = await self._make_request('GET', f'/api/subscription-page-configs/{uuid}')
return self._parse_subscription_page_config(response['response'])
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
async def create_subscription_page_config(self, name: str) -> SubscriptionPageConfig:
data = {'name': name}
response = await self._make_request('POST', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def update_subscription_page_config(
self,
uuid: str,
name: Optional[str] = None,
config: Optional[Dict[str, Any]] = None
) -> SubscriptionPageConfig:
data = {'uuid': uuid}
if name is not None:
data['name'] = name
if config is not None:
data['config'] = config
response = await self._make_request('PATCH', '/api/subscription-page-configs', data)
return self._parse_subscription_page_config(response['response'])
async def delete_subscription_page_config(self, uuid: str) -> bool:
response = await self._make_request('DELETE', f'/api/subscription-page-configs/{uuid}')
return response['response']['isDeleted']
async def reorder_subscription_page_configs(self, items: List[Dict[str, Any]]) -> List[SubscriptionPageConfig]:
data = {'items': items}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/reorder', data)
configs_data = response['response'].get('configs', [])
return [self._parse_subscription_page_config(c) for c in configs_data]
async def clone_subscription_page_config(self, clone_from_uuid: str) -> SubscriptionPageConfig:
data = {'cloneFromUuid': clone_from_uuid}
response = await self._make_request('POST', '/api/subscription-page-configs/actions/clone', data)
return self._parse_subscription_page_config(response['response'])
async def get_subpage_config_by_short_uuid(self, short_uuid: str) -> Optional[Dict[str, Any]]:
try:
response = await self._make_request('GET', f'/api/subscriptions/subpage-config/{short_uuid}')
return response.get('response')
except RemnaWaveAPIError as e:
if e.status_code == 404:
return None
raise
def _parse_subscription_page_config(self, data: Dict) -> SubscriptionPageConfig:
"""Парсит данные конфигурации страницы подписки"""
return SubscriptionPageConfig(
uuid=data['uuid'],
name=data['name'],
view_position=data['viewPosition'],
config=data.get('config')
)
async def get_user_devices(self, user_uuid: str) -> Dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
@@ -584,44 +985,94 @@ 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]:
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:
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 +1080,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:
+270 -127
View File
@@ -1,12 +1,16 @@
import asyncio
import logging
import json
import hashlib
import hmac
import base64
from typing import Optional, Dict, Any
from ipaddress import (
IPv4Address,
IPv4Network,
IPv6Address,
IPv6Network,
ip_address,
ip_network,
)
from typing import Iterable, Optional, Dict, Any, List, Union, Tuple
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
@@ -15,155 +19,294 @@ from app.database.database import get_db
logger = logging.getLogger(__name__)
IPAddress = Union[IPv4Address, IPv6Address]
IPNetwork = Union[IPv4Network, IPv6Network]
YOOKASSA_ALLOWED_IP_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("185.71.76.0/27"),
ip_network("185.71.77.0/27"),
ip_network("77.75.153.0/25"),
ip_network("77.75.154.128/25"),
ip_network("77.75.156.11/32"),
ip_network("77.75.156.35/32"),
ip_network("2a02:5180::/32"),
)
CLOUDFLARE_TRUSTED_NETWORKS: tuple[IPNetwork, ...] = (
ip_network("173.245.48.0/20"),
ip_network("103.21.244.0/22"),
ip_network("103.22.200.0/22"),
ip_network("103.31.4.0/22"),
ip_network("141.101.64.0/18"),
ip_network("108.162.192.0/18"),
ip_network("190.93.240.0/20"),
ip_network("188.114.96.0/20"),
ip_network("197.234.240.0/22"),
ip_network("198.41.128.0/17"),
ip_network("162.158.0.0/15"),
ip_network("104.16.0.0/13"),
ip_network("104.24.0.0/14"),
ip_network("172.64.0.0/13"),
ip_network("131.0.72.0/22"),
ip_network("2400:cb00::/32"),
ip_network("2606:4700::/32"),
ip_network("2803:f800::/32"),
ip_network("2405:b500::/32"),
ip_network("2405:8100::/32"),
ip_network("2a06:98c0::/29"),
ip_network("2c0f:f248::/32"),
)
YOOKASSA_ALLOWED_EVENTS: tuple[str, ...] = (
"payment.succeeded",
"payment.waiting_for_capture",
"payment.canceled",
)
def collect_yookassa_ip_candidates(*values: Optional[str]) -> List[str]:
candidates: List[str] = []
for value in values:
if not value:
continue
for part in value.split(","):
normalized = part.strip()
if normalized:
candidates.append(normalized)
return candidates
def _parse_candidate_ip(candidate: str) -> Optional[IPAddress]:
value = candidate.strip()
if not value:
return None
if value.startswith("[") and "]" in value:
value = value[1:value.index("]")]
if "%" in value:
value = value.split("%", 1)[0]
if value.count(":") == 1 and "." in value:
host, _, port = value.rpartition(":")
if port.isdigit():
value = host
try:
return ip_address(value)
except ValueError:
return None
def _should_trust_forwarded_headers(remote_ip: Optional[IPAddress]) -> bool:
if remote_ip is None:
return True
if _is_trusted_proxy_ip(remote_ip):
return True
return any(
getattr(remote_ip, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
)
_TRUSTED_PROXY_NETWORKS_CACHE: Tuple[str, Tuple[IPNetwork, ...]] = ("", ())
def _get_trusted_proxy_networks() -> Tuple[IPNetwork, ...]:
global _TRUSTED_PROXY_NETWORKS_CACHE
raw_value = getattr(settings, "YOOKASSA_TRUSTED_PROXY_NETWORKS", "") or ""
cached_raw, cached_networks = _TRUSTED_PROXY_NETWORKS_CACHE
if raw_value == cached_raw:
return cached_networks
networks: List[IPNetwork] = []
for part in raw_value.split(","):
candidate = part.strip()
if not candidate:
continue
try:
networks.append(ip_network(candidate, strict=False))
except ValueError:
logger.warning("Неверная сеть доверенного прокси YooKassa: %s", candidate)
cached_networks = tuple(networks)
_TRUSTED_PROXY_NETWORKS_CACHE = (raw_value, cached_networks)
return cached_networks
def _is_trusted_proxy_ip(ip_object: IPAddress) -> bool:
if any(
getattr(ip_object, attribute)
for attribute in ("is_private", "is_loopback", "is_link_local", "is_reserved")
):
return True
if any(ip_object in network for network in CLOUDFLARE_TRUSTED_NETWORKS):
return True
return any(ip_object in network for network in _get_trusted_proxy_networks())
def resolve_yookassa_ip(
candidates: Iterable[str],
*,
remote: Optional[str] = None,
) -> Optional[IPAddress]:
remote_ip = _parse_candidate_ip(remote) if remote else None
if (
remote_ip is not None
and remote_ip.is_global
and not _is_trusted_proxy_ip(remote_ip)
):
return remote_ip
candidate_list = list(candidates)
if _should_trust_forwarded_headers(remote_ip):
last_hop = remote_ip
for candidate in reversed(candidate_list):
ip_object = _parse_candidate_ip(candidate)
if ip_object is not None:
if last_hop is None or _is_trusted_proxy_ip(last_hop):
if _is_trusted_proxy_ip(ip_object):
last_hop = ip_object
continue
return ip_object
break
if last_hop is not None and not _is_trusted_proxy_ip(last_hop):
return last_hop
return remote_ip if remote_ip is not None else next(
(ip for ip in (_parse_candidate_ip(value) for value in candidate_list) if ip is not None),
None,
)
def is_yookassa_ip_allowed(ip_object: IPAddress) -> bool:
return any(ip_object in network for network in YOOKASSA_ALLOWED_IP_NETWORKS)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
try:
signature_parts = signature.strip().split(' ')
if len(signature_parts) < 4:
logger.error(f"Неверный формат подписи YooKassa: {signature}")
return False
version = signature_parts[0]
payment_id = signature_parts[1]
timestamp = signature_parts[2]
received_signature = signature_parts[3]
if version != "v1":
logger.error(f"Неподдерживаемая версия подписи: {version}")
return False
logger.info(f"Проверка подписи v1 для платежа {payment_id}, timestamp: {timestamp}")
expected_signature_1 = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_1_b64 = base64.b64encode(expected_signature_1).decode('utf-8')
signed_payload_2 = f"{payment_id}.{timestamp}.{body}"
expected_signature_2 = hmac.new(
secret.encode('utf-8'),
signed_payload_2.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_2_b64 = base64.b64encode(expected_signature_2).decode('utf-8')
signed_payload_3 = f"{timestamp}.{body}"
expected_signature_3 = hmac.new(
secret.encode('utf-8'),
signed_payload_3.encode('utf-8'),
hashlib.sha256
).digest()
expected_signature_3_b64 = base64.b64encode(expected_signature_3).decode('utf-8')
logger.debug(f"Получена подпись: {received_signature}")
logger.debug(f"Ожидаемая подпись (вариант 1): {expected_signature_1_b64}")
logger.debug(f"Ожидаемая подпись (вариант 2): {expected_signature_2_b64}")
logger.debug(f"Ожидаемая подпись (вариант 3): {expected_signature_3_b64}")
is_valid = (
hmac.compare_digest(received_signature, expected_signature_1_b64) or
hmac.compare_digest(received_signature, expected_signature_2_b64) or
hmac.compare_digest(received_signature, expected_signature_3_b64)
)
if is_valid:
logger.info("✅ Подпись YooKassa webhook проверена успешно")
else:
logger.warning("⚠️ Подпись YooKassa webhook не совпадает ни с одним вариантом")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи YooKassa: {e}")
return False
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен YooKassa webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
header_ip_candidates = collect_yookassa_ip_candidates(
request.headers.get("X-Forwarded-For"),
request.headers.get("X-Real-IP"),
request.headers.get("Cf-Connecting-Ip"),
)
client_ip = resolve_yookassa_ip(
header_ip_candidates,
remote=request.remote,
)
if client_ip is None:
logger.warning(
"🚫 Не удалось определить IP-адрес отправителя YooKassa webhook. Кандидаты: %s",
header_ip_candidates + ([request.remote] if request.remote else []),
)
return web.Response(status=403, text="Forbidden")
if not is_yookassa_ip_allowed(client_ip):
logger.warning(
"🚫 YooKassa webhook отклонён: IP %s не входит в доверенные диапазоны (%s)",
client_ip,
", ".join(str(network) for network in YOOKASSA_ALLOWED_IP_NETWORKS),
)
return web.Response(status=403, text="Forbidden")
logger.info("🌐 IP-адрес YooKassa подтверждён: %s", client_ip)
body = await request.text()
if not body:
logger.warning("⚠️ Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
logger.info(f"📄 Body: {body}")
signature = request.headers.get('Signature') or request.headers.get('X-YooKassa-Signature')
if settings.YOOKASSA_WEBHOOK_SECRET and signature:
logger.info(f"🔐 Получена подпись: {signature}")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.warning("❌ Подпись не совпала, но продолжаем обработку (режим отладки)")
else:
logger.info("✅ Подпись webhook проверена успешно")
elif settings.YOOKASSA_WEBHOOK_SECRET and not signature:
logger.warning("⚠️ Webhook без подписи, но секрет настроен")
elif signature and not settings.YOOKASSA_WEBHOOK_SECRET:
logger.info("ℹ️ Подпись получена, но проверка отключена (YOOKASSA_WEBHOOK_SECRET не настроен)")
else:
logger.info("ℹ️ Проверка подписи отключена")
if signature:
logger.info("ℹ️ Получена подпись YooKassa: %s", signature)
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"❌ Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"📊 Обработка webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"🔍 Полные данные webhook: {webhook_data}")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("⚠️ Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
# Извлекаем 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")
async for db in get_db():
try:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
# Проверяем, не обрабатывается ли этот платеж уже (защита от дублирования)
from app.database.models import PaymentMethod
from app.database.crud.transaction import get_transaction_by_external_id
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")
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type}")
logger.info(f"✅ Успешно обработан webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=200, text="OK")
else:
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type}")
logger.error(f"❌ Ошибка обработки webhook YooKassa: {event_type} для платежа {yookassa_payment_id}")
return web.Response(status=500, text="Processing error")
finally:
await db.close()
except Exception as e:
logger.error(f"❌ Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
app.router.add_get(webhook_path, self._get_handler)
app.router.add_options(webhook_path, self._options_handler)
logger.info(f"✅ Настроен YooKassa webhook на пути: POST {webhook_path}")
async def _get_handler(self, request: web.Request) -> web.Response:
return web.json_response({
"status": "ok",
@@ -172,7 +315,7 @@ class YooKassaWebhookHandler:
"path": request.path,
"note": "Use POST method for actual webhooks"
})
async def _options_handler(self, request: web.Request) -> web.Response:
return web.Response(
status=200,
@@ -185,46 +328,46 @@ class YooKassaWebhookHandler:
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({
"status": "ok",
"status": "ok",
"service": "yookassa_webhook",
"port": settings.YOOKASSA_WEBHOOK_PORT,
"path": settings.YOOKASSA_WEBHOOK_PATH,
"enabled": settings.is_yookassa_enabled()
})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("ℹ️ YooKassa отключена, webhook сервер не запускается")
return
try:
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host=settings.YOOKASSA_WEBHOOK_HOST,
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(
"✅ YooKassa webhook сервер запущен на %s:%s",
settings.YOOKASSA_WEBHOOK_HOST,
@@ -236,7 +379,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
settings.YOOKASSA_WEBHOOK_PORT,
settings.YOOKASSA_WEBHOOK_PATH,
)
try:
while True:
await asyncio.sleep(1)
@@ -246,7 +389,7 @@ async def start_yookassa_webhook_server(payment_service: PaymentService) -> None
await site.stop()
await runner.cleanup()
logger.info("✅ YooKassa webhook сервер остановлен")
except Exception as e:
logger.error(f"❌ Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
raise
+38 -1
View File
@@ -1 +1,38 @@
# Инициализация админских обработчиков
# Инициализация админских обработчиков
from . import (
backup,
blacklist,
bot_configuration,
bulk_ban,
campaigns,
daily_contests,
contests,
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,
)
+371
View File
@@ -0,0 +1,371 @@
"""
Обработчики админ-панели для управления черным списком
"""
import logging
from aiogram import types
from aiogram.filters import StateFilter
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.states import BlacklistStates
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(BlacklistStates.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
if await state.get_state() != BlacklistStates.waiting_for_blacklist_url.state:
return
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,
StateFilter(BlacklistStates.waiting_for_blacklist_url)
)
+269 -29
View File
@@ -13,6 +13,11 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting, User
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_id,
get_server_squad_by_uuid,
)
from app.localization.texts import get_texts
from app.config import settings
from app.services.remnawave_service import RemnaWaveService
@@ -30,13 +35,23 @@ from app.external.telegram_stars import TelegramStarsService
CATEGORY_PAGE_SIZE = 10
SETTINGS_PAGE_SIZE = 8
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"core": {
"title": "🤖 Основные",
"description": "Базовые настройки бота, обязательные каналы и ключевые сервисы.",
"icon": "🤖",
"categories": ("CORE", "CHANNEL"),
"categories": (
"CORE",
"CHANNEL",
"TIMEZONE",
"DATABASE",
"POSTGRES",
"SQLITE",
"REDIS",
"REMNAWAVE",
),
},
"support": {
"title": "💬 Поддержка",
@@ -46,24 +61,35 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, MulenPay, PAL24, Wata, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
"PAYMENT_VERIFICATION",
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"MULENPAY",
"PAL24",
"WATA",
"PLATEGA",
"TRIBUTE",
"TELEGRAM",
),
},
"subscriptions": {
"title": "📅 Подписки и цены",
"description": "Тарифы, периоды, лимиты трафика и автопродление.",
"description": "Тарифы, простая покупка, периоды, лимиты трафика и автопродление.",
"icon": "📅",
"categories": ("SUBSCRIPTIONS_CORE", "PERIODS", "SUBSCRIPTION_PRICES", "TRAFFIC", "TRAFFIC_PACKAGES", "AUTOPAY"),
"categories": (
"SUBSCRIPTIONS_CORE",
"SIMPLE_SUBSCRIPTION",
"PERIODS",
"SUBSCRIPTION_PRICES",
"TRAFFIC",
"TRAFFIC_PACKAGES",
"AUTOPAY",
),
},
"trial": {
"title": "🎁 Пробный период",
@@ -99,18 +125,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"ADDITIONAL",
),
},
"database": {
"title": "💾 База данных",
"description": "Режим базы, параметры PostgreSQL, SQLite и Redis.",
"icon": "💾",
"categories": ("DATABASE", "POSTGRES", "SQLITE", "REDIS"),
},
"remnawave": {
"title": "🌐 RemnaWave API",
"description": "Интеграция с RemnaWave: URL, ключи и способы авторизации.",
"icon": "🌐",
"categories": ("REMNAWAVE",),
},
"server": {
"title": "📊 Статус серверов",
"description": "Мониторинг серверов, SLA и внешние метрики.",
@@ -127,13 +141,14 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"title": "⚡ Расширенные",
"description": "Web API, webhook, логирование, модерация и режим отладки.",
"icon": "",
"categories": ("WEB_API", "WEBHOOK", "LOG", "MODERATION", "DEBUG"),
},
"external_admin": {
"title": "🛡️ Внешняя админка",
"description": "Токен, по которому внешняя админка проверяет запросы.",
"icon": "🛡️",
"categories": ("EXTERNAL_ADMIN",),
"categories": (
"WEB_API",
"WEBHOOK",
"LOG",
"MODERATION",
"DEBUG",
"EXTERNAL_ADMIN",
),
},
}
@@ -146,12 +161,9 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = (
"referral",
"notifications",
"interface",
"database",
"remnawave",
"server",
"maintenance",
"advanced",
"external_admin",
)
CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple(
@@ -242,6 +254,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
payment_statuses = {
"YooKassa": settings.is_yookassa_enabled(),
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
@@ -298,7 +311,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
settings.REFERRAL_COMMISSION_PERCENT
or settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS
or settings.REFERRAL_INVITER_BONUS_KOPEKS
or settings.get_referred_user_reward_kopeks()
)
return ("🟢", "Программа активна") if active else ("", "Бонусы не заданы")
@@ -1417,6 +1429,16 @@ def _build_setting_keyboard(
for chunk in _chunk(choice_buttons, 2):
rows.append(list(chunk))
if key == "SIMPLE_SUBSCRIPTION_SQUAD_UUID" and not is_read_only:
rows.append([
types.InlineKeyboardButton(
text="🌍 Выбрать сквад",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{callback_token}:1"
),
)
])
if definition.python_type is bool and not is_read_only:
rows.append([
types.InlineKeyboardButton(
@@ -1645,6 +1667,217 @@ async def show_bot_config_category(
await callback.answer()
@admin_required
@error_handler
async def show_simple_subscription_squad_selector(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 5)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if key != "SIMPLE_SUBSCRIPTION_SQUAD_UUID":
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
try:
page = max(1, int(parts[5])) if len(parts) > 5 else 1
except ValueError:
page = 1
limit = SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
total_count = total_count or 0
total_pages = max(1, math.ceil(total_count / limit)) if total_count else 1
if total_count and page > total_pages:
page = total_pages
squads, total_count = await get_all_server_squads(
db,
available_only=False,
page=page,
limit=limit,
)
current_uuid = bot_configuration_service.get_current_value(key) or ""
current_display = "Любой доступный"
if current_uuid:
selected_server = next((srv for srv in squads if srv.squad_uuid == current_uuid), None)
if not selected_server:
selected_server = await get_server_squad_by_uuid(db, current_uuid)
if selected_server:
current_display = selected_server.display_name
else:
current_display = current_uuid
lines = [
"🌍 <b>Выберите сквад для простой покупки</b>",
"",
f"Текущий выбор: {html.escape(current_display)}" if current_display else "Текущий выбор: —",
"",
]
if total_count == 0:
lines.append("❌ Доступные сервера не найдены.")
else:
lines.append("Выберите сервер из списка ниже.")
if total_pages > 1:
lines.append(f"Страница {page}/{total_pages}")
text = "\n".join(lines)
keyboard_rows: List[List[types.InlineKeyboardButton]] = []
for server in squads:
status_icon = (
"" if server.squad_uuid == current_uuid else ("🟢" if server.is_available else "🔒")
)
label_parts = [status_icon, server.display_name]
if server.country_code:
label_parts.append(f"({server.country_code.upper()})")
if isinstance(server.price_kopeks, int) and server.price_kopeks > 0:
try:
label_parts.append(f"{settings.format_price(server.price_kopeks)}")
except Exception:
pass
label = " ".join(label_parts)
keyboard_rows.append([
types.InlineKeyboardButton(
text=label,
callback_data=(
f"botcfg_simple_squad_select:{group_key}:{category_page}:{settings_page}:{token}:{server.id}:{page}"
),
)
])
if total_pages > 1:
nav_row: List[types.InlineKeyboardButton] = []
if page > 1:
nav_row.append(
types.InlineKeyboardButton(
text="⬅️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page - 1}"
),
)
)
if page < total_pages:
nav_row.append(
types.InlineKeyboardButton(
text="➡️",
callback_data=(
f"botcfg_simple_squad:{group_key}:{category_page}:{settings_page}:{token}:{page + 1}"
),
)
)
if nav_row:
keyboard_rows.append(nav_row)
keyboard_rows.append([
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=(
f"botcfg_setting:{group_key}:{category_page}:{settings_page}:{token}"
),
)
])
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def select_simple_subscription_squad(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
parts = callback.data.split(":", 6)
group_key = parts[1] if len(parts) > 1 else CATEGORY_FALLBACK_KEY
try:
category_page = max(1, int(parts[2])) if len(parts) > 2 else 1
except ValueError:
category_page = 1
try:
settings_page = max(1, int(parts[3])) if len(parts) > 3 else 1
except ValueError:
settings_page = 1
token = parts[4] if len(parts) > 4 else ""
try:
server_id = int(parts[5]) if len(parts) > 5 else None
except ValueError:
server_id = None
if server_id is None:
await callback.answer("Не удалось определить сервер", show_alert=True)
return
try:
key = bot_configuration_service.resolve_callback_token(token)
except KeyError:
await callback.answer("Эта настройка больше недоступна", show_alert=True)
return
if bot_configuration_service.is_read_only(key):
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("Сервер не найден", show_alert=True)
return
try:
await bot_configuration_service.set_value(db, key, server.squad_uuid)
except ReadOnlySettingError:
await callback.answer("Эта настройка доступна только для чтения", show_alert=True)
return
await db.commit()
text = _render_setting_text(key)
keyboard = _build_setting_keyboard(key, group_key, category_page, settings_page)
await callback.message.edit_text(text, reply_markup=keyboard)
await _store_setting_context(
state,
key=key,
group_key=group_key,
category_page=category_page,
settings_page=settings_page,
)
await callback.answer("Сквад выбран")
@admin_required
@error_handler
async def test_remnawave_connection(
@@ -1751,7 +1984,7 @@ async def test_payment_provider(
return
amount_kopeks = 10 * 100
description = settings.get_balance_payment_description(amount_kopeks)
description = settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
@@ -2519,6 +2752,14 @@ def register_handlers(dp: Dispatcher) -> None:
test_payment_provider,
F.data.startswith("botcfg_test_payment:"),
)
dp.callback_query.register(
select_simple_subscription_squad,
F.data.startswith("botcfg_simple_squad_select:"),
)
dp.callback_query.register(
show_simple_subscription_squad_selector,
F.data.startswith("botcfg_simple_squad:"),
)
dp.callback_query.register(
show_bot_config_setting,
F.data.startswith("botcfg_setting:"),
@@ -2557,4 +2798,3 @@ def register_handlers(dp: Dispatcher) -> None:
handle_import_message,
BotConfigStates.waiting_for_import_file,
)
+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
)
+7 -2
View File
@@ -46,6 +46,9 @@ def _format_campaign_summary(campaign, texts) -> str:
bonus_info = f"💰 Бонус на баланс: <b>{bonus_text}</b>"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
device_limit = campaign.subscription_device_limit
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
bonus_info = (
"📱 Подписка: <b>{days} д.</b>\n"
"🌐 Трафик: <b>{traffic}</b>\n"
@@ -53,7 +56,7 @@ def _format_campaign_summary(campaign, texts) -> str:
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
devices=device_limit,
)
return (
@@ -935,7 +938,9 @@ async def start_edit_campaign_subscription_devices(
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
current_devices = campaign.subscription_device_limit
if current_devices is None:
current_devices = settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(
+784
View File
@@ -0,0 +1,784 @@
import logging
import math
from datetime import datetime, timezone, time
from zoneinfo import ZoneInfo
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
create_referral_contest,
get_contest_events_count,
get_contest_leaderboard,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
toggle_referral_contest,
update_referral_contest,
delete_referral_contest,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_admin_pagination_keyboard,
get_contest_mode_keyboard,
get_referral_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
PAGE_SIZE = 5
def _ensure_timezone(tz_name: str) -> ZoneInfo:
try:
return ZoneInfo(tz_name)
except Exception: # noqa: BLE001
logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name)
return ZoneInfo("UTC")
def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str:
start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc)
end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc)
start_local = start_local.astimezone(tz)
end_local = end_local.astimezone(tz)
status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t(
"ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен"
)
period = (
f"{start_local.strftime('%d.%m %H:%M')}"
f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})"
)
summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00"
summary_times = contest.daily_summary_times or summary_time
parts = [
f"{status}",
f"Период: <b>{period}</b>",
f"Дневная сводка: <b>{summary_times}</b>",
]
if contest.prize_text:
parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text))
if contest.last_daily_summary_date:
parts.append(
texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format(
date=contest.last_daily_summary_date.strftime("%d.%m")
)
)
return "\n".join(parts)
def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None:
try:
dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M")
except ValueError:
return None
return dt.replace(tzinfo=tz)
def _parse_time(value: str):
try:
return datetime.strptime(value.strip(), "%H:%M").time()
except ValueError:
return None
def _parse_times(value: str) -> list[time]:
times: list[time] = []
for part in value.split(","):
part = part.strip()
if not part:
continue
parsed = _parse_time(part)
if parsed:
times.append(parsed)
return times
@admin_required
@error_handler
async def show_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.message.edit_text(
texts.t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
return
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_root_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def show_referral_contests_menu(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t("ADMIN_CONTESTS_TITLE", "🏆 <b>Конкурсы</b>\n\nВыберите действие:"),
reply_markup=get_admin_contests_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def list_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t(
"ADMIN_CONTESTS_DISABLED",
"Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
),
show_alert=True,
)
return
page = 1
if callback.data.startswith("admin_contests_list_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
total = await get_referral_contests_count(db)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
page = max(1, min(page, total_pages))
offset = (page - 1) * PAGE_SIZE
contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset)
texts = get_texts(db_user.language)
lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 <b>Конкурсы</b>\n")]
if not contests:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for contest in contests:
lines.append(f"• <b>{contest.title}</b> (#{contest.id})")
contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
lines.append(_format_contest_summary(contest, texts, contest_tz))
lines.append("")
keyboard_rows: list[list[types.InlineKeyboardButton]] = []
for contest in contests:
title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..."
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"🔎 {title}",
callback_data=f"admin_contest_view_{contest.id}",
)
]
)
pagination = get_admin_pagination_keyboard(
page,
total_pages,
"admin_contests_list",
back_callback="admin_contests",
language=db_user.language,
)
keyboard_rows.extend(pagination.inline_keyboard)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_contest_details(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
lines = [
f"🏆 <b>{contest.title}</b>",
_format_contest_summary(contest, texts, tz),
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").format(count=total_events),
]
if contest.description:
lines.append("")
lines.append(contest.description)
if leaderboard:
lines.append("")
lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name}{score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest.id,
is_active=contest.is_active,
can_delete=(
not contest.is_active
and (contest.end_at.replace(tzinfo=timezone.utc) if contest.end_at.tzinfo is None else contest.end_at)
< datetime.now(timezone.utc)
),
language=db_user.language,
),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден", show_alert=True)
return
await toggle_referral_contest(db, contest, not contest.is_active)
await show_contest_details(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_summary_times(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
await state.set_state(AdminStates.editing_referral_contest_summary_times)
await state.update_data(contest_id=contest_id)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_contest_view_{contest_id}",
)
]
]
)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_ENTER_DAILY_TIME",
"Во сколько отправлять ежедневные итоги? Формат ЧЧ:ММ или несколько через запятую (12:00,18:00).",
),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_summary_times(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
contest_id = data.get("contest_id")
if not contest_id:
await message.answer(texts.ERROR)
await state.clear()
return
times = _parse_times(message.text or "")
summary_time = times[0] if times else _parse_time(message.text or "")
if not summary_time:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
)
await state.clear()
return
contest = await get_referral_contest(db, int(contest_id))
if not contest:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
await update_referral_contest(
db,
contest,
daily_summary_time=summary_time,
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
await state.clear()
@admin_required
@error_handler
async def delete_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
now_utc = datetime.utcnow()
if contest.is_active or contest.end_at > now_utc:
await callback.answer(
texts.t("ADMIN_CONTEST_DELETE_RESTRICT", "Удалять можно только завершённые конкурсы."),
show_alert=True,
)
return
await delete_referral_contest(db, contest)
await callback.answer(texts.t("ADMIN_CONTEST_DELETED", "Конкурс удалён."), show_alert=True)
await list_contests(callback, db_user, db)
@admin_required
@error_handler
async def show_leaderboard(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
texts = get_texts(db_user.language)
if not contest:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True)
return
lines = [
texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f"{idx}. {user.full_name} ({user.telegram_id}) — {score}")
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def start_contest_creation(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_contests_enabled():
await callback.answer(
texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
await state.clear()
await state.set_state(AdminStates.creating_referral_contest_mode)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_MODE_PROMPT",
"Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
),
reply_markup=get_contest_mode_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def select_contest_mode(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered"
await state.update_data(contest_type=mode)
await state.set_state(AdminStates.creating_referral_contest_title)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"),
reply_markup=None,
)
await callback.answer()
@admin_required
@error_handler
async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
title = message.text.strip()
texts = get_texts(db_user.language)
await state.update_data(title=title)
await state.set_state(AdminStates.creating_referral_contest_description)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
description = message.text.strip()
if description in {"-", "skip", "пропустить"}:
description = None
await state.update_data(description=description)
await state.set_state(AdminStates.creating_referral_contest_prize)
texts = get_texts(db_user.language)
await message.answer(
texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):")
)
@admin_required
@error_handler
async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
prize = message.text.strip()
if prize in {"-", "skip", "пропустить"}:
prize = None
await state.update_data(prize=prize)
await state.set_state(AdminStates.creating_referral_contest_start)
texts = get_texts(db_user.language)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_START",
"Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
start_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not start_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
await state.update_data(start_at=start_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_end)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_END",
"Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
)
)
@admin_required
@error_handler
async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
tz = _ensure_timezone(settings.TIMEZONE)
end_dt = _parse_local_datetime(message.text, tz)
texts = get_texts(db_user.language)
if not end_dt:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00")
)
return
data = await state.get_data()
start_raw = data.get("start_at")
start_dt = datetime.fromisoformat(start_raw) if start_raw else None
if start_dt and end_dt <= start_dt:
await message.answer(
texts.t(
"ADMIN_CONTEST_END_BEFORE_START",
"Дата окончания должна быть позже даты начала.",
)
)
return
await state.update_data(end_at=end_dt.isoformat())
await state.set_state(AdminStates.creating_referral_contest_time)
await message.answer(
texts.t(
"ADMIN_CONTEST_ENTER_DAILY_TIME",
"Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).",
)
)
@admin_required
@error_handler
async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
times = _parse_times(message.text or "")
summary_time = times[0] if times else _parse_time(message.text)
texts = get_texts(db_user.language)
if not summary_time:
await message.answer(
texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00 или 12:00,18:00")
)
return
data = await state.get_data()
tz = _ensure_timezone(settings.TIMEZONE)
start_at_raw = data.get("start_at")
end_at_raw = data.get("end_at")
if not start_at_raw or not end_at_raw:
await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату."))
return
start_at = (
datetime.fromisoformat(start_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
end_at = (
datetime.fromisoformat(end_at_raw)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
contest_type = data.get("contest_type") or "referral_paid"
contest = await create_referral_contest(
db,
title=data.get("title"),
description=data.get("description"),
prize_text=data.get("prize"),
contest_type=contest_type,
start_at=start_at,
end_at=end_at,
daily_summary_time=summary_time,
daily_summary_times=",".join(t.strftime("%H:%M") for t in times) if times else None,
timezone_name=tz.key,
created_by=db_user.id,
)
await state.clear()
await message.answer(
texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"),
reply_markup=get_referral_contest_manage_keyboard(
contest.id,
is_active=contest.is_active,
language=db_user.language,
),
)
@admin_required
@error_handler
async def show_detailed_stats(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден.", show_alert=True)
return
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
# Общее сообщение с основной статистикой
general_lines = [
"📈 <b>Статистика конкурса</b>",
f"🏆 {contest.title}",
"",
f"👥 Участников: <b>{stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
]
await callback.message.edit_text(
"\n".join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def show_detailed_stats_page(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
contest_id: int = None,
page: int = 1,
stats: dict = None,
):
if contest_id is None or stats is None:
# Парсим из callback.data: admin_contest_detailed_stats_page_{contest_id}_page_{page}
parts = callback.data.split("_")
contest_id = int(parts[5]) # contest_id после page
page = int(parts[7]) # page после второго page
# Получаем stats если не переданы
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
participants = stats['participants']
total_participants = len(participants)
PAGE_SIZE = 10
total_pages = math.ceil(total_participants / PAGE_SIZE)
page = max(1, min(page, total_pages))
offset = (page - 1) * PAGE_SIZE
page_participants = participants[offset:offset + PAGE_SIZE]
lines = [f"📊 По участникам (страница {page}/{total_pages}):"]
for p in page_participants:
lines.extend([
f"• <b>{p['full_name']}</b>",
f" 📨 Приглашено: {p['total_referrals']}",
f" 💰 Оплатили: {p['paid_referrals']}",
f" ❌ Не оплатили: {p['unpaid_referrals']}",
f" 💵 Сумма: {p['total_paid_amount'] // 100} руб.",
"" # Пустая строка для разделения
])
pagination = get_admin_pagination_keyboard(
page,
total_pages,
f"admin_contest_detailed_stats_page_{contest_id}",
back_callback=f"admin_contest_view_{contest_id}",
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(lines),
reply_markup=pagination,
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
dp.callback_query.register(list_contests, F.data == "admin_contests_list")
dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_"))
dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_"))
dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_"))
dp.callback_query.register(prompt_edit_summary_times, F.data.startswith("admin_contest_edit_times_"))
dp.callback_query.register(delete_contest, F.data.startswith("admin_contest_delete_"))
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
dp.message.register(process_title, AdminStates.creating_referral_contest_title)
dp.message.register(process_description, AdminStates.creating_referral_contest_description)
dp.message.register(process_prize, AdminStates.creating_referral_contest_prize)
dp.message.register(process_start_date, AdminStates.creating_referral_contest_start)
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
+550
View File
@@ -0,0 +1,550 @@
import json
import logging
from datetime import datetime, timedelta
from typing import Dict
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.crud.contest import (
get_template_by_id,
list_templates,
update_template_fields,
create_round,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
get_admin_contests_keyboard,
get_admin_contests_root_keyboard,
get_daily_contest_manage_keyboard,
)
from app.localization.texts import get_texts
from app.services.contest_rotation_service import contest_rotation_service
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
"schedule_times": {"type": str, "label": "расписание HH:MM через запятую"},
"cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"},
}
async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None:
return await get_template_by_id(db, template_id)
@admin_required
@error_handler
async def show_daily_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=False)
lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")]
if not templates:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
keyboard_rows.append([types.InlineKeyboardButton(text="❌ Закрыть все активные раунды", callback_data="admin_daily_close_all")])
keyboard_rows.append([types.InlineKeyboardButton(text=" Сбросить попытки во всех активных раундах", callback_data="admin_daily_reset_all_attempts")])
keyboard_rows.append([types.InlineKeyboardButton(text=" Запустить все активные конкурсы", callback_data="admin_daily_start_all")])
for tpl in templates:
keyboard_rows.append(
[
types.InlineKeyboardButton(
text=f"⚙️ {tpl.name}",
callback_data=f"admin_daily_contest_{tpl.id}",
)
]
)
keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")])
await callback.message.edit_text(
"\n".join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
try:
template_id = int(callback.data.split("_")[-1])
except Exception:
await callback.answer("Некорректный id", show_alert=True)
return
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
lines = [
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
f"Длительность раунда: {tpl.cooldown_hours} ч.",
]
await callback.message.edit_text(
"\n".join(lines),
reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def toggle_daily_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
tpl.is_enabled = not tpl.is_enabled
await db.commit()
await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено"))
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def start_round_now(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
if not tpl.is_enabled:
tpl.is_enabled = True
await db.commit()
await db.refresh(tpl)
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def manual_start_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
# Проверяем, есть ли уже активный раунд для этого шаблона
from app.database.crud.contest import get_active_rounds
exists = await get_active_rounds(db, tpl.id)
if exists:
await callback.answer(texts.t("ADMIN_ROUND_ALREADY_ACTIVE", "Раунд уже активен."), show_alert=True)
await show_daily_contest(callback, db_user, db)
return
# Для ручного старта не включаем конкурс, если он выключен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
# Анонсируем всем пользователям (как тест)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Тестовый раунд запущен"), show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def prompt_edit_field(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
parts = callback.data.split("_")
template_id = int(parts[3])
field = "_".join(parts[4:]) # поле может содержать подчеркивания
tpl = await _get_template(db, template_id)
if not tpl or field not in EDITABLE_FIELDS:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
meta = EDITABLE_FIELDS[field]
await state.set_state(AdminStates.editing_daily_contest_field)
await state.update_data(template_id=template_id, field=field)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t(
"ADMIN_CONTEST_FIELD_PROMPT",
"Введите новое значение для {label}:",
).format(label=meta.get("label", field)),
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_field(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
field = data.get("field")
if not template_id or not field or field not in EDITABLE_FIELDS:
await message.answer(texts.ERROR)
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
meta = EDITABLE_FIELDS[field]
raw = message.text or ""
try:
if meta["type"] is int:
value = int(raw)
if meta.get("min") is not None and value < meta["min"]:
raise ValueError("min")
else:
value = raw.strip()
except Exception:
await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число"))
await state.clear()
return
await update_template_fields(db, tpl, **{field: value})
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def edit_payload(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
await state.set_state(AdminStates.editing_daily_contest_value)
await state.update_data(template_id=template_id, field="payload")
payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2)
kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await callback.message.edit_text(
texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"<code>{payload_json}</code>",
reply_markup=kb,
)
await callback.answer()
@admin_required
@error_handler
async def process_payload(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
data = await state.get_data()
template_id = data.get("template_id")
if not template_id:
await message.answer(texts.ERROR)
await state.clear()
return
try:
payload = json.loads(message.text or "{}")
if not isinstance(payload, dict):
raise ValueError
except Exception:
await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON"))
await state.clear()
return
tpl = await _get_template(db, template_id)
if not tpl:
await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."))
await state.clear()
return
await update_template_fields(db, tpl, payload=payload)
back_kb = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_daily_contest_{template_id}",
)
]
]
)
await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=back_kb)
await state.clear()
@admin_required
@error_handler
async def start_all_contests(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
templates = await list_templates(db, enabled_only=True)
if not templates:
await callback.answer(texts.t("ADMIN_CONTESTS_EMPTY", "Нет активных конкурсов."), show_alert=True)
return
started_count = 0
for tpl in templates:
from app.database.crud.contest import get_active_round_by_template
exists = await get_active_round_by_template(db, tpl.id)
if exists:
continue # уже запущен
payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined]
now = datetime.utcnow()
ends = now + timedelta(hours=tpl.cooldown_hours)
round_obj = await create_round(
db,
template=tpl,
starts_at=now,
ends_at=ends,
payload=payload,
)
await contest_rotation_service._announce_round_start( # type: ignore[attr-defined]
tpl,
now.replace(tzinfo=None),
ends.replace(tzinfo=None),
)
started_count += 1
message = f"Запущено конкурсов: {started_count}"
await callback.answer(message, show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def close_all_rounds(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
for rnd in active_rounds:
rnd.status = "finished"
await db.commit()
await callback.answer(f"Закрыто раундов: {len(active_rounds)}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_all_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
from app.database.crud.contest import get_active_rounds
active_rounds = await get_active_rounds(db)
if not active_rounds:
await callback.answer("Нет активных раундов", show_alert=True)
return
total_deleted = 0
for rnd in active_rounds:
deleted = await clear_attempts(db, rnd.id)
total_deleted += deleted
await callback.answer(f"Попытки сброшены: {total_deleted}", show_alert=True)
await show_daily_contests(callback, db_user, db)
@admin_required
@error_handler
async def reset_attempts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template, clear_attempts
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
deleted_count = await clear_attempts(db, round_obj.id)
await callback.answer(f"Попытки сброшены: {deleted_count}", show_alert=True)
await show_daily_contest(callback, db_user, db)
@admin_required
@error_handler
async def close_round(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
texts = get_texts(db_user.language)
template_id = int(callback.data.split("_")[-1])
tpl = await _get_template(db, template_id)
if not tpl:
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
from app.database.crud.contest import get_active_round_by_template
round_obj = await get_active_round_by_template(db, tpl.id)
if not round_obj:
await callback.answer("Нет активного раунда", show_alert=True)
return
round_obj.status = "finished"
await db.commit()
await db.refresh(round_obj)
await callback.answer("Раунд закрыт", show_alert=True)
await show_daily_contest(callback, db_user, db)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily")
dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_"))
dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_"))
dp.callback_query.register(start_all_contests, F.data == "admin_daily_start_all")
dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_"))
dp.callback_query.register(manual_start_round, F.data.startswith("admin_daily_manual_"))
dp.callback_query.register(close_all_rounds, F.data == "admin_daily_close_all")
dp.callback_query.register(reset_all_attempts, F.data == "admin_daily_reset_all_attempts")
dp.callback_query.register(reset_attempts, F.data.startswith("admin_daily_reset_attempts_"))
dp.callback_query.register(close_round, F.data.startswith("admin_daily_close_"))
dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_"))
dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_"))
dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field)
dp.message.register(process_payload, AdminStates.editing_daily_contest_value)
+1 -1
View File
@@ -449,4 +449,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(
admin_commands_help,
Command("admin_help")
)
)
+6 -1
View File
@@ -74,6 +74,7 @@ async def show_maintenance_panel(
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
🛠 <b>Автозапуск мониторинга:</b> {'Включен' if status_info['monitoring_configured'] else 'Отключен'}
<b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
@@ -233,7 +234,11 @@ async def check_panel_status(
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
attempts_used = status_data.get("attempts_used")
if attempts_used:
message_parts.append(f"🔁 Попыток проверки: {attempts_used}")
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
+264 -45
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,54 +817,103 @@ async def confirm_broadcast(
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
for user in users:
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
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов 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:
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,
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} сек."
)
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
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}"
)
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
return False, user.telegram_id
except Exception as e:
logger.error(
f"Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}"
)
else:
await callback.bot.send_message(
chat_id=user.telegram_id,
text=message_text,
parse_mode="HTML",
reply_markup=broadcast_keyboard
)
sent_count += 1
if sent_count % 20 == 0:
await asyncio.sleep(1)
except Exception as e:
failed_count += 1
logger.error(f"Ошибка отправки рассылки пользователю {user.telegram_id}: {e}")
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
# Отправляем сообщения пакетами для эффективности
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]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
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:
@@ -847,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
@@ -928,6 +1047,106 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
and (user.subscription.traffic_used_gb or 0) <= 0
]
if target == "expiring_subscribers":
expiring_subs = await get_expiring_subscriptions(db, 7)
return [sub.user for sub in expiring_subs if sub.user]
if target == "expired_subscribers":
now = datetime.utcnow()
expired_statuses = {
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
}
expired_users = []
for user in users:
subscription = user.subscription
if subscription:
if subscription.status in expired_statuses:
expired_users.append(user)
continue
if subscription.end_date <= now and not subscription.is_active:
expired_users.append(user)
continue
elif user.has_had_paid_subscription:
expired_users.append(user)
return expired_users
if target == "canceled_subscribers":
return [
user
for user in users
if user.subscription
and user.subscription.status == SubscriptionStatus.DISABLED.value
]
if target == "trial_ending":
now = datetime.utcnow()
in_3_days = now + timedelta(days=3)
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.is_active
and user.subscription.end_date <= in_3_days
]
if target == "trial_expired":
now = datetime.utcnow()
return [
user
for user in users
if user.subscription
and user.subscription.is_trial
and user.subscription.end_date <= now
]
if target == "autopay_failed":
from app.database.models import SubscriptionEvent
week_ago = datetime.utcnow() - timedelta(days=7)
stmt = select(SubscriptionEvent.user_id).where(
and_(
SubscriptionEvent.event_type == "autopay_failed",
SubscriptionEvent.occurred_at >= week_ago,
)
).distinct()
result = await db.execute(stmt)
failed_user_ids = set(result.scalars().all())
return [user for user in users if user.id in failed_user_ids]
if target == "low_balance":
threshold_kopeks = 10000 # 100 рублей
return [
user
for user in users
if (user.balance_kopeks or 0) < threshold_kopeks
and (user.balance_kopeks or 0) > 0
]
if target == "inactive_30d":
threshold = datetime.utcnow() - timedelta(days=30)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
if target == "inactive_60d":
threshold = datetime.utcnow() - timedelta(days=60)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
if target == "inactive_90d":
threshold = datetime.utcnow() - timedelta(days=90)
return [
user
for user in users
if user.last_activity and user.last_activity < threshold
]
return []
+15 -1
View File
@@ -1027,6 +1027,20 @@ async def process_notification_value_input(message: Message, state: FSMContext):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
# Добавляем дополнительные проверки диапазона значений
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
if value < 0 or value > 100:
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
return
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
await message.answer("❌ Количество часов должно быть от 1 до 168.")
return
elif key == "expired_third_wave" and field == "trigger":
if value < 2: # Минимум 2 дня
await message.answer("❌ Количество дней должно быть не менее 2.")
return
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
@@ -1075,4 +1089,4 @@ async def process_notification_value_input(message: Message, state: FSMContext):
def register_handlers(dp):
dp.include_router(router)
dp.include_router(router)
+612
View File
@@ -0,0 +1,612 @@
from __future__ import annotations
import html
import math
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, User
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
PendingPayment,
SUPPORTED_MANUAL_CHECK_METHODS,
get_payment_record,
list_recent_pending_payments,
run_manual_check,
)
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago, format_username
PAGE_SIZE = 6
def _method_display(method: PaymentMethod) -> str:
if method == PaymentMethod.MULENPAY:
return settings.get_mulenpay_display_name()
if method == PaymentMethod.PAL24:
return "PayPalych"
if method == PaymentMethod.WATA:
return "WATA"
if method == PaymentMethod.HELEKET:
return "Heleket"
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
return "Telegram Stars"
return method.value
def _status_info(
record: PendingPayment,
*,
texts,
) -> tuple[str, str]:
status = (record.status or "").lower()
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if record.method == PaymentMethod.PAL24:
mapping = {
"new": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"process": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"fail": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.MULENPAY:
mapping = {
"created": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"hold": ("🔒", texts.t("ADMIN_PAYMENT_STATUS_ON_HOLD", "🔒 Hold")),
"success": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancel": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"error": ("⚠️", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.WATA:
mapping = {
"opened": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"processing": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"closed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"declined": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.PLATEGA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"inprogress": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"confirmed": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"failed": ("", texts.t("ADMIN_PAYMENT_STATUS_FAILED", "❌ Failed")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"cancelled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.HELEKET:
if status in {"pending", "created", "waiting", "check", "processing"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
if status in {"paid", "paid_over"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
if status in {"cancel", "canceled", "fail", "failed", "expired"}:
return "", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
if record.method == PaymentMethod.YOOKASSA:
mapping = {
"pending": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"waiting_for_capture": ("", texts.t("ADMIN_PAYMENT_STATUS_PROCESSING", "⌛ Processing")),
"succeeded": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"canceled": ("", texts.t("ADMIN_PAYMENT_STATUS_CANCELED", "❌ Cancelled")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
"active": ("", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")),
"paid": ("", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")),
"expired": ("", texts.t("ADMIN_PAYMENT_STATUS_EXPIRED", "⌛ Expired")),
}
return mapping.get(status, ("", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")))
if record.method == PaymentMethod.TELEGRAM_STARS:
if record.is_paid:
return "", texts.t("ADMIN_PAYMENT_STATUS_PAID", "✅ Paid")
return "", texts.t("ADMIN_PAYMENT_STATUS_PENDING", "⏳ Pending")
return "", texts.t("ADMIN_PAYMENT_STATUS_UNKNOWN", "❓ Unknown")
def _is_checkable(record: PendingPayment) -> bool:
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status = (record.status or "").lower()
if record.method == PaymentMethod.PAL24:
return status in {"new", "process"}
if record.method == PaymentMethod.MULENPAY:
return status in {"created", "processing", "hold"}
if record.method == PaymentMethod.WATA:
return status in {"opened", "pending", "processing", "inprogress", "in_progress"}
if record.method == PaymentMethod.PLATEGA:
return status in {"pending", "inprogress", "in_progress"}
if record.method == PaymentMethod.HELEKET:
return status not in {"paid", "paid_over", "cancel", "canceled", "fail", "failed", "expired"}
if record.method == PaymentMethod.YOOKASSA:
return status in {"pending", "waiting_for_capture"}
if record.method == PaymentMethod.CRYPTOBOT:
return status in {"active"}
return False
def _record_display_number(record: PendingPayment) -> str:
if record.identifier:
return str(record.identifier)
return str(record.local_id)
def _build_list_keyboard(
records: list[PendingPayment],
*,
page: int,
total_pages: int,
language: str,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
for record in records:
number = _record_display_number(record)
details_template = texts.t("ADMIN_PAYMENTS_ITEM_DETAILS", "📄 #{number}")
try:
button_text = details_template.format(number=number)
except Exception: # pragma: no cover - fallback for broken localization
button_text = f"📄 {number}"
buttons.append(
[
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_payment_{record.method.value}_{record.local_id}",
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
navigation_row.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_payments_page_{page - 1}",
)
)
navigation_row.append(
InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data="admin_payments_page_current",
)
)
if page < total_pages:
navigation_row.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"admin_payments_page_{page + 1}",
)
)
buttons.append(navigation_row)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_panel")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_detail_keyboard(
record: PendingPayment,
*,
language: str,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
rows: list[list[InlineKeyboardButton]] = []
payment = record.payment
payment_url = getattr(payment, "payment_url", None)
if record.method == PaymentMethod.PAL24:
payment_url = payment.link_url or payment.link_page_url or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = payment.url or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, "confirmation_url", None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
payment.bot_invoice_url
or payment.mini_app_invoice_url
or payment.web_app_invoice_url
or payment_url
)
if payment_url:
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_OPEN_LINK", "🔗 Open link"),
url=payment_url,
)
]
)
if _is_checkable(record):
rows.append(
[
InlineKeyboardButton(
text=texts.t("ADMIN_PAYMENT_CHECK_BUTTON", "🔁 Check status"),
callback_data=f"admin_payment_check_{record.method.value}_{record.local_id}",
)
]
)
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="admin_payments")])
return InlineKeyboardMarkup(inline_keyboard=rows)
def _format_user_line(user: User) -> str:
username = format_username(user.username, user.telegram_id, user.full_name)
return f"👤 {html.escape(username)} (<code>{user.telegram_id}</code>)"
def _build_record_lines(
record: PendingPayment,
*,
index: int,
texts,
language: str,
) -> list[str]:
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
identifier = (
html.escape(str(record.identifier)) if record.identifier else ""
)
display_number = html.escape(_record_display_number(record))
lines = [
f"{index}. <b>{html.escape(method_name)}</b> — {amount}",
f" {emoji} {status_text}",
f" 🕒 {created} ({age})",
_format_user_line(record.user),
]
if identifier:
lines.append(f" 🆔 ID: <code>{identifier}</code>")
else:
lines.append(f" 🆔 ID: <code>{display_number}</code>")
return lines
def _build_payment_details_text(record: PendingPayment, *, texts, language: str) -> str:
method_name = _method_display(record.method)
emoji, status_text = _status_info(record, texts=texts)
amount = settings.format_price(record.amount_kopeks)
if record.method == PaymentMethod.CRYPTOBOT:
crypto_amount = getattr(record.payment, "amount", None)
crypto_asset = getattr(record.payment, "asset", None)
if crypto_amount and crypto_asset:
amount = f"{crypto_amount} {crypto_asset}"
created = format_datetime(record.created_at)
age = format_time_ago(record.created_at, language)
raw_identifier = record.identifier if record.identifier else record.local_id
identifier = html.escape(str(raw_identifier)) if raw_identifier is not None else ""
lines = [
texts.t("ADMIN_PAYMENT_DETAILS_TITLE", "💳 <b>Payment details</b>"),
"",
f"<b>{html.escape(method_name)}</b>",
f"{emoji} {status_text}",
"",
f"💰 {texts.t('ADMIN_PAYMENT_AMOUNT', 'Amount')}: {amount}",
f"🕒 {texts.t('ADMIN_PAYMENT_CREATED', 'Created')}: {created} ({age})",
f"🆔 ID: <code>{identifier}</code>",
_format_user_line(record.user),
]
if record.expires_at:
expires_at = format_datetime(record.expires_at)
lines.append(f"{texts.t('ADMIN_PAYMENT_EXPIRES', 'Expires')}: {expires_at}")
payment = record.payment
if record.method == PaymentMethod.PAL24:
if getattr(payment, "payment_status", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.payment_status))}"
)
if getattr(payment, "payment_method", None):
lines.append(
f"🏦 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method))}"
)
if getattr(payment, "balance_amount", None):
lines.append(
f"💱 {texts.t('ADMIN_PAYMENT_GATEWAY_AMOUNT', 'Gateway amount')}: "
f"{html.escape(str(payment.balance_amount))}"
)
if getattr(payment, "payer_account", None):
lines.append(
f"👛 {texts.t('ADMIN_PAYMENT_GATEWAY_ACCOUNT', 'Payer account')}: "
f"{html.escape(str(payment.payer_account))}"
)
if record.method == PaymentMethod.MULENPAY:
if getattr(payment, "mulen_payment_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.mulen_payment_id))}"
)
if record.method == PaymentMethod.WATA:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "terminal_public_id", None):
lines.append(
f"🏦 Terminal: {html.escape(str(payment.terminal_public_id))}"
)
if record.method == PaymentMethod.HELEKET:
if getattr(payment, "order_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.order_id))}"
)
if getattr(payment, "payer_amount", None) and getattr(payment, "payer_currency", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_PAYER_AMOUNT', 'Paid amount')}: "
f"{html.escape(str(payment.payer_amount))} {html.escape(str(payment.payer_currency))}"
)
if record.method == PaymentMethod.YOOKASSA:
if getattr(payment, "payment_method_type", None):
lines.append(
f"💳 {texts.t('ADMIN_PAYMENT_GATEWAY_METHOD', 'Method')}: "
f"{html.escape(str(payment.payment_method_type))}"
)
if getattr(payment, "confirmation_url", None):
lines.append(texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above."))
if record.method == PaymentMethod.CRYPTOBOT:
if getattr(payment, "amount", None) and getattr(payment, "asset", None):
lines.append(
f"🪙 {texts.t('ADMIN_PAYMENT_CRYPTO_AMOUNT', 'Crypto amount')}: "
f"{html.escape(str(payment.amount))} {html.escape(str(payment.asset))}"
)
if getattr(payment, "bot_invoice_url", None) or getattr(payment, "mini_app_invoice_url", None):
lines.append(
texts.t("ADMIN_PAYMENT_HAS_LINK", "🔗 Payment link is available above.")
)
if getattr(payment, "status", None):
lines.append(
f"📊 {texts.t('ADMIN_PAYMENT_GATEWAY_STATUS', 'Gateway status')}: "
f"{html.escape(str(payment.status))}"
)
if record.method == PaymentMethod.TELEGRAM_STARS:
description = getattr(payment, "description", "") or ""
if description:
lines.append(f"📝 {html.escape(description)}")
if getattr(payment, "external_id", None):
lines.append(
f"🧾 {texts.t('ADMIN_PAYMENT_GATEWAY_ID', 'Gateway ID')}: "
f"{html.escape(str(payment.external_id))}"
)
if _is_checkable(record):
lines.append("")
lines.append(texts.t("ADMIN_PAYMENT_CHECK_HINT", "️ You can trigger a manual status check."))
return "\n".join(lines)
def _parse_method_and_id(payload: str, *, prefix: str) -> Optional[tuple[PaymentMethod, int]]:
suffix = payload[len(prefix) :]
try:
method_str, identifier = suffix.rsplit("_", 1)
method = PaymentMethod(method_str)
payment_id = int(identifier)
return method, payment_id
except (ValueError, KeyError):
return None
@admin_required
@error_handler
async def show_payments_overview(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
texts = get_texts(db_user.language)
page = 1
if callback.data.startswith("admin_payments_page_"):
try:
page = int(callback.data.split("_")[-1])
except ValueError:
page = 1
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page < 1:
page = 1
if page > total_pages:
page = total_pages
start_index = (page - 1) * PAGE_SIZE
page_records = records[start_index : start_index + PAGE_SIZE]
header = texts.t("ADMIN_PAYMENTS_TITLE", "💳 <b>Top-up verification</b>")
description = texts.t(
"ADMIN_PAYMENTS_DESCRIPTION",
"Pending invoices created during the last 24 hours.",
)
notice = texts.t(
"ADMIN_PAYMENTS_NOTICE",
"Only invoices younger than 24 hours and waiting for payment can be checked.",
)
lines = [header, "", description]
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append("")
lines.append(notice)
else:
empty_text = texts.t("ADMIN_PAYMENTS_EMPTY", "No pending top-ups in the last 24 hours.")
lines.append("")
lines.append(empty_text)
keyboard = _build_list_keyboard(
page_records,
page=page,
total_pages=total_pages,
language=db_user.language,
)
await callback.message.edit_text(
"\n".join(line for line in lines if line is not None),
parse_mode="HTML",
reply_markup=keyboard,
)
await callback.answer()
async def _render_payment_details(
callback: types.CallbackQuery,
db_user: User,
record: PendingPayment,
) -> None:
texts = get_texts(db_user.language)
text = _build_payment_details_text(record, texts=texts, language=db_user.language)
keyboard = _build_detail_keyboard(record, language=db_user.language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
@admin_required
@error_handler
async def show_payment_details(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
if not record:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
await _render_payment_details(callback, db_user, record)
await callback.answer()
@admin_required
@error_handler
async def manual_check_payment(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
parsed = _parse_method_and_id(callback.data, prefix="admin_payment_check_")
if not parsed:
await callback.answer("❌ Invalid payment reference", show_alert=True)
return
method, payment_id = parsed
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
await callback.answer(texts.t("ADMIN_PAYMENT_NOT_FOUND", "Payment not found."), show_alert=True)
return
if not _is_checkable(record):
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_NOT_AVAILABLE", "Manual check is not available for this invoice."),
show_alert=True,
)
return
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
if not updated:
await callback.answer(
texts.t("ADMIN_PAYMENT_CHECK_FAILED", "Failed to refresh the payment status."),
show_alert=True,
)
return
await _render_payment_details(callback, db_user, updated)
if updated.status != record.status or updated.is_paid != record.is_paid:
emoji, status_text = _status_info(updated, texts=texts)
message = texts.t(
"ADMIN_PAYMENT_CHECK_SUCCESS",
"Status updated: {status}",
).format(status=f"{emoji} {status_text}")
else:
message = texts.t(
"ADMIN_PAYMENT_CHECK_NO_CHANGES",
"Status is unchanged after the check.",
)
await callback.answer(message, show_alert=True)
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(manual_check_payment, F.data.startswith("admin_payment_check_"))
dp.callback_query.register(
show_payment_details,
F.data.startswith("admin_payment_") & ~F.data.startswith("admin_payment_check_"),
)
dp.callback_query.register(show_payments_overview, F.data.startswith("admin_payments_page_"))
dp.callback_query.register(show_payments_overview, F.data == "admin_payments")
File diff suppressed because it is too large Load Diff
+73 -16
View File
@@ -96,6 +96,24 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
label_en="📱 Device limit",
action="input",
),
SettingEntry(
key="TRIAL_PAYMENT_ENABLED",
section="trial",
label_ru="💳 Платная активация",
label_en="💳 Paid activation",
action="toggle",
description_ru="Если включено — за активацию триала будет списываться указанная сумма.",
description_en="When enabled, the configured amount is charged during trial activation.",
),
SettingEntry(
key="TRIAL_ACTIVATION_PRICE",
section="trial",
label_ru="💰 Стоимость активации",
label_en="💰 Activation price",
action="price",
description_ru="Указывается в копейках. 0 — бесплатная активация.",
description_en="Amount in kopeks. 0 — free activation.",
),
SettingEntry(
key="TRIAL_ADD_REMAINING_DAYS_TO_PAID",
section="trial",
@@ -105,15 +123,6 @@ TRIAL_ENTRIES: Tuple[SettingEntry, ...] = (
description_ru="Если включено — при покупке платной подписки оставшиеся дни триала будут добавлены к сроку.",
description_en="When enabled, remaining trial days are added to paid subscription duration.",
),
SettingEntry(
key="TRIAL_SQUAD_UUID",
section="trial",
label_ru="🆔 Squad UUID",
label_en="🆔 Squad UUID",
action="input",
description_ru="Можно оставить пустым, если не требуется назначать пробные подписки в конкретный Squad.",
description_en="Leave empty if trial subscriptions shouldn't be bound to a specific Squad.",
),
)
@@ -125,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",
@@ -196,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"])
@@ -290,11 +337,14 @@ def _format_trial_summary(lang_code: str) -> str:
duration = settings.TRIAL_DURATION_DAYS
traffic = settings.TRIAL_TRAFFIC_LIMIT_GB
devices = settings.TRIAL_DEVICE_LIMIT
price_note = ""
if settings.is_trial_paid_activation_enabled():
price_note = f", 💳 {settings.format_price(settings.get_trial_activation_price())}"
traffic_label = _format_traffic_label(traffic, lang_code, short=True)
devices_label = f"{devices}📱" if lang_code == "ru" else f"{devices}📱"
days_suffix = "д" if lang_code == "ru" else "d"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}"
return f"{duration}{days_suffix}, {traffic_label}, {devices_label}{price_note}"
def _format_core_summary(lang_code: str) -> str:
@@ -435,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)}"
),
)
]
)
@@ -449,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}"
),
)
)
@@ -465,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)}"
),
)
]
)
@@ -927,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)
@@ -1128,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()
@@ -1159,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()
+104 -1
View File
@@ -211,6 +211,14 @@ def _format_rubles(amount_kopeks: int) -> str:
return formatted.replace(",", " ")
def _format_priority_line(texts, group: PromoGroup) -> str:
priority = getattr(group, "priority", 0)
return texts.t(
"ADMIN_PROMO_GROUP_PRIORITY_LINE",
"🎯 Приоритет: {priority}",
).format(priority=priority)
def _format_auto_assign_line(texts, group: PromoGroup) -> str:
threshold = getattr(group, "auto_assign_total_spent_kopeks", 0) or 0
@@ -294,6 +302,7 @@ def _build_edit_menu_content(
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_priority_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
@@ -318,6 +327,15 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_name",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY",
"🎯 Приоритет",
),
callback_data=f"promo_group_edit_field_{group.id}_priority",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -640,6 +658,32 @@ async def process_create_group_name(message: types.Message, state: FSMContext):
return
await state.update_data(new_group_name=name)
await state.set_state(AdminStates.creating_promo_group_priority)
texts = get_texts((await state.get_data()).get("language", "ru"))
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT",
"Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
)
)
async def process_create_group_priority(message: types.Message, state: FSMContext):
texts = get_texts((await state.get_data()).get("language", "ru"))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
await state.update_data(new_group_priority=priority)
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
await _prompt_for_discount(
message,
@@ -772,6 +816,7 @@ async def process_create_group_auto_assign(
group = await create_promo_group(
db,
data["new_group_name"],
priority=data.get("new_group_priority", 0),
traffic_discount_percent=data["new_group_traffic"],
server_discount_percent=data["new_group_servers"],
device_discount_percent=data["new_group_devices"],
@@ -862,6 +907,12 @@ async def prompt_edit_promo_group_field(
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
"Введите новое название промогруппы (текущее: {name}):",
).format(name=group.name)
elif field == "priority":
await state.set_state(AdminStates.editing_promo_group_priority)
prompt = texts.t(
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT",
"Введите новый приоритет (текущий: {current}):",
).format(current=getattr(group, "priority", 0))
elif field == "traffic":
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
prompt = texts.t(
@@ -935,6 +986,48 @@ async def process_edit_group_name(
)
@admin_required
@error_handler
async def process_edit_group_priority(
message: types.Message,
state: FSMContext,
db_user,
db: AsyncSession,
):
data = await state.get_data()
texts = get_texts(data.get("language", db_user.language))
try:
priority = int(message.text)
if priority < 0:
raise ValueError
except (ValueError, TypeError):
await message.answer(
texts.t(
"ADMIN_PROMO_GROUP_INVALID_PRIORITY",
"❌ Приоритет должен быть неотрицательным целым числом",
)
)
return
group = await get_promo_group_by_id(db, data.get("edit_group_id"))
if not group:
await message.answer("❌ Промогруппа не найдена")
await state.clear()
return
group = await update_promo_group(db, group, priority=priority)
await state.set_state(AdminStates.editing_promo_group_menu)
await _send_edit_menu_after_update(
message,
texts,
group,
data.get("language", db_user.language),
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name),
)
@admin_required
@error_handler
async def process_edit_group_traffic(
@@ -1158,8 +1251,9 @@ async def show_promo_group_members(
lines = []
for index, user in enumerate(members, start=offset + 1):
username = f"@{user.username}" if user.username else ""
user_link = f'<a href="tg://user?id={user.telegram_id}">{user.full_name}</a>'
lines.append(
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
f"{index}. {user_link} (ID {user.id}, {username}, TG {user.telegram_id})"
)
body = "\n".join(lines)
@@ -1181,6 +1275,7 @@ async def show_promo_group_members(
await callback.message.edit_text(
f"{title}\n\n{body}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML",
)
await callback.answer()
@@ -1323,6 +1418,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
dp.message.register(
process_create_group_priority,
AdminStates.creating_promo_group_priority,
)
dp.message.register(
process_create_group_traffic,
AdminStates.creating_promo_group_traffic_discount,
@@ -1345,6 +1444,10 @@ def register_handlers(dp: Dispatcher):
)
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
dp.message.register(
process_edit_group_priority,
AdminStates.editing_promo_group_priority,
)
dp.message.register(
process_edit_group_traffic,
AdminStates.editing_promo_group_traffic_discount,
+81 -52
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import html
import logging
import re
@@ -1896,66 +1897,94 @@ async def _send_offer_to_users(
squad_name: Optional[str],
effect_type: str,
) -> Tuple[int, int]:
from app.database.database import AsyncSessionLocal
sent = 0
failed = 0
for user in users:
try:
offer_record = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=user.subscription.id if user.subscription else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
)
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f"claim_discount_{offer_record.id}",
async def send_single_offer(user):
"""Отправляет одно предложение с семафором ограничения"""
async with semaphore:
try:
# Используем отдельную сессию для изоляции транзакции
async with AsyncSessionLocal() as new_db:
offer_record = await upsert_discount_offer(
new_db,
user_id=user.id,
subscription_id=getattr(user, "subscription", None).id if getattr(user, "subscription", None) else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
"template_id": template.id,
"offer_type": template.offer_type,
"test_duration_hours": template.test_duration_hours,
"test_squad_uuids": template.test_squad_uuids,
"active_discount_hours": template.active_discount_hours,
},
)
]
]
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
user_texts = get_texts(user.language or db_user.language)
keyboard_rows: List[List[InlineKeyboardButton]] = [
[
build_miniapp_or_callback_button(
text=template.button_text,
callback_data=f"claim_discount_{offer_record.id}",
)
]
]
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
keyboard_rows.append([
InlineKeyboardButton(
text=user_texts.t("PROMO_OFFER_CLOSE", "❌ Закрыть"),
callback_data="promo_offer_close",
)
])
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
sent += 1
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
failed += 1
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
failed += 1
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
message_text = _render_template_text(
template,
user.language or db_user.language,
server_name=squad_name,
)
await bot.send_message(
chat_id=user.telegram_id,
text=message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning("Не удалось отправить предложение пользователю %s: %s", user.telegram_id, exc)
return False
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Ошибка рассылки промо предложения пользователю %s: %s", user.telegram_id, exc)
return False
# Отправляем предложения пакетами для эффективности
batch_size = 100
for i in range(0, len(users), batch_size):
batch = users[i:i + batch_size]
tasks = [send_single_offer(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool): # Успешно или неуспешно
if result:
sent += 1
else:
failed += 1
elif isinstance(result, Exception): # Ошибка выполнения задачи
failed += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent, failed
+141 -42
View File
@@ -15,8 +15,9 @@ from app.localization.texts import get_texts
from app.database.crud.promocode import (
get_promocodes_list, get_promocodes_count, create_promocode,
get_promocode_statistics, get_promocode_by_code, update_promocode,
delete_promocode
delete_promocode, get_promocode_by_id
)
from app.database.crud.promo_group import get_promo_group_by_id, get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime
@@ -81,16 +82,24 @@ async def show_promocodes_list(
for promo in promocodes:
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text += f"{status_emoji} {type_emoji} <code>{promo.code}</code>\n"
text += f"📊 Использований: {promo.current_uses}/{promo.max_uses}\n"
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 Бонус: {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 Дней: {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -129,15 +138,20 @@ async def show_promocode_management(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
status_emoji = "" if promo.is_active else ""
type_emoji = {"balance": "💰", "subscription_days": "📅", "trial_subscription": "🎁"}.get(promo.type, "🎫")
type_emoji = {
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
"promo_group": "🏷️"
}.get(promo.type, "🎫")
text = f"""
🎫 <b>Управление промокодом</b>
@@ -145,12 +159,17 @@ async def show_promocode_management(
{status_emoji} <b>Статус:</b> {'Активен' if promo.is_active else 'Неактивен'}
📊 <b>Использований:</b> {promo.current_uses}/{promo.max_uses}
"""
if promo.type == PromoCodeType.BALANCE.value:
text += f"💰 <b>Бонус:</b> {settings.format_price(promo.balance_bonus_kopeks)}\n"
elif promo.type == PromoCodeType.SUBSCRIPTION_DAYS.value:
text += f"📅 <b>Дней:</b> {promo.subscription_days}\n"
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ <b>Промогруппа:</b> {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
elif promo.promo_group_id:
text += f"🏷️ <b>Промогруппа ID:</b> {promo.promo_group_id} (не найдена)\n"
if promo.valid_until:
text += f"⏰ <b>Действует до:</b> {format_datetime(promo.valid_until)}\n"
@@ -200,8 +219,8 @@ async def show_promocode_edit_menu(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -445,13 +464,14 @@ async def select_promocode_type(
state: FSMContext
):
promo_type = callback.data.split('_')[-1]
type_names = {
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка"
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
"group": "🏷️ Промогруппа"
}
await state.update_data(promocode_type=promo_type)
await callback.message.edit_text(
@@ -509,6 +529,77 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
if not groups_with_counts:
await message.answer(
"❌ Промогруппы не найдены. Создайте хотя бы одну промогруппу.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")]
])
)
await state.clear()
return
keyboard = []
text = f"🏷️ <b>Промокод:</b> <code>{code}</code>\n\nВыберите промогруппу для назначения:\n\n"
for promo_group, user_count in groups_with_counts:
text += f"{promo_group.name} (приоритет: {promo_group.priority}, пользователей: {user_count})\n"
keyboard.append([
types.InlineKeyboardButton(
text=f"{promo_group.name} (↑{promo_group.priority})",
callback_data=f"promo_select_group_{promo_group.id}"
)
])
keyboard.append([
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_promocodes")
])
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await state.set_state(AdminStates.selecting_promo_group)
@admin_required
@error_handler
async def process_promo_group_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession
):
"""Handle promo group selection for promocode"""
try:
promo_group_id = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промогруппы", show_alert=True)
return
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
return
await state.update_data(
promo_group_id=promo_group_id,
promo_group_name=promo_group.name
)
await callback.message.edit_text(
f"🏷️ <b>Промокод для промогруппы</b>\n\n"
f"Промогруппа: {promo_group.name}\n"
f"Приоритет: {promo_group.priority}\n\n"
f"📊 Введите количество использований промокода (или 0 для безлимита):"
)
await state.set_state(AdminStates.setting_promocode_uses)
await callback.answer()
@admin_required
@@ -557,8 +648,8 @@ async def handle_edit_value(
data = await state.get_data()
promo_id = data.get('editing_promo_id')
edit_action = data.get('edit_action')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -643,8 +734,8 @@ async def handle_edit_uses(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -708,17 +799,20 @@ async def process_promocode_expiry(
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
max_uses = data.get('promocode_max_uses', 1)
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
type_map = {
"balance": PromoCodeType.BALANCE,
"days": PromoCodeType.SUBSCRIPTION_DAYS,
"trial": PromoCodeType.TRIAL_SUBSCRIPTION
"trial": PromoCodeType.TRIAL_SUBSCRIPTION,
"group": PromoCodeType.PROMO_GROUP
}
promocode = await create_promocode(
db=db,
code=code,
@@ -727,27 +821,31 @@ async def process_promocode_expiry(
subscription_days=value if promo_type in ["days", "trial"] else 0,
max_uses=max_uses,
valid_until=valid_until,
created_by=db_user.id
created_by=db_user.id,
promo_group_id=promo_group_id if promo_type == "group" else None
)
type_names = {
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка"
"balance": "Пополнение баланса",
"days": "Дни подписки",
"trial": "Тестовая подписка",
"group": "Промогруппа"
}
summary_text = f"""
<b>Промокод создан!</b>
🎫 <b>Код:</b> <code>{promocode.code}</code>
📝 <b>Тип:</b> {type_names.get(promo_type)}
"""
if promo_type == "balance":
summary_text += f"💰 <b>Сумма:</b> {settings.format_price(promocode.balance_bonus_kopeks)}\n"
elif promo_type in ["days", "trial"]:
summary_text += f"📅 <b>Дней:</b> {promocode.subscription_days}\n"
elif promo_type == "group" and promo_group_name:
summary_text += f"🏷️ <b>Промогруппа:</b> {promo_group_name}\n"
summary_text += f"📊 <b>Использований:</b> {promocode.max_uses}\n"
if promocode.valid_until:
@@ -775,8 +873,8 @@ async def handle_edit_expiry(
):
data = await state.get_data()
promo_id = data.get('editing_promo_id')
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await message.answer("❌ Промокод не найден")
await state.clear()
@@ -822,8 +920,8 @@ async def toggle_promocode_status(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -849,8 +947,8 @@ async def confirm_delete_promocode(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -897,8 +995,8 @@ async def delete_promocode_confirmed(
except (ValueError, IndexError):
await callback.answer("❌ Ошибка получения ID промокода", show_alert=True)
return
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -921,8 +1019,8 @@ async def show_promocode_stats(
db: AsyncSession
):
promo_id = int(callback.data.split('_')[-1])
promo = await db.get(PromoCode, promo_id)
promo = await get_promocode_by_id(db, promo_id)
if not promo:
await callback.answer("❌ Промокод не найден", show_alert=True)
return
@@ -1007,6 +1105,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_list, F.data == "admin_promo_list")
dp.callback_query.register(start_promocode_creation, F.data == "admin_promo_create")
dp.callback_query.register(select_promocode_type, F.data.startswith("promo_type_"))
dp.callback_query.register(process_promo_group_selection, F.data.startswith("promo_select_group_"))
dp.callback_query.register(show_promocode_management, F.data.startswith("promo_manage_"))
dp.callback_query.register(toggle_promocode_status, F.data.startswith("promo_toggle_"))
-3
View File
@@ -68,7 +68,6 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
@@ -105,7 +104,6 @@ async def show_referral_statistics(
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.get_referred_user_reward_kopeks())}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
@@ -190,7 +188,6 @@ async def show_referral_settings(
Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
Бонус новому пользователю при регистрации: {settings.format_price(settings.get_referred_user_reward_kopeks())}
<b>Комиссионные:</b>
Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
+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_"))
+2 -1
View File
@@ -421,7 +421,8 @@ async def show_server_users(
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
lines.append(f"{index}. {safe_user_name}")
user_link = f'<a href="tg://user?id={user.telegram_id}">{safe_user_name}</a>'
lines.append(f"{index}. {user_link}")
text += "\n" + "\n".join(lines)
else:
+36 -15
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
from datetime import datetime, timedelta
import time
import html
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
@@ -174,12 +175,24 @@ async def view_admin_ticket(
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
username_value = ticket.user.username if ticket.user else None
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
ticket_text += f"📱 Username: @{username_display}\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -221,15 +234,11 @@ async def view_admin_ticket(
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
# DM: при наличии username используем tg://resolve, иначе fallback по ID
if ticket.user.username:
pm_url = f"tg://resolve?domain={ticket.user.username}"
else:
pm_url = f"tg://user?id={ticket.user.telegram_id}"
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
# Профиль: по ID
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
@@ -750,7 +759,21 @@ async def handle_admin_block_duration_input(
ticket_text += f"📝 Заголовок: {updated.title}\n"
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n"
if updated.user and updated.user.telegram_id:
ticket_text += f"🆔 Telegram ID: <code>{updated.user.telegram_id}</code>\n"
if updated.user.username:
safe_username = html.escape(updated.user.username)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
else:
ticket_text += "📱 Username: отсутствует\n"
chat_link = f"tg://user?id={int(updated.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
if updated.is_user_reply_blocked:
if updated.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
@@ -778,12 +801,10 @@ async def handle_admin_block_duration_input(
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id:
if updated.user and updated.user.telegram_id and updated.user.username:
safe_username = html.escape(updated.user.username)
buttons_row = []
if updated.user.username:
pm_url = f"tg://resolve?domain={updated.user.username}"
else:
pm_url = f"tg://user?id={updated.user.telegram_id}"
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
+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"
)
File diff suppressed because it is too large Load Diff
+93
View File
@@ -1,4 +1,5 @@
import logging
import re
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +19,92 @@ from app.database.crud.welcome_text import (
logger = logging.getLogger(__name__)
def validate_html_tags(text: str) -> tuple[bool, str]:
"""
Проверяет HTML-теги в тексте на соответствие требованиям Telegram API.
Args:
text: Текст для проверки
Returns:
Кортеж из (валидно ли, сообщение об ошибке или None)
"""
# Поддерживаемые теги в parse_mode="HTML" для Telegram API
allowed_tags = {
'b', 'strong', # жирный
'i', 'em', # курсив
'u', 'ins', # подчеркнуто
's', 'strike', 'del', # зачеркнуто
'code', # моноширинный для коротких фрагментов
'pre', # моноширинный блок кода
'a' # ссылки
}
# Убираем плейсхолдеры из строки перед проверкой тегов
# Плейсхолдеры имеют формат {ключ}, и не являются тегами
placeholder_pattern = r'\{[^{}]+\}'
clean_text = re.sub(placeholder_pattern, '', text)
# Находим все открывающие и закрывающие теги
tag_pattern = r'<(/?)([a-zA-Z]+)(\s[^>]*)?>'
tags_with_pos = [(m.group(1), m.group(2), m.group(3), m.start(), m.end()) for m in re.finditer(tag_pattern, clean_text)]
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
# Проверяем, является ли тег поддерживаемым
if tag_lower not in allowed_tags:
return False, f"Неподдерживаемый HTML-тег: <{tag}>. Используйте только теги: {', '.join(sorted(allowed_tags))}"
# Проверяем атрибуты для тега <a>
if tag_lower == 'a':
if closing:
continue # Для закрывающего тега не нужно проверять атрибуты
if not attrs:
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем, что есть атрибут href
if 'href=' not in attrs.lower():
return False, "Тег <a> должен содержать атрибут href, например: <a href='URL'>ссылка</a>"
# Проверяем формат URL
href_match = re.search(r'href\s*=\s*[\'"]([^\'"]+)[\'"]', attrs, re.IGNORECASE)
if href_match:
url = href_match.group(1)
# Проверяем, что URL начинается с поддерживаемой схемы
if not re.match(r'^https?://|^tg://', url, re.IGNORECASE):
return False, f"URL в теге <a> должен начинаться с http://, https:// или tg://. Найдено: {url}"
else:
return False, "Не удалось извлечь URL из атрибута href тега <a>"
# Проверяем парность тегов с использованием стека
stack = []
for closing, tag, attrs, start_pos, end_pos in tags_with_pos:
tag_lower = tag.lower()
if tag_lower not in allowed_tags:
continue
if closing:
# Это закрывающий тег
if not stack:
return False, f"Лишний закрывающий тег: </{tag}>"
last_opening_tag = stack.pop()
if last_opening_tag.lower() != tag_lower:
return False, f"Тег </{tag}> не соответствует открывающему тегу <{last_opening_tag}>"
else:
# Это открывающий тег
stack.append(tag)
# Если остались незакрытые теги
if stack:
unclosed_tags = ", ".join([f"<{tag}>" for tag in stack])
return False, f"Незакрытые теги: {unclosed_tags}"
return True, None
def get_telegram_formatting_info() -> str:
return """
📝 <b>Поддерживаемые теги форматирования:</b>
@@ -202,6 +289,12 @@ async def process_welcome_text_edit(
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
return
# Проверяем HTML-теги на валидность
is_valid, error_msg = validate_html_tags(new_text)
if not is_valid:
await message.answer(f"❌ Ошибка в HTML-разметке:\n\n{error_msg}")
return
success = await set_welcome_text(db, new_text, db_user.id)
if success:
+56 -6
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
@@ -67,7 +68,7 @@ async def start_cryptobot_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -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}: "
+309
View File
@@ -0,0 +1,309 @@
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
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@error_handler
async def start_heleket_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
markup = settings.get_heleket_markup_percent()
markup_text: Optional[str]
if markup > 0:
label = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
markup_text = f"{label}: {markup:.0f}%"
elif markup < 0:
label = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
markup_text = f"{label}: {abs(markup):.0f}%"
else:
markup_text = None
message_lines = [
"🪙 <b>Пополнение через Heleket</b>",
"\n",
"Введите сумму пополнения от 100 до 100,000 ₽:",
"",
"⚡ Мгновенное зачисление", "🔒 Безопасная оплата",
]
if markup_text:
message_lines.extend(["", markup_text])
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
quick_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_buttons:
keyboard.inline_keyboard = quick_buttons + keyboard.inline_keyboard
await callback.message.edit_text(
"\n".join(filter(None, message_lines)),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
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()
@error_handler
async def process_heleket_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
) -> None:
texts = get_texts(db_user.language)
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
amount_rubles = amount_kopeks / 100
if amount_rubles < 100:
await message.answer("Минимальная сумма пополнения: 100 ₽")
return
if amount_rubles > 100000:
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
return
payment_service = PaymentService(message.bot)
result = await payment_service.create_heleket_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса на {amount_rubles:.0f}",
language=db_user.language,
)
if not result:
await message.answer(
"❌ Не удалось создать счёт в Heleket. Попробуйте позже или обратитесь в поддержку."
)
await state.clear()
return
payment_url = result.get("payment_url")
if not payment_url:
await message.answer("❌ Не удалось получить ссылку для оплаты Heleket")
await state.clear()
return
payer_amount = result.get("payer_amount")
payer_currency = result.get("payer_currency")
exchange_rate = result.get("exchange_rate")
discount_percent = result.get("discount_percent")
details = [
"🪙 <b>Оплата через Heleket</b>",
"",
f"💰 Сумма к зачислению: {amount_rubles:.0f}",
]
if payer_amount and payer_currency:
details.append(f"🪙 К оплате: {payer_amount} {payer_currency}")
markup_percent: Optional[float] = None
if discount_percent is not None:
try:
discount_int = int(discount_percent)
markup_percent = -discount_int
except (TypeError, ValueError):
markup_percent = None
if markup_percent:
label_markup = texts.t("PAYMENT_HELEKET_MARKUP_LABEL", "Наценка провайдера")
label_discount = texts.t("PAYMENT_HELEKET_DISCOUNT_LABEL", "Скидка провайдера")
absolute = abs(markup_percent)
if markup_percent > 0:
details.append(f"📈 {label_markup}: +{absolute}%")
else:
details.append(f"📉 {label_discount}: {absolute}%")
if payer_amount and payer_currency:
try:
payer_amount_float = float(payer_amount)
if payer_amount_float > 0:
rub_per_currency = amount_rubles / payer_amount_float
details.append(
f"💱 Курс: 1 {payer_currency}{rub_per_currency:.2f}"
)
except (TypeError, ValueError, ZeroDivisionError):
pass
details.extend(
[
"",
"📱 Инструкция:",
"1. Нажмите кнопку 'Оплатить'",
"2. Перейдите на страницу Heleket",
"3. Оплатите указанную сумму",
"4. Баланс пополнится автоматически",
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("PAY_WITH_COINS_BUTTON", "🪙 Оплатить"), url=payment_url)],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_heleket_{result['local_payment_id']}"
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
])
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()
@error_handler
async def check_heleket_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
) -> None:
try:
local_payment_id = int(callback.data.split("_")[-1])
except (ValueError, IndexError):
await callback.answer("Некорректный идентификатор платежа", show_alert=True)
return
from app.database.crud.heleket import get_heleket_payment_by_id
payment = await get_heleket_payment_by_id(db, local_payment_id)
if not payment:
await callback.answer("Платёж не найден", show_alert=True)
return
language = getattr(payment.user, "language", None) or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_ALREADY_PAID", "✅ Платёж уже зачислен")
await callback.answer(message, show_alert=True)
return
payment_service = PaymentService(callback.bot)
updated_payment = await payment_service.sync_heleket_payment_status(
db,
local_payment_id=local_payment_id,
)
if updated_payment:
payment = updated_payment
if payment.is_paid:
message = texts.t("HELEKET_PAYMENT_SUCCESS", "✅ Платёж зачислен на баланс")
await callback.answer(message, show_alert=True)
return
status_normalized = (payment.status or "").lower()
status_messages = {
"check": texts.t("HELEKET_STATUS_CHECK", "⏳ Ожидание оплаты"),
"process": texts.t("HELEKET_STATUS_PROCESS", "⚙️ Платёж обрабатывается"),
"confirm_check": texts.t("HELEKET_STATUS_CONFIRM_CHECK", "⛓ Ожидание подтверждений сети"),
"wrong_amount": texts.t("HELEKET_STATUS_WRONG_AMOUNT", "❗️ Оплачена неверная сумма"),
"wrong_amount_waiting": texts.t(
"HELEKET_STATUS_WRONG_AMOUNT_WAITING",
"❗️ Недостаточная сумма, ожидаем доплату",
),
"paid_over": texts.t("HELEKET_STATUS_PAID_OVER", "✅ Платёж зачислен (с переплатой)"),
"paid": texts.t("HELEKET_STATUS_PAID", "✅ Платёж зачислен"),
"cancel": texts.t("HELEKET_STATUS_CANCEL", "🚫 Платёж отменён"),
"fail": texts.t("HELEKET_STATUS_FAIL", "❌ Ошибка при оплате"),
"system_fail": texts.t("HELEKET_STATUS_SYSTEM_FAIL", "❌ Системная ошибка Heleket"),
"refund_process": texts.t("HELEKET_STATUS_REFUND_PROCESS", "↩️ Возврат обрабатывается"),
"refund_fail": texts.t("HELEKET_STATUS_REFUND_FAIL", "⚠️ Ошибка возврата"),
"refund_paid": texts.t("HELEKET_STATUS_REFUND_PAID", "✅ Возврат выполнен"),
"locked": texts.t("HELEKET_STATUS_LOCKED", "🔒 Средства заблокированы"),
}
message = status_messages.get(status_normalized)
if message is None:
template = texts.t("HELEKET_STATUS_UNKNOWN", "️ Статус платежа: {status}")
status_value = payment.status or status_normalized or ""
try:
message = template.format(status=status_value)
except Exception: # pragma: no cover - defensive formatting
message = f"️ Статус платежа: {status_value}"
await callback.answer(message, show_alert=True)
+287 -28
View File
@@ -1,12 +1,15 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import BalanceStates
from app.database.crud.user import add_user_balance
from app.utils.price_display import calculate_user_price, format_price_button
from app.utils.pricing_utils import format_period_description
from app.database.crud.transaction import (
get_user_transactions, get_user_transactions_count,
create_transaction
@@ -26,34 +29,61 @@ logger = logging.getLogger(__name__)
TRANSACTIONS_PER_PAGE = 10
def get_quick_amount_buttons(language: str) -> list:
def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
Returns:
List of button rows for inline keyboard
"""
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED or settings.DISABLE_TOPUP_BUTTONS:
return []
from app.localization.texts import get_texts
texts = get_texts(language)
buttons = []
periods = settings.get_available_subscription_periods()
periods = periods[:6]
periods = periods[:6] # Limit to 6 periods
for period in periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
callback_data = f"quick_amount_{price_kopeks}"
base_price_kopeks = getattr(settings, price_attr)
# Calculate price with user's promo group discount using unified system
price_info = calculate_user_price(user, base_price_kopeks, period, "period")
callback_data = f"quick_amount_{price_info.final_price}"
# Format button text with discount display
period_label = f"{period} дней"
# For balance buttons, use simpler format without emoji and period label prefix
if price_info.has_discount:
button_text = (
f"{texts.format_price(price_info.base_price)}"
f"{texts.format_price(price_info.final_price)} "
f"(-{price_info.discount_percent}%) • {period_label}"
)
else:
button_text = f"{texts.format_price(price_info.final_price)}{period_label}"
buttons.append(
types.InlineKeyboardButton(
text=f"{price_rubles} ₽ ({period} дней)",
text=button_text,
callback_data=callback_data
)
)
keyboard_rows = []
for i in range(0, len(buttons), 2):
keyboard_rows.append(buttons[i:i + 2])
return keyboard_rows
@@ -64,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()
@@ -179,18 +232,126 @@ async def handle_balance_history_pagination(
async def show_payment_methods(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
from app.utils.payment_utils import get_payment_methods_text
from app.database.crud.subscription import get_subscription_by_user_id
from app.utils.pricing_utils import calculate_months_from_days, apply_percentage_discount
from app.config import settings
from app.services.subscription_service import SubscriptionService
texts = get_texts(db_user.language)
payment_text = get_payment_methods_text(db_user.language)
await callback.message.edit_text(
payment_text,
reply_markup=get_payment_methods_keyboard(0, db_user.language),
parse_mode="HTML"
)
# Добавляем информацию о текущем тарифе пользователя
subscription = await get_subscription_by_user_id(db, db_user.id)
tariff_info = ""
if subscription and not subscription.is_trial:
# Рассчитываем приблизительную стоимость продления на 30 дней
duration_days = 30 # Берем для примера 30 дней
current_traffic = subscription.traffic_limit_gb
current_connected_squads = subscription.connected_squads or []
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
try:
# Получаем цены для текущих параметров
from app.config import PERIOD_PRICES
base_price_original = PERIOD_PRICES.get(duration_days, 0)
period_discount_percent = db_user.get_promo_discount("period", duration_days)
base_price, base_discount_total = apply_percentage_discount(
base_price_original,
period_discount_percent,
)
# Рассчитываем стоимость серверов
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
current_connected_squads,
db,
promo_group_id=db_user.promo_group_id,
)
servers_discount_percent = db_user.get_promo_discount("servers", duration_days)
total_servers_price = 0
for server_price in per_server_monthly_prices:
discounted_per_month, discount_per_month = apply_percentage_discount(
server_price,
servers_discount_percent,
)
total_servers_price += discounted_per_month
# Рассчитываем стоимость трафика
traffic_price_per_month = settings.get_traffic_price(current_traffic)
traffic_discount_percent = db_user.get_promo_discount("traffic", duration_days)
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
traffic_price_per_month,
traffic_discount_percent,
)
# Рассчитываем стоимость устройств
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount("devices", duration_days)
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Общая стоимость
months_in_period = calculate_months_from_days(duration_days)
total_price = (
base_price +
total_servers_price * months_in_period +
traffic_discounted_per_month * months_in_period +
devices_discounted_per_month * months_in_period
)
traffic_value = current_traffic or 0
if traffic_value <= 0:
traffic_display = texts.t("TRAFFIC_UNLIMITED_SHORT", "Безлимит")
else:
traffic_display = texts.format_traffic(traffic_value)
current_tariff_desc = (
f"📱 Подписка: {len(current_connected_squads)} серверов, "
f"{traffic_display}, {current_device_limit} устр."
)
estimated_price_info = f"💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней"
tariff_info = f"\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}"
except Exception as e:
logger.warning(f"Не удалось рассчитать стоимость текущей подписки для пользователя {db_user.id}: {e}")
tariff_info = ""
full_text = payment_text + tariff_info
keyboard = get_payment_methods_keyboard(0, db_user.language)
try:
await callback.message.edit_text(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.edit_caption(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except TelegramBadRequest:
pass
await callback.message.answer(
full_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@@ -257,6 +418,10 @@ async def handle_successful_topup_with_cart(
success_text = (
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
f"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
f"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса.\n\n"
f"🛒 У вас есть сохраненная корзина подписки\n"
f"Стоимость: {texts.format_price(total_price)}\n\n"
f"Хотите продолжить оформление?"
@@ -279,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>
@@ -390,6 +565,14 @@ async def process_topup_amount(
from .mulenpay import process_mulenpay_payment_amount
async with AsyncSessionLocal() as db:
await process_mulenpay_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_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(
message, db_user, db, amount_kopeks, state
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -406,6 +589,11 @@ async def process_topup_amount(
from .cryptobot import process_cryptobot_payment_amount
async with AsyncSessionLocal() as db:
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("Неизвестный способ оплаты")
@@ -496,6 +684,14 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_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
)
elif payment_method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
@@ -519,6 +715,14 @@ async def handle_quick_amount_selection(
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
@@ -575,6 +779,21 @@ async def handle_topup_amount_callback(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "platega":
from app.database.database import AsyncSessionLocal
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
@@ -589,6 +808,20 @@ async def handle_topup_amount_callback(
await process_cryptobot_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "heleket":
from app.database.database import AsyncSessionLocal
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "wata":
from app.database.database import AsyncSessionLocal
from .wata import process_wata_payment_amount
async with AsyncSessionLocal() as db:
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
@@ -672,6 +905,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("pal24_method_"),
)
from .platega import start_platega_payment, handle_platega_method_selection
dp.callback_query.register(
start_platega_payment,
F.data == "topup_platega"
)
dp.callback_query.register(
handle_platega_method_selection,
F.data.startswith("platega_method_"),
)
from .yookassa import check_yookassa_payment_status
dp.callback_query.register(
check_yookassa_payment_status,
@@ -712,6 +955,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_cryptobot_")
)
from .heleket import start_heleket_payment, check_heleket_payment_status
dp.callback_query.register(
start_heleket_payment,
F.data == "topup_heleket"
)
dp.callback_query.register(
check_heleket_payment_status,
F.data.startswith("check_heleket_")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
@@ -730,6 +983,12 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_pal24_")
)
from .platega import check_platega_payment_status
dp.callback_query.register(
check_platega_payment_status,
F.data.startswith("check_platega_")
)
dp.callback_query.register(
handle_payment_methods_unavailable,
F.data == "payment_methods_unavailable"
@@ -744,4 +1003,4 @@ def register_balance_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_topup_amount_callback,
F.data.startswith("topup_amount|")
)
)
+54 -3
View File
@@ -48,7 +48,7 @@ async def start_mulenpay_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -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(
+171 -9
View File
@@ -1,8 +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
@@ -202,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(
@@ -264,7 +292,7 @@ async def start_pal24_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -275,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()
@@ -307,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,
@@ -423,8 +473,77 @@ async def check_pal24_payment_status(
if not isinstance(links_meta, dict):
links_meta = {}
sbp_link = links_meta.get("sbp") or payment.link_url
card_link = links_meta.get("card")
links_info = status_info.get("links") or {}
def _extract_link(source: Any, keys: tuple[str, ...]) -> Optional[str]:
stack: list[Any] = [source]
while stack:
current = stack.pop()
if isinstance(current, dict):
for key in keys:
value = current.get(key)
if value:
return str(value)
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
return None
raw_response = metadata.get("raw_response") if isinstance(metadata, dict) else None
remote_data = status_info.get("remote_data")
transfer_keys = (
"transfer_url",
"transferUrl",
"transfer_link",
"transferLink",
"transfer",
"sbp_url",
"sbpUrl",
"sbp_link",
"sbpLink",
)
card_keys = (
"link_url",
"linkUrl",
"link",
"card_url",
"cardUrl",
"card_link",
"cardLink",
"payment_url",
"paymentUrl",
"url",
)
extra_sbp_link = (
_extract_link(raw_response, transfer_keys)
if raw_response
else None
)
if not extra_sbp_link and remote_data:
extra_sbp_link = _extract_link(remote_data, transfer_keys)
extra_card_link = (
_extract_link(raw_response, card_keys)
if raw_response
else None
)
if not extra_card_link and remote_data:
extra_card_link = _extract_link(remote_data, card_keys)
sbp_link = (
links_info.get("sbp")
or links_meta.get("sbp")
or status_info.get("sbp_url")
or extra_sbp_link
or payment.link_url
)
card_link = (
links_info.get("card")
or links_meta.get("card")
or status_info.get("card_url")
or extra_card_link
)
if not card_link and payment.link_page_url and payment.link_page_url != sbp_link:
card_link = payment.link_page_url
@@ -459,10 +578,53 @@ async def check_pal24_payment_status(
db_user = getattr(callback, 'db_user', None)
texts = get_texts(db_user.language if db_user else 'ru') if db_user else get_texts('ru')
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"), callback_data=f"check_pal24_{local_payment_id}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
pay_rows: list[list[types.InlineKeyboardButton]] = []
if not payment.is_paid and payment.status in {"NEW", "PROCESS"}:
default_sbp_text = texts.t(
"PAL24_SBP_PAY_BUTTON",
"🏦 Оплатить через PayPalych (СБП)",
)
sbp_button_text = settings.get_pal24_sbp_button_text(default_sbp_text)
if sbp_link and settings.is_pal24_sbp_button_visible():
pay_rows.append(
[
types.InlineKeyboardButton(
text=sbp_button_text,
url=sbp_link,
)
]
)
default_card_text = texts.t(
"PAL24_CARD_PAY_BUTTON",
"💳 Оплатить банковской картой (PayPalych)",
)
card_button_text = settings.get_pal24_card_button_text(default_card_text)
if card_link and settings.is_pal24_card_button_visible():
if not pay_rows or pay_rows[-1][0].url != card_link:
pay_rows.append(
[
types.InlineKeyboardButton(
text=card_button_text,
url=card_link,
)
]
)
keyboard_rows = pay_rows + [
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_pal24_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.answer()
try:
+403
View File
@@ -0,0 +1,403 @@
"""Handlers for Platega balance interactions."""
import logging
from typing import List
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
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
def _get_active_methods() -> List[int]:
methods = settings.get_platega_active_methods()
return [code for code in methods if code in {2, 10, 11, 12, 13}]
async def _prompt_amount(
message: types.Message,
db_user: User,
state: FSMContext,
method_code: int,
) -> None:
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 = (
settings.format_price(max_amount_kopeks)
if max_amount_kopeks and max_amount_kopeks > 0
else ""
)
default_prompt_body = (
"Введите сумму для пополнения от {min_amount} до {max_amount}.\n"
if max_amount_kopeks and max_amount_kopeks > 0
else "Введите сумму для пополнения от {min_amount}.\n"
)
prompt_template = texts.t(
"PLATEGA_TOPUP_PROMPT",
(
"💳 <b>Оплата через Platega ({method_name})</b>\n\n"
f"{default_prompt_body}"
"Оплата происходит через Platega."
),
)
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
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
await message.edit_text(
prompt_template.format(
method_name=method_name,
min_amount=min_amount_label,
max_amount=max_amount_label,
),
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(
platega_prompt_message_id=message.message_id,
platega_prompt_chat_id=message.chat.id,
)
@error_handler
async def start_platega_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
),
show_alert=True,
)
return
active_methods = _get_active_methods()
if not active_methods:
await callback.answer(
texts.t(
"PLATEGA_METHODS_NOT_CONFIGURED",
"⚠️ На стороне Platega нет доступных методов оплаты",
),
show_alert=True,
)
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])
await callback.answer()
return
method_buttons: list[list[types.InlineKeyboardButton]] = []
for method_code in active_methods:
label = settings.get_platega_method_display_title(method_code)
method_buttons.append(
[
types.InlineKeyboardButton(
text=label,
callback_data=f"platega_method_{method_code}",
)
]
)
method_buttons.append(
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
)
await callback.message.edit_text(
texts.t(
"PLATEGA_SELECT_PAYMENT_METHOD",
"Выберите способ оплаты Platega:",
),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=method_buttons),
)
if not has_pending_amount:
await state.set_state(BalanceStates.waiting_for_platega_method)
await callback.answer()
@error_handler
async def handle_platega_method_selection(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
try:
method_code = int(callback.data.rsplit("_", 1)[-1])
except ValueError:
await callback.answer("❌ Некорректный способ оплаты", show_alert=True)
return
if method_code not in _get_active_methods():
await callback.answer("⚠️ Этот способ сейчас недоступен", show_alert=True)
return
await _prompt_amount(callback.message, db_user, state, method_code)
await callback.answer()
@error_handler
async def process_platega_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
texts = get_texts(db_user.language)
if not settings.is_platega_enabled():
await message.answer(
texts.t(
"PLATEGA_TEMPORARILY_UNAVAILABLE",
"❌ Оплата через Platega временно недоступна",
)
)
return
data = await state.get_data()
method_code = int(data.get("platega_method", 0))
if method_code not in _get_active_methods():
await message.answer(
texts.t(
"PLATEGA_METHOD_SELECTION_REQUIRED",
"⚠️ Выберите способ оплаты Platega перед вводом суммы",
)
)
await state.set_state(BalanceStates.waiting_for_platega_method)
return
if amount_kopeks < settings.PLATEGA_MIN_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_LOW",
"Минимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MIN_AMOUNT_KOPEKS))
)
return
if amount_kopeks > settings.PLATEGA_MAX_AMOUNT_KOPEKS:
await message.answer(
texts.t(
"PLATEGA_AMOUNT_TOO_HIGH",
"Максимальная сумма для оплаты через Platega: {amount}",
).format(amount=settings.format_price(settings.PLATEGA_MAX_AMOUNT_KOPEKS))
)
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_platega_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
language=db_user.language,
payment_method_code=method_code,
)
except Exception as error:
logger.exception("Ошибка создания платежа Platega: %s", error)
payment_result = None
if not payment_result or not payment_result.get("redirect_url"):
await message.answer(
texts.t(
"PLATEGA_PAYMENT_ERROR",
"❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
)
)
await state.clear()
return
redirect_url = payment_result.get("redirect_url")
local_payment_id = payment_result.get("local_payment_id")
transaction_id = payment_result.get("transaction_id")
method_title = settings.get_platega_method_display_title(method_code)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"PLATEGA_PAY_BUTTON",
"💳 Оплатить через {method}",
).format(method=method_title),
url=redirect_url,
)
],
[
types.InlineKeyboardButton(
text=texts.t("CHECK_STATUS_BUTTON", "📊 Проверить статус"),
callback_data=f"check_platega_{local_payment_id}",
)
],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")],
]
)
instructions_template = texts.t(
"PLATEGA_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через Platega ({method})</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID транзакции: {transaction}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку «Оплатить»\n"
"2. Следуйте подсказкам платёжной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
"❓ Если возникнут проблемы, обратитесь в {support}"
),
)
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),
transaction=transaction_id or local_payment_id,
support=settings.get_support_contact_display_html(),
),
reply_markup=keyboard,
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()
@error_handler
async def check_platega_payment_status(
callback: types.CallbackQuery,
db: AsyncSession,
):
try:
local_payment_id = int(callback.data.split("_")[-1])
except ValueError:
await callback.answer("❌ Некорректный идентификатор платежа", show_alert=True)
return
payment_service = PaymentService(callback.bot)
try:
status_info = await payment_service.get_platega_payment_status(db, local_payment_id)
except Exception as error:
logger.exception("Ошибка проверки статуса Platega: %s", error)
await callback.answer("⚠️ Ошибка проверки статуса", show_alert=True)
return
if not status_info:
await callback.answer("⚠️ Платёж не найден", show_alert=True)
return
payment = status_info.get("payment")
status = status_info.get("status")
is_paid = status_info.get("is_paid")
language = "ru"
user = getattr(payment, "user", None)
if user and getattr(user, "language", None):
language = user.language
texts = get_texts(language)
if is_paid:
await callback.answer(texts.t("PLATEGA_PAYMENT_ALREADY_CONFIRMED", "✅ Платёж уже зачислен"), show_alert=True)
else:
await callback.answer(
texts.t("PLATEGA_PAYMENT_STATUS", "Текущий статус платежа: {status}").format(status=status),
show_alert=True,
)
+67 -20
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,23 +35,28 @@ 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
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
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()
+51 -3
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
@@ -45,7 +47,7 @@ async def start_wata_payment(
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -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(
+157 -21
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
@@ -48,7 +52,7 @@ async def start_yookassa_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -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()
@@ -98,7 +106,7 @@ async def start_yookassa_sbp_payment(
# Если включен быстрый выбор суммы и не отключены кнопки, добавляем кнопки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
from .main import get_quick_amount_buttons
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
quick_amount_buttons = get_quick_amount_buttons(db_user.language, db_user)
if quick_amount_buttons:
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
@@ -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
@@ -145,7 +175,7 @@ async def process_yookassa_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={
@@ -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
@@ -230,7 +321,7 @@ async def process_yookassa_sbp_payment_amount(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
description=settings.get_balance_payment_description(amount_kopeks, telegram_user_id=db_user.telegram_id),
receipt_email=None,
receipt_phone=None,
metadata={
@@ -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']}")
+414
View File
@@ -0,0 +1,414 @@
import logging
import random
from datetime import datetime, timedelta
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
from app.localization.texts import get_texts
from app.services.contest_rotation_service import (
GAME_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
GAME_ANAGRAM,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import extend_subscription
from app.utils.decorators import auth_required, error_handler
from app.keyboards.inline import get_back_keyboard
from app.states import ContestStates
logger = logging.getLogger(__name__)
def _user_allowed(subscription) -> bool:
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
return ""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
await extend_subscription(db, subscription, prize_days)
texts = get_texts(language)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
texts = get_texts(language)
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
# ---------- Handlers ----------
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ""
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
buttons.append(
[
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
]
)
if not buttons:
buttons.append(
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
)
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
)
await callback.answer()
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[-1]
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
slug = "_".join(parts[2:-1])
# reload round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
return
if not round_obj.template or not round_obj.template.is_enabled:
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
return
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
return
tpl = round_obj.template
if tpl.slug == GAME_QUEST:
await _render_quest(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_LOCKS:
await _render_locks(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_SERVER:
await _render_server_lottery(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_CIPHER:
await _render_cipher(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_EMOJI:
await _render_emoji(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_ANAGRAM:
await _render_anagram(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_BLITZ:
await _render_blitz(callback, db_user, round_obj, tpl)
else:
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
keyboard = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
)
)
keyboard.append(row_buttons)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
total = round_obj.payload.get("total", 20)
keyboard = []
row = []
for i in range(total):
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
flags = round_obj.payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard = []
row = []
for flag in shuffled_flags:
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
letters = round_obj.payload.get("letters", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
]
)
await callback.message.edit_text(
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
reply_markup=keyboard,
)
await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
return
tpl = round_obj.template
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
return
secret_idx = round_obj.payload.get("secret_idx")
correct_flag = ""
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_winner = False
if tpl.slug == GAME_SERVER:
is_winner = pick == correct_flag
elif tpl.slug == GAME_QUEST:
# Format: quest_{idx}
try:
if pick.startswith("quest_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_LOCKS:
# Format: locks_{idx}
try:
if pick.startswith("locks_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = pick == "blitz"
else:
is_winner = False
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
else:
responses = {
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
}.get(tpl.slug, ["Неудача"])
await callback.answer(random.choice(responses), show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
attempt = await get_attempt(db2, round_obj.id, db_user.id)
if attempt:
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
answer = (message.text or "").strip().upper()
tpl = round_obj.template
correct = (round_obj.payload.get("answer") or "").upper()
is_winner = correct and answer == correct
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
else:
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
dp.message.register(handle_text_answer, ContestStates.waiting_for_answer)
dp.message.register(lambda message: None, Command("contests")) # placeholder
+279 -37
View File
@@ -17,6 +17,7 @@ from app.database.crud.promo_group import (
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.keyboards.inline import (
get_main_menu_keyboard,
get_main_menu_keyboard_async,
get_language_selection_keyboard,
get_info_menu_keyboard,
)
@@ -38,7 +39,8 @@ from app.utils.promo_offer import (
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.pricing_utils import format_period_description
from app.utils.timezone import format_local_datetime
from app.handlers.subscription.traffic import handle_add_traffic
logger = logging.getLogger(__name__)
@@ -148,6 +150,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()
@@ -163,7 +177,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)
@@ -185,22 +199,26 @@ async def show_main_menu(
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=db_user,
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
keyboard=keyboard,
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
@@ -229,6 +247,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)
@@ -251,6 +281,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>")
@@ -282,6 +324,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)
@@ -422,6 +476,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)
@@ -457,6 +523,7 @@ async def show_faq_pages(
await callback.message.edit_text(
caption,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -466,6 +533,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 ""
@@ -581,6 +660,7 @@ async def show_faq_page(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -589,6 +669,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
@@ -687,6 +779,7 @@ async def show_privacy_policy(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -696,6 +789,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
@@ -794,6 +899,7 @@ async def show_public_offer(
await callback.message.edit_text(
message_text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
disable_web_page_preview=settings.DISABLE_WEB_PAGE_PREVIEW,
)
await callback.answer()
@@ -803,6 +909,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():
@@ -833,6 +951,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():
@@ -888,6 +1018,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)
@@ -902,7 +1044,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)
@@ -924,22 +1066,26 @@ async def handle_back_to_menu(
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=db_user,
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons,
)
await edit_or_answer_photo(
callback=callback,
caption=menu_text,
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=is_admin,
is_moderator=is_moderator,
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks,
subscription=db_user.subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart, # Добавляем параметр для отображения уведомления о сохраненной корзине
custom_buttons=custom_buttons,
),
keyboard=keyboard,
parse_mode="HTML",
force_text=settings.is_text_main_menu_mode(),
)
@@ -952,7 +1098,8 @@ def _get_subscription_status(user: User, texts) -> str:
current_time = datetime.utcnow()
actual_status = (subscription.actual_status or "").lower()
end_date_text = subscription.end_date.strftime("%d.%m.%Y")
end_date = getattr(subscription, "end_date", None)
end_date_text = format_local_datetime(end_date, "%d.%m.%Y") if end_date else None
days_left = 0
if subscription.end_date > current_time:
@@ -968,10 +1115,16 @@ def _get_subscription_status(user: User, texts) -> str:
return texts.t(
"SUB_STATUS_EXPIRED",
"🔴 Истекла\n📅 {end_date}",
).format(end_date=end_date_text)
).format(end_date=end_date_text or "")
if actual_status == "trial":
if days_left > 1:
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",
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
@@ -990,7 +1143,7 @@ def _get_subscription_status(user: User, texts) -> str:
)
if actual_status == "active":
if days_left > 7:
if days_left > 7 and end_date_text:
return texts.t(
"SUB_STATUS_ACTIVE_LONG",
"💎 Активна\n📅 до {end_date} ({days} дн.)",
@@ -1072,15 +1225,94 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
random_message = await get_random_active_message(db)
if random_message:
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
async def handle_activate_button(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# Получить подписку пользователя
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, db_user.id)
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
await callback.answer(
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
show_alert=True,
)
return
# Параметры из подписки или дефолтные
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
connected_squads = subscription.connected_squads if subscription else []
# Получить IDs серверов из UUIDs
from app.database.crud.server_squad import get_server_ids_by_uuids
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = db_user.balance_kopeks
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
best_period = None
best_price = 0
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
# Найти максимальный период, цена которого <= баланса
for period in sorted(available_periods, reverse=True):
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
server_ids,
device_limit,
db,
user=db_user
)
if price <= balance:
best_period = period
best_price = price
break
if best_period:
# Создать новую подписку
from app.database.crud.subscription import create_paid_subscription
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
)
# Списать деньги
db_user.balance_kopeks -= best_price
await db.commit()
await callback.answer(
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
show_alert=True,
)
else:
await callback.answer(
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
show_alert=True,
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
handle_back_to_menu,
F.data == "back_to_menu"
@@ -1146,3 +1378,13 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("language_select:"),
StateFilter(None)
)
dp.callback_query.register(
handle_add_traffic,
F.data == "buy_traffic"
)
dp.callback_query.register(
handle_activate_button,
F.data == "activate_button"
)
+256
View File
@@ -0,0 +1,256 @@
import asyncio
import logging
from datetime import datetime
from aiogram import Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import PollQuestion, User
from app.localization.texts import get_texts
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
logger = logging.getLogger(__name__)
async def _delete_message_later(bot, chat_id: int, message_id: int, delay: int = 10) -> None:
try:
await asyncio.sleep(delay)
await bot.delete_message(chat_id, message_id)
except Exception as error: # pragma: no cover - cleanup best effort
logger.debug("Не удалось удалить сообщение опроса %s: %s", message_id, error)
async def _render_question_text(
poll_title: str,
question: PollQuestion,
current_index: int,
total: int,
language: str,
) -> str:
texts = get_texts(language)
header = texts.t("POLL_QUESTION_HEADER", "<b>Вопрос {current}/{total}</b>").format(
current=current_index,
total=total,
)
lines = [f"🗳️ <b>{poll_title}</b>", "", header, "", question.text]
return "\n".join(lines)
async def _update_poll_message(
message: types.Message,
text: str,
*,
reply_markup: types.InlineKeyboardMarkup | None = None,
parse_mode: str | None = "HTML",
) -> bool:
try:
await message.edit_text(
text,
reply_markup=reply_markup,
parse_mode=parse_mode,
)
return True
except TelegramBadRequest as error:
error_text = str(error).lower()
if "message is not modified" in error_text:
logger.debug(
"Опросное сообщение уже актуально, пропускаем обновление: %s",
error,
)
return True
logger.warning(
"Не удалось обновить сообщение опроса %s: %s",
message.message_id,
error,
)
except Exception as error: # pragma: no cover - defensive logging
logger.exception(
"Непредвиденная ошибка при обновлении сообщения опроса %s: %s",
message.message_id,
error,
)
return False
def _build_options_keyboard(response_id: int, question: PollQuestion) -> types.InlineKeyboardMarkup:
buttons: list[list[types.InlineKeyboardButton]] = []
for option in sorted(question.options, key=lambda o: o.order):
buttons.append(
[
types.InlineKeyboardButton(
text=option.text,
callback_data=f"poll_answer:{response_id}:{question.id}:{option.id}",
)
]
)
return types.InlineKeyboardMarkup(inline_keyboard=buttons)
async def handle_poll_start(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
response_id = int(callback.data.split(":")[1])
except (IndexError, ValueError):
await callback.answer("❌ Опрос не найден", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
texts = get_texts(db_user.language)
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
if not response.poll or not response.poll.questions:
await callback.answer(texts.t("POLL_EMPTY", "Опрос пока недоступен."), show_alert=True)
return
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
index, question = await get_next_question(response)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Не удалось загрузить вопросы."), show_alert=True)
return
question_text = await _render_question_text(
response.poll.title,
question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
async def handle_poll_answer(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
try:
_, response_id, question_id, option_id = callback.data.split(":", 3)
response_id = int(response_id)
question_id = int(question_id)
option_id = int(option_id)
except (ValueError, IndexError):
await callback.answer("❌ Некорректные данные", show_alert=True)
return
response = await get_poll_response_by_id(db, response_id)
texts = get_texts(db_user.language)
if not response or response.user_id != db_user.id:
await callback.answer("❌ Опрос не найден", show_alert=True)
return
if not response.poll:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
if response.completed_at:
await callback.answer(texts.t("POLL_ALREADY_COMPLETED", "Вы уже прошли этот опрос."), show_alert=True)
return
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
await callback.answer(texts.t("POLL_ERROR", "Вопрос не найден."), show_alert=True)
return
option = await get_question_option(question, option_id)
if not option:
await callback.answer(texts.t("POLL_ERROR", "Вариант ответа не найден."), show_alert=True)
return
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
try:
await db.refresh(response, attribute_names=["answers"])
except Exception as error: # pragma: no cover - defensive cache busting
logger.debug(
"Не удалось обновить локальные ответы опроса %s: %s",
response.id,
error,
)
response = await get_poll_response_by_id(db, response.id)
if not response:
await callback.answer(texts.t("POLL_ERROR", "Опрос недоступен."), show_alert=True)
return
index, next_question = await get_next_question(response)
if next_question:
question_text = await _render_question_text(
response.poll.title,
next_question,
index,
len(response.poll.questions),
db_user.language,
)
if not await _update_poll_message(
callback.message,
question_text,
reply_markup=_build_options_keyboard(response.id, next_question),
):
await callback.answer(texts.t("POLL_ERROR", "Не удалось показать вопрос."), show_alert=True)
return
await callback.answer()
return
response.completed_at = datetime.utcnow()
await db.commit()
reward_amount = await reward_user_for_poll(db, response)
thanks_lines = [texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!")]
if reward_amount:
thanks_lines.append(
texts.t(
"POLL_REWARD_GRANTED",
"Награда {amount} зачислена на ваш баланс.",
).format(amount=settings.format_price(reward_amount))
)
if not await _update_poll_message(
callback.message,
"\n\n".join(thanks_lines),
):
await callback.answer(texts.t("POLL_COMPLETED", "🙏 Спасибо за участие в опросе!"))
return
asyncio.create_task(
_delete_message_later(callback.bot, callback.message.chat.id, callback.message.message_id)
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_poll_start, F.data.startswith("poll_start:"))
dp.callback_query.register(handle_poll_answer, F.data.startswith("poll_answer:"))
+68 -26
View File
@@ -1,13 +1,13 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
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
@@ -32,6 +32,47 @@ async def show_promocode_menu(
await callback.answer()
async def activate_promocode_for_registration(
db: AsyncSession,
user_id: int,
code: str,
bot: Bot = None
) -> dict:
"""
Активирует промокод для пользователя во время регистрации.
Возвращает результат активации без отправки сообщений.
"""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, user_id, code)
if result["success"]:
logger.info(f"✅ Пользователь {user_id} активировал промокод {code} при регистрации")
# Отправляем уведомление админу, если бот доступен
if bot:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if user:
notification_service = AdminNotificationService(bot)
await notification_service.send_promocode_activation_notification(
db,
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(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
return result
@error_handler
async def process_promocode(
message: types.Message,
@@ -39,10 +80,28 @@ 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()
if not code:
await message.answer(
texts.t(
@@ -52,31 +111,14 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
return
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db, db_user.id, code)
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result["success"]:
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result["description"]),
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
@@ -85,13 +127,13 @@ async def process_promocode(
"already_used_by_user": texts.PROMOCODE_USED,
"server_error": texts.ERROR
}
error_text = error_messages.get(result["error"], texts.PROMOCODE_INVALID)
await message.answer(
error_text,
reply_markup=get_back_keyboard(db_user.language)
)
await state.clear()
+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"
File diff suppressed because it is too large Load Diff
+44 -8
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,9 +18,11 @@ 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_")
allowed_prefixes = ("balance_", "admin_stars_test_", "simple_sub_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
@@ -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))
@@ -130,6 +162,10 @@ async def handle_successful_payment(
"⭐ Потрачено звезд: {stars_spent}\n"
"💰 Зачислено на баланс: {amount}\n"
"🆔 ID транзакции: {transaction_id}...\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
"подписка будет приобретена автоматически после пополнения баланса.\n\n"
"Спасибо за пополнение! 🚀",
).format(
stars_spent=payment.total_amount,
@@ -168,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 платежей")
+641 -278
View File
File diff suppressed because it is too large Load Diff
+49 -20
View File
@@ -56,6 +56,7 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.promo_offer_service import promo_offer_service
from app.states import SubscriptionStates
@@ -186,7 +187,7 @@ async def handle_subscription_config_back(
if current_state == SubscriptionStates.selecting_traffic.state:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
@@ -201,35 +202,26 @@ async def handle_subscription_config_back(
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
elif current_state == SubscriptionStates.selecting_devices.state:
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
await _show_previous_configuration_step(callback, state, db_user, texts, db)
elif current_state == SubscriptionStates.confirming_purchase.state:
if settings.is_devices_selection_enabled():
data = await state.get_data()
selected_countries = data.get('countries', [])
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
texts.SELECT_DEVICES,
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
elif settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
await state.set_state(SubscriptionStates.selecting_devices)
else:
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
await _show_previous_configuration_step(callback, state, db_user, texts, db)
else:
from app.handlers.menu import show_main_menu
@@ -249,7 +241,44 @@ async def handle_subscription_cancel(
await state.clear()
await clear_subscription_checkout_draft(db_user.id)
# Удаляем сохраненную корзину, чтобы не показывать кнопку возврата
await user_cart_service.delete_user_cart(db_user.id)
from app.handlers.menu import show_main_menu
await show_main_menu(callback, db_user, db)
await callback.answer("❌ Покупка отменена")
async def _show_previous_configuration_step(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
texts,
db: AsyncSession,
):
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
data = await state.get_data()
selected_countries = data.get('countries', [])
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
return
if settings.is_traffic_selectable():
await callback.message.edit_text(
texts.SELECT_TRAFFIC,
reply_markup=get_traffic_packages_keyboard(db_user.language)
)
await state.set_state(SubscriptionStates.selecting_traffic)
return
await callback.message.edit_text(
await _build_subscription_period_prompt(db_user, texts, db),
reply_markup=get_subscription_period_keyboard(db_user.language, db_user),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.selecting_period)
+1 -1
View File
@@ -107,7 +107,7 @@ def _get_addon_discount_percent_for_user(
if user is None:
return 0
promo_group = getattr(user, "promo_group", None)
promo_group = user.get_primary_promo_group()
if promo_group is None:
return 0
+28
View File
@@ -79,6 +79,7 @@ from app.utils.promo_offer import (
)
from .common import _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, logger
from .summary import present_subscription_summary
async def handle_add_countries(
callback: types.CallbackQuery,
@@ -405,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(
@@ -588,6 +600,11 @@ async def countries_continue(
await callback.answer("⚠️ Выберите хотя бы одну страну!", show_alert=True)
return
if not settings.is_devices_selection_enabled():
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
return
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -892,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,
+28
View File
@@ -183,6 +183,13 @@ async def handle_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
if not subscription or subscription.is_trial:
await callback.answer(
texts.t("PAID_FEATURE_ONLY", "⚠️ Эта функция доступна только для платных подписок"),
@@ -233,6 +240,13 @@ async def confirm_change_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
current_devices = subscription.device_limit
if new_devices_count == current_devices:
@@ -379,6 +393,13 @@ async def execute_change_devices(
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -863,6 +884,13 @@ async def confirm_add_devices(
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not settings.is_devices_selection_enabled():
await callback.answer(
texts.t("DEVICES_SELECTION_DISABLED", "⚠️ Изменение количества устройств недоступно"),
show_alert=True,
)
return
resume_callback = None
new_total_devices = subscription.device_limit + devices_count
+75 -22
View File
@@ -77,6 +77,7 @@ from app.utils.promo_offer import (
build_promo_offer_hint,
get_user_active_promo_discount_percent,
)
from app.utils.timezone import format_local_datetime
from .common import _apply_discount_to_monthly_component, _apply_promo_offer_discount, logger
from .countries import _get_available_countries, _get_countries_info, get_countries_price_by_uuids_fallback
@@ -158,7 +159,18 @@ async def _prepare_subscription_summary(
total_servers_discount += total_discount_for_server
selected_server_prices.append(total_price_for_server)
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
devices_selection_enabled = settings.is_devices_selection_enabled()
forced_disabled_limit: Optional[int] = None
if devices_selection_enabled:
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
else:
forced_disabled_limit = settings.get_disabled_mode_device_limit()
if forced_disabled_limit is None:
devices_selected = settings.DEFAULT_DEVICE_LIMIT
else:
devices_selected = forced_disabled_limit
summary_data['devices'] = devices_selected
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -241,17 +253,20 @@ async def _prepare_subscription_summary(
else:
traffic_display = f"{summary_data.get('traffic_gb', 0)} ГБ"
if base_discount_total > 0:
details_lines = []
# Добавляем строку базового периода только если цена не равна 0
if base_discount_total > 0 and base_price > 0:
base_line = (
f"- Базовый период: <s>{texts.format_price(base_price_original)}</s> "
f"{texts.format_price(base_price)}"
f" (скидка {period_discount_percent}%:"
f" -{texts.format_price(base_discount_total)})"
)
else:
details_lines.append(base_line)
elif base_price_original > 0:
base_line = f"- Базовый период: {texts.format_price(base_price_original)}"
details_lines = [base_line]
details_lines.append(base_line)
if total_traffic_price > 0:
traffic_line = (
@@ -275,7 +290,7 @@ async def _prepare_subscription_summary(
f" -{texts.format_price(total_servers_discount)})"
)
details_lines.append(servers_line)
if total_devices_price > 0:
if devices_selection_enabled and total_devices_price > 0:
devices_line = (
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
f" = {texts.format_price(total_devices_price)}"
@@ -300,17 +315,28 @@ async def _prepare_subscription_summary(
details_text = "\n".join(details_lines)
summary_text = (
"📋 <b>Сводка заказа</b>\n\n"
f"📅 <b>Период:</b> {period_display}\n"
f"📊 <b>Трафик:</b> {traffic_display}\n"
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}\n"
f"📱 <b>Устройства:</b> {devices_selected}\n\n"
"💰 <b>Детализация стоимости:</b>\n"
f"{details_text}\n\n"
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}\n\n"
"Подтверждаете покупку?"
)
summary_lines = [
"📋 <b>Сводка заказа</b>",
"",
f"📅 <b>Период:</b> {period_display}",
f"📊 <b>Трафик:</b> {traffic_display}",
f"🌍 <b>Страны:</b> {', '.join(selected_countries_names)}",
]
if devices_selection_enabled:
summary_lines.append(f"📱 <b>Устройства:</b> {devices_selected}")
summary_lines.extend([
"",
"💰 <b>Детализация стоимости:</b>",
details_text,
"",
f"💎 <b>Общая стоимость:</b> {texts.format_price(total_price)}",
"",
"Подтверждаете покупку?",
])
summary_text = "\n".join(summary_lines)
return summary_text, summary_data
@@ -327,7 +353,7 @@ async def _build_subscription_period_prompt(
if promo_offer_hint:
lines.extend(["", promo_offer_hint])
promo_text = _build_promo_group_discount_text(
promo_text = await _build_promo_group_discount_text(
db_user,
settings.get_available_subscription_periods(),
texts=texts,
@@ -382,7 +408,18 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
)
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
device_limit = subscription.device_limit
if device_limit is None:
if settings.is_devices_selection_enabled():
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
forced_limit = settings.get_disabled_mode_device_limit()
if forced_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
else:
device_limit = forced_limit
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
@@ -410,7 +447,12 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
return 0
async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSession):
devices_used = await get_current_devices_count(db_user)
devices_selection_enabled = settings.is_devices_selection_enabled()
if devices_selection_enabled:
devices_used = await get_current_devices_count(db_user)
else:
devices_used = 0
countries_info = await _get_countries_info(subscription.connected_squads)
countries_text = ", ".join([c['name'] for c in countries_info]) if countries_info else "Нет"
@@ -439,10 +481,21 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
subscription_cost = await get_subscription_cost(subscription, db)
info_text = texts.SUBSCRIPTION_INFO.format(
info_template = texts.SUBSCRIPTION_INFO
if not devices_selection_enabled:
info_template = info_template.replace(
"\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}",
"",
).replace(
"\n📱 <b>Devices:</b> {devices_used} / {devices_limit}",
"",
)
info_text = info_template.format(
status=status_text,
type=type_text,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
end_date=format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M"),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=traffic_text,
+2 -2
View File
@@ -88,12 +88,12 @@ async def _get_promo_offer_hint(
) -> Optional[str]:
return await build_promo_offer_hint(db, db_user, texts, percent)
def _build_promo_group_discount_text(
async def _build_promo_group_discount_text(
db_user: User,
periods: Optional[List[int]] = None,
texts=None,
) -> str:
promo_group = getattr(db_user, "promo_group", None)
promo_group = db_user.get_primary_promo_group()
if not promo_group:
return ""
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
import logging
from typing import Optional, TYPE_CHECKING
from aiogram import types
from aiogram.fsm.context import FSMContext
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import save_subscription_checkout_draft
from app.states import SubscriptionStates
from app.keyboards.inline import get_subscription_confirm_keyboard
if TYPE_CHECKING: # pragma: no cover - only for type checking
from .pricing import _prepare_subscription_summary
logger = logging.getLogger(__name__)
async def present_subscription_summary(
callback: types.CallbackQuery,
state: FSMContext,
db_user,
texts: Optional = None,
) -> bool:
"""Render the subscription purchase summary and switch to the confirmation state.
Returns ``True`` when the summary is shown successfully and ``False`` if
calculation failed (an error is shown to the user in this case).
"""
if texts is None:
texts = get_texts(db_user.language)
data = await state.get_data()
from .pricing import _prepare_subscription_summary
try:
summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts)
except ValueError as exc:
logger.error(
"Ошибка в расчете цены подписки для пользователя %s: %s",
db_user.telegram_id,
exc,
)
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
return False
await state.set_data(prepared_data)
await save_subscription_checkout_draft(db_user.id, prepared_data)
await callback.message.edit_text(
summary_text,
reply_markup=get_subscription_confirm_keyboard(db_user.language),
parse_mode="HTML",
)
await state.set_state(SubscriptionStates.confirming_purchase)
return True
+13 -6
View File
@@ -80,6 +80,7 @@ from app.utils.promo_offer import (
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
from .summary import present_subscription_summary
async def handle_add_traffic(
callback: types.CallbackQuery,
@@ -352,12 +353,15 @@ async def select_traffic(
reply_markup=get_countries_keyboard(countries, [], db_user.language)
)
await state.set_state(SubscriptionStates.selecting_countries)
else:
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
await callback.answer()
return
countries = await _get_available_countries(db_user.promo_group_id)
available_countries = [c for c in countries if c.get('is_available', True)]
data['countries'] = [available_countries[0]['uuid']] if available_countries else []
await state.set_data(data)
if settings.is_devices_selection_enabled():
selected_devices = data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
await callback.message.edit_text(
@@ -365,8 +369,11 @@ async def select_traffic(
reply_markup=get_devices_keyboard(selected_devices, db_user.language)
)
await state.set_state(SubscriptionStates.selecting_devices)
await callback.answer()
return
await callback.answer()
if await present_subscription_summary(callback, state, db_user, texts):
await callback.answer()
async def add_traffic(
callback: types.CallbackQuery,
+5 -1
View File
@@ -114,7 +114,11 @@ async def handle_successful_payment(message: types.Message):
)
await message.answer(
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!"
f"✅ Баланс успешно пополнен на {settings.format_price(amount_kopeks)}!\n\n"
"⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. "
"Обязательно активируйте подписку отдельно!\n\n"
f"🔄 При наличии сохранённой корзины подписки и включенной автопокупке, "
f"подписка будет приобретена автоматически после пополнения баланса."
)
logger.info(f"✅ Обработан Stars платеж: {payment.telegram_payment_charge_id}")
+256 -3
View File
@@ -53,6 +53,16 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_submenu_system",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_TRIALS", "🧪 Триалы"),
callback_data="admin_trials",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_MAIN_PAYMENTS", "💳 Пополнения"),
callback_data="admin_payments",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
])
@@ -85,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
[
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"),
callback_data="admin_contests",
)
],
[
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
],
@@ -101,6 +117,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_POLLS", "🗳️ Опросы"),
callback_data="admin_polls",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_COMMUNICATIONS_PROMO_OFFERS", "🎯 Промо-предложения"),
@@ -229,6 +251,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)
@@ -290,6 +326,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")
]
@@ -330,6 +378,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", "📢 По кампании"),
@@ -423,6 +477,181 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"),
callback_data="admin_contests_referral",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"),
callback_data="admin_contests_daily",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"),
],
]
)
def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"),
callback_data="admin_contests_list",
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTESTS_CREATE", " Новый конкурс"),
callback_data="admin_contests_create",
),
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="admin_contests",
)
],
]
)
def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"),
callback_data="admin_contest_mode_paid",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑‍🤝‍🧑 Просто реферал"),
callback_data="admin_contest_mode_registered",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral")
],
]
)
def get_daily_contest_manage_keyboard(
template_id: int,
is_enabled: bool,
language: str = "ru",
) -> InlineKeyboardMarkup:
texts = get_texts(language)
toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"),
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"),
InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_MANUAL", "🧪 Ручной старт"), callback_data=f"admin_daily_manual_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"),
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_RESET_ATTEMPTS", "🔄 Сбросить попытки"), callback_data=f"admin_daily_reset_attempts_{template_id}"),
],
[
InlineKeyboardButton(text=_t(texts, "ADMIN_CLOSE_ROUND", "❌ Закрыть раунд"), callback_data=f"admin_daily_close_{template_id}"),
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"),
],
]
)
def get_referral_contest_manage_keyboard(
contest_id: int,
*,
is_active: bool,
can_delete: bool = False,
language: str = "ru",
) -> InlineKeyboardMarkup:
texts = get_texts(language)
toggle_text = (
_t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить")
if is_active
else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить")
)
rows = [
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"),
callback_data=f"admin_contest_leaderboard_{contest_id}",
),
InlineKeyboardButton(
text=toggle_text,
callback_data=f"admin_contest_toggle_{contest_id}",
),
],
[
InlineKeyboardButton(
text="📈 Детальная статистика",
callback_data=f"admin_contest_detailed_stats_{contest_id}",
),
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_EDIT_SUMMARY_TIMES", "🕒 Итоги в день"),
callback_data=f"admin_contest_edit_times_{contest_id}",
),
],
]
if can_delete:
rows.append(
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_CONTEST_DELETE", "🗑 Удалить"),
callback_data=f"admin_contest_delete_{contest_id}",
)
]
)
rows.append(
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"),
callback_data="admin_contests_list",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
def get_campaign_management_keyboard(
campaign_id: int, is_active: bool, language: str = "ru"
) -> InlineKeyboardMarkup:
@@ -748,6 +977,12 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
callback_data=f"admin_user_promo_group_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_REFERRALS_BUTTON", "🤝 Рефералы"),
callback_data=f"admin_user_referrals_{user_id}"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_STATISTICS", "📊 Статистика"),
@@ -809,20 +1044,28 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
def get_user_promo_group_keyboard(
promo_groups: List[Tuple[Any, int]],
user_id: int,
current_group_id: Optional[int],
current_group_ids, # Can be Optional[int] or List[int]
language: str = "ru"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
# Ensure current_group_ids is a list
if current_group_ids is None:
current_group_ids = []
elif isinstance(current_group_ids, int):
current_group_ids = [current_group_ids]
keyboard: List[List[InlineKeyboardButton]] = []
for group, members_count in promo_groups:
prefix = "" if current_group_id is not None and group.id == current_group_id else "👥"
# Check if user has this group
has_group = group.id in current_group_ids
prefix = "" if has_group else "👥"
count_text = f" ({members_count})" if members_count else ""
keyboard.append([
InlineKeyboardButton(
text=f"{prefix} {group.name}{count_text}",
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
callback_data=f"admin_user_promo_group_toggle_{user_id}_{group.id}"
)
])
@@ -869,6 +1112,10 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_TRIAL", "🎁 Триал"),
callback_data="promo_type_trial"
),
InlineKeyboardButton(
text=_t(texts, "ADMIN_PROMOCODE_TYPE_PROMO_GROUP", "🏷️ Промогруппа"),
callback_data="promo_type_group"
)
],
[
@@ -1070,6 +1317,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", "🆕 Только новые"),
+415 -135
View File
@@ -1,7 +1,7 @@
from typing import List, Optional
from aiogram import types
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from datetime import datetime
from datetime import datetime, timezone
from app.database.models import User
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,7 +9,11 @@ from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
from app.utils.pricing_utils import (
format_period_description,
apply_percentage_discount,
)
from app.utils.price_display import PriceInfo, format_price_button
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
@@ -19,6 +23,136 @@ import logging
logger = logging.getLogger(__name__)
async def get_main_menu_keyboard_async(
db: AsyncSession,
language: str = DEFAULT_LANGUAGE,
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
subscription_is_active: bool = False,
balance_kopeks: int = 0,
subscription=None,
show_resume_checkout: bool = False,
has_saved_cart: bool = False,
*,
is_moderator: bool = False,
custom_buttons: Optional[list[InlineKeyboardButton]] = None,
user=None, # Добавляем параметр пользователя для получения данных
) -> InlineKeyboardMarkup:
"""
Асинхронная версия get_main_menu_keyboard с поддержкой конструктора меню.
Если MENU_LAYOUT_ENABLED=True, использует конфигурацию из БД.
Иначе делегирует в синхронную версию.
"""
if settings.MENU_LAYOUT_ENABLED:
from app.services.menu_layout_service import MenuLayoutService, MenuContext
from datetime import datetime
# Получаем данные для плейсхолдеров
subscription_days_left = 0
traffic_used_gb = 0.0
traffic_left_gb = 0.0
referral_count = 0
referral_earnings_kopeks = 0
registration_days = 0
promo_group_id = None
has_autopay = False
username = ""
# Заполняем данными из подписки
if subscription:
# Дни до окончания подписки
if hasattr(subscription, 'days_left'):
# Используем свойство из модели, которое правильно вычисляет дни в UTC
subscription_days_left = subscription.days_left
elif hasattr(subscription, 'end_date') and subscription.end_date:
# Fallback: вычисляем вручную, используя UTC
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
days_left = (subscription.end_date - now_utc).days
subscription_days_left = max(0, days_left)
# Трафик
if hasattr(subscription, 'traffic_used_gb'):
traffic_used_gb = subscription.traffic_used_gb or 0.0
if hasattr(subscription, 'traffic_limit_gb') and subscription.traffic_limit_gb:
traffic_left_gb = max(0, subscription.traffic_limit_gb - (subscription.traffic_used_gb or 0))
# Автоплатеж
if hasattr(subscription, 'autopay_enabled'):
has_autopay = subscription.autopay_enabled
# Получаем данные пользователя
if user:
# Имя пользователя
if hasattr(user, 'username') and user.username:
username = user.username
elif hasattr(user, 'first_name') and user.first_name:
username = user.first_name
# Дни с регистрации
if hasattr(user, 'created_at') and user.created_at:
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
registration_days = (now_utc - user.created_at).days
# ID промо-группы
if hasattr(user, 'promo_group_id'):
promo_group_id = user.promo_group_id
# Получаем данные о рефералах из БД (если нужно)
try:
from app.database.crud.referral import get_user_referral_stats
if user and hasattr(user, 'id'):
referral_data = await get_user_referral_stats(db, user.id)
if referral_data:
referral_count = referral_data.get('invited_count', 0)
referral_earnings_kopeks = referral_data.get('total_earned_kopeks', 0)
except Exception as e:
logger.error(f"Error getting referral data: {e}")
context = MenuContext(
language=language,
is_admin=is_admin,
is_moderator=is_moderator,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
has_had_paid_subscription=has_had_paid_subscription,
balance_kopeks=balance_kopeks,
subscription=subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
custom_buttons=custom_buttons or [],
# Добавляем данные для плейсхолдеров
username=username,
subscription_days=subscription_days_left,
traffic_used_gb=traffic_used_gb,
traffic_left_gb=traffic_left_gb,
referral_count=referral_count,
referral_earnings_kopeks=referral_earnings_kopeks,
registration_days=registration_days,
promo_group_id=promo_group_id,
has_autopay=has_autopay,
)
return await MenuLayoutService.build_keyboard(db, context)
# Fallback на синхронную версию
return get_main_menu_keyboard(
language=language,
is_admin=is_admin,
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=balance_kopeks,
subscription=subscription,
show_resume_checkout=show_resume_checkout,
has_saved_cart=has_saved_cart,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
def _get_localized_value(values, language: str, default_language: str = "en") -> str:
if not isinstance(values, dict):
return ""
@@ -78,7 +212,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:
@@ -90,28 +257,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)
@@ -257,7 +446,8 @@ def get_main_menu_keyboard(
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
keyboard = []
keyboard: list[list[InlineKeyboardButton]] = []
paired_buttons: list[InlineKeyboardButton] = []
if has_active_subscription and subscription_is_active:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -312,22 +502,36 @@ def get_main_menu_keyboard(
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
else:
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
])
)
# Добавляем кнопку докупки трафика для лимитированных подписок
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
)
keyboard.append([InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")])
show_trial = not has_had_paid_subscription and not has_active_subscription
show_buy = not has_active_subscription or not subscription_is_active
subscription_buttons = []
current_subscription = subscription
has_active_paid_subscription = bool(
current_subscription
and not getattr(current_subscription, "is_trial", False)
and getattr(current_subscription, "is_active", False)
)
simple_purchase_button = None
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
simple_purchase_button = InlineKeyboardButton(
text=texts.MENU_SIMPLE_SUBSCRIPTION,
callback_data="simple_subscription_purchase",
)
subscription_buttons: list[InlineKeyboardButton] = []
if show_trial:
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_TRIAL, callback_data="menu_trial")
@@ -337,71 +541,78 @@ def get_main_menu_keyboard(
subscription_buttons.append(
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="menu_buy")
)
# Добавляем кнопку простой покупки после кнопки "Купить подписку"
if settings.SIMPLE_SUBSCRIPTION_ENABLED:
subscription_buttons.append(
InlineKeyboardButton(text="⚡ Простая покупка", callback_data="simple_subscription_purchase")
)
if subscription_buttons:
if len(subscription_buttons) == 2:
keyboard.append(subscription_buttons)
elif len(subscription_buttons) == 1:
keyboard.append([subscription_buttons[0]])
elif len(subscription_buttons) > 2:
# Если больше 2 кнопок, добавляем по отдельности
for button in subscription_buttons:
keyboard.append([button])
paired_buttons.extend(subscription_buttons)
if simple_purchase_button:
paired_buttons.append(simple_purchase_button)
if show_resume_checkout or has_saved_cart:
keyboard.append([
resume_callback = (
"return_to_saved_cart" if has_saved_cart else "subscription_resume_checkout"
)
paired_buttons.append(
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data=resume_callback,
)
])
)
if custom_buttons:
for button in custom_buttons:
if isinstance(button, InlineKeyboardButton):
keyboard.append([button])
paired_buttons.append(button)
# Добавляем кнопки промокода и рефералов, учитывая настройки
additional_buttons = [
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode")
]
)
# Добавляем кнопку рефералов только если программа включена
# Добавляем кнопку рефералов, только если программа включена
if settings.is_referral_program_enabled():
additional_buttons.append(
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
)
keyboard.append(additional_buttons)
# Support button is configurable (runtime via service)
# Добавляем кнопку конкурсов
if settings.CONTESTS_ENABLED and settings.CONTESTS_BUTTON_VISIBLE:
paired_buttons.append(
InlineKeyboardButton(text=texts.t("CONTESTS_BUTTON", "🎲 Конкурсы"), callback_data="contests_menu")
)
try:
from app.services.support_settings_service import SupportSettingsService
support_enabled = SupportSettingsService.is_support_menu_enabled()
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
])
keyboard.append([
if support_enabled:
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support")
)
# Добавляем кнопку активации
if settings.ACTIVATE_BUTTON_VISIBLE:
paired_buttons.append(
InlineKeyboardButton(text=settings.ACTIVATE_BUTTON_TEXT, callback_data="activate_button")
)
paired_buttons.append(
InlineKeyboardButton(
text=texts.t("MENU_INFO", "️ Инфо"),
callback_data="menu_info",
)
])
)
if settings.is_language_selection_enabled():
keyboard.append([
paired_buttons.append(
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
)
for i in range(0, len(paired_buttons), 2):
row = paired_buttons[i : i + 2]
keyboard.append(row)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -667,7 +878,7 @@ def get_insufficient_balance_keyboard(
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout",
callback_data="return_to_saved_cart",
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
@@ -695,6 +906,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
@@ -799,7 +1011,7 @@ def get_payment_methods_keyboard_with_cart(
keyboard.inline_keyboard.insert(-1, [ # Вставляем перед кнопкой "назад"
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data="subscription_resume_checkout"
callback_data="return_to_saved_cart"
)
])
@@ -856,36 +1068,59 @@ def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_subscription_period_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_subscription_period_keyboard(
language: str = DEFAULT_LANGUAGE,
user: Optional[User] = None
) -> InlineKeyboardMarkup:
"""
Generate subscription period selection keyboard with personalized pricing.
Args:
language: User's language code
user: User object for personalized discounts (None = default discounts)
Returns:
InlineKeyboardMarkup with period buttons showing personalized prices
"""
from app.utils.price_display import calculate_user_price
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_subscription_periods()
period_texts = {
14: texts.PERIOD_14_DAYS,
30: texts.PERIOD_30_DAYS,
60: texts.PERIOD_60_DAYS,
90: texts.PERIOD_90_DAYS,
180: texts.PERIOD_180_DAYS,
360: texts.PERIOD_360_DAYS
}
for days in available_periods:
if days in period_texts:
keyboard.append([
InlineKeyboardButton(
text=period_texts[days],
callback_data=f"period_{days}"
)
])
# Get base price for this period
base_price = PERIOD_PRICES.get(days, 0)
# Calculate personalized price with user's discounts
price_info = calculate_user_price(user, base_price, days, "period")
# Format period description
period_display = format_period_description(days, language)
# Format button text with discount display
button_text = format_price_button(
period_label=period_display,
price_info=price_info,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=button_text,
callback_data=f"period_{days}"
)
])
# Кнопка "Простая покупка" была убрана из выбора периода подписки
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1060,6 +1295,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))
@@ -1075,6 +1311,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:
@@ -1084,6 +1321,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(
@@ -1091,6 +1329,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([
@@ -1099,6 +1338,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()
@@ -1111,6 +1351,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([
@@ -1119,6 +1360,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([
@@ -1127,6 +1369,16 @@ 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([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
callback_data=_build_callback("platega"),
)
])
has_direct_payment_methods = True
if settings.is_cryptobot_enabled():
keyboard.append([
@@ -1135,15 +1387,33 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
callback_data=_build_callback("cryptobot")
)
])
has_direct_payment_methods = True
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
if settings.is_heleket_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_HELEKET", "🪙 Криптовалюта (Heleket)"),
callback_data=_build_callback("heleket")
)
])
has_direct_payment_methods = True
if len(keyboard) == 1:
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_VIA_SUPPORT", "🛠️ Через поддержку"),
callback_data="topup_support"
)
])
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", "⚠️ Способы оплаты временно недоступны"),
@@ -1372,7 +1642,7 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
for days in [1, 3, 7, 14]:
keyboard.append([
InlineKeyboardButton(
text=f"{days} дн{_get_days_suffix(days)}",
text=f"{days} {_get_days_word(days)}",
callback_data=f"autopay_days_{days}"
)
])
@@ -1384,37 +1654,17 @@ def get_autopay_days_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboar
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def _get_days_suffix(days: int) -> str:
if days == 1:
return "ь"
elif 2 <= days <= 4:
return "я"
else:
return "ей"
def _get_days_word(days: int) -> str:
if days % 10 == 1 and days % 100 != 11:
return "день"
if 2 <= days % 10 <= 4 and not (12 <= days % 100 <= 14):
return "дня"
return "дней"
def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
periods = [
(14, f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"),
(30, f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"),
(60, f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"),
(90, f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}")
]
for days, text in periods:
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"extend_period_{days}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
# Deprecated: get_extend_subscription_keyboard() was removed.
# Use get_extend_subscription_keyboard_with_prices() instead for personalized pricing.
def get_add_traffic_keyboard(
@@ -1951,15 +2201,39 @@ def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) ->
if isinstance(price_info, dict):
final_price = price_info.get("final")
original_price = price_info.get("original", 0)
if final_price is None:
final_price = price_info.get("original", 0)
else:
final_price = price_info
original_price = price_info
period_display = format_period_description(days, language)
# Create PriceInfo from already calculated prices
# Note: original_price and final_price are calculated in the handler
discount_percent = 0
if original_price > final_price and original_price > 0:
discount_percent = ((original_price - final_price) * 100) // original_price
price_info_obj = PriceInfo(
base_price=original_price,
final_price=final_price,
discount_percent=discount_percent
)
# Format button using unified system
button_text = format_price_button(
period_label=period_display,
price_info=price_info_obj,
format_price_func=texts.format_price,
emphasize=False,
add_exclamation=False
)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display} - {texts.format_price(final_price)}",
text=button_text,
callback_data=f"extend_period_{days}"
)
])
@@ -2074,33 +2348,39 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
texts = get_texts(language)
keyboard = []
if show_countries_management:
keyboard.append([
InlineKeyboardButton(text=texts.t("ADD_COUNTRIES_BUTTON", "🌐 Добавить страны"), callback_data="subscription_add_countries")
])
keyboard.extend([
[
InlineKeyboardButton(text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"), callback_data="subscription_change_devices")
],
[
InlineKeyboardButton(text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"), callback_data="subscription_manage_devices")
]
])
if settings.is_traffic_selectable():
keyboard.insert(-2, [
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
keyboard.insert(-2, [
keyboard.append([
InlineKeyboardButton(text=texts.t("RESET_TRAFFIC_BUTTON", "🔄 Сбросить трафик"), callback_data="subscription_reset_traffic")
])
keyboard.append([
InlineKeyboardButton(text=texts.t("SWITCH_TRAFFIC_BUTTON", "🔄 Переключить трафик"), callback_data="subscription_switch_traffic")
])
if settings.is_devices_selection_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("CHANGE_DEVICES_BUTTON", "📱 Изменить устройства"),
callback_data="subscription_change_devices"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
callback_data="subscription_manage_devices"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+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: "Пополнение через поддержку отключено. Попробуйте другой способ оплаты."
+269 -11
View File
@@ -3,6 +3,7 @@
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
"ADD_TRAFFIC_PROMPT": "📈 <b>Add traffic to your subscription</b>\n\nCurrent limit: {current_traffic}\nChoose extra traffic:",
"BUY_TRAFFIC_BUTTON": "📈 Buy more traffic",
"ADMIN_BACK_TO_ADMIN": "⬅️ Back to admin",
"ADMIN_BACK_TO_LIST": "⬅️ Back to list",
"ADMIN_BACK_TO_MAIN": "🏠 Back to main menu",
@@ -48,6 +49,76 @@
"ADMIN_CAMPAIGN_SERVERS": "🌍 Servers",
"ADMIN_CAMPAIGN_STATS": "📊 Statistics",
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic",
"ADMIN_CONTESTS": "🏆 Contests",
"ADMIN_CONTESTS_CREATE": " New contest",
"ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.",
"ADMIN_CONTESTS_LIST": "📋 Current contests",
"ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests",
"ADMIN_CONTESTS_DAILY": "📆 Daily contests",
"ADMIN_CONTESTS_COMING_SOON": "Coming soon.",
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Contests</b>\n",
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\n\nChoose an action:",
"ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase",
"ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Referral registration",
"ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.",
"ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:",
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):",
"ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):",
"ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:",
"ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:",
"ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Multiple times allowed, comma-separated HH:MM (e.g., 12:00 or 12:00,18:00).",
"ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00",
"ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00",
"ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.",
"ADMIN_CONTEST_CREATED": "Contest created!",
"ADMIN_CONTEST_PRIZE": "Prize: {prize}",
"ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}",
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active",
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled",
"ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: <b>{count}</b>",
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:",
"ADMIN_CONTEST_LEADERBOARD": "📊 Leaders",
"ADMIN_CONTEST_ENABLE": "▶️ Start",
"ADMIN_CONTEST_DISABLE": "⏸️ Pause",
"ADMIN_CONTEST_START_MANUAL": "🧪 Manual start",
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Daily summaries",
"ADMIN_CONTEST_DELETE": "🗑 Delete",
"ADMIN_CONTEST_DELETE_RESTRICT": "Only finished contests can be deleted.",
"ADMIN_CONTEST_DELETED": "Contest deleted.",
"ADMIN_CONTEST_NOT_FOUND": "Contest not found.",
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.",
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests",
"ADMIN_EDIT_PRIZE": "🏅 Prize (days)",
"ADMIN_EDIT_MAX_WINNERS": "👥 Winners",
"ADMIN_EDIT_ATTEMPTS": "🔁 Attempts",
"ADMIN_EDIT_TIMES": "⏰ Rounds/day",
"ADMIN_EDIT_SCHEDULE": "🕒 Schedule",
"ADMIN_EDIT_COOLDOWN": "⌛ Duration",
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
"ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:",
"ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:",
"ADMIN_ROUND_STARTED": "Round started",
"ADMIN_UPDATED": "Updated",
"ADMIN_INVALID_NUMBER": "Invalid number",
"ADMIN_INVALID_JSON": "Invalid JSON",
"CONTEST_MENU_TITLE": "🎲 <b>Games/Contests</b>\nChoose a game:",
"CONTEST_EMPTY": "No games right now",
"CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.",
"CONTEST_ROUND_FINISHED": "Round finished or unavailable.",
"CONTEST_ALREADY_PLAYED": "You already played this round.",
"CONTEST_UNKNOWN": "Contest type not supported.",
"CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:",
"CONTEST_LOCKS_PROMPT": "Find the hacked lock:",
"CONTEST_SERVER_PROMPT": "Choose a server:",
"CONTEST_CIPHER_PROMPT": "Decode: {q}",
"CONTEST_EMOJI_PROMPT": "Guess the service: {q}",
"CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}",
"CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”",
"CONTEST_BLITZ_BUTTON": "I'm here!",
"CONTEST_WIN": "🎉 You win! ",
"CONTEST_LOSE": "Wrong, try next round.",
"CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!",
"ADMIN_CANCEL": "❌ Cancel",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers",
@@ -130,6 +201,8 @@
"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",
"ADMIN_MESSAGES_ALL_USERS": "📨 All users",
@@ -164,6 +237,44 @@
"ADMIN_MONITORING_STOP": "⏸️ Stop",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Stop",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Test notifications",
"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",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Processing",
"ADMIN_PAYMENT_STATUS_PAID": "Paid",
"ADMIN_PAYMENT_STATUS_FAILED": "Failed",
"ADMIN_PAYMENT_STATUS_CANCELED": "Cancelled",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Unknown status",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "On hold",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Expired",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Payment details</b>",
"ADMIN_PAYMENT_AMOUNT": "Amount",
"ADMIN_PAYMENT_CREATED": "Created",
"ADMIN_PAYMENT_EXPIRES": "Expires",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Gateway status",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Method",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Gateway amount",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Payer account",
"ADMIN_PAYMENT_GATEWAY_ID": "Gateway ID",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Paid amount",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Crypto amount",
"ADMIN_PAYMENT_HAS_LINK": "🔗 A payment link is available via the button above.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Open link",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Check status",
"ADMIN_PAYMENT_CHECK_HINT": "️ You can trigger a manual status check.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Manual status check is not available for this invoice.",
"ADMIN_PAYMENT_CHECK_FAILED": "Failed to refresh the payment status.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Status updated: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Status did not change after the check.",
"ADMIN_PAYMENT_NOT_FOUND": "Payment not found.",
"ADMIN_NODE_DISABLE": "⏸️ Disable",
"ADMIN_NODE_ENABLE": "▶️ Enable",
"ADMIN_NODE_RESTART": "🔄 Restart",
@@ -280,6 +391,7 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Balance",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -336,7 +448,18 @@
"ADMIN_PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Period discounts:",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group {name} updated.",
"ADMIN_PROMO_GROUP_UPDATED": "Promo group \"{name}\" updated.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Priority: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Enter group priority (0 = base, higher = higher priority):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Enter new priority (current: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Priority",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Priority must be a non-negative integer",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Primary: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Additional groups:",
"ADMIN_USER_PROMO_GROUPS_NONE": "User has no promo groups",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Group «{name}» added",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Group «{name}» removed",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Cannot remove the last promo group",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Promo offers</b>\n\nSelect a template to configure:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "After activation the discount lasts for {hours} h.",
"ADMIN_PROMO_OFFER_ALLOWED": "Available segments:",
@@ -659,6 +782,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",
@@ -679,6 +806,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}%",
@@ -746,6 +889,10 @@
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
"BALANCE_TOPUP": "💳 Top up balance",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Balance top-up required</b>\\n\\nYour cart contains items totaling {total_amount}, but your current balance is insufficient.\\n\\n💳 <b>Top up your balance</b> to complete the purchase.\\n\\nChoose a top-up method:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Your {period} subscription was purchased automatically after topping up your balance.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Subscription automatically extended for {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ New expiration date: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Open the My subscription section to access your link and setup instructions.",
"BALANCE_TOP_UP": "💳 Top up",
"BLOCK_BY_TIME": "⏳ Temporary block",
"BLOCK_FOREVER": "🚫 Block permanently",
@@ -793,6 +940,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",
@@ -818,6 +966,9 @@
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
"DEVICE_CHANGE_NO_REFUND_INFO": "️ Payments are not refunded",
"DEVICE_CHANGE_RESULT_LINE": "📱 Was: {old} → Now: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Invalid request",
"DEVICES_INVALID_COUNT": "❌ Invalid device count",
"DEVICES_SELECTION_DISABLED": "⚠️ Device selection is unavailable",
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
"DEVICE_FETCH_ERROR": "❌ Failed to load devices",
"DEVICE_FETCH_INFO_ERROR": "❌ Failed to load device information",
@@ -964,7 +1115,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.",
@@ -974,8 +1124,27 @@
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
"PAYMENT_CARD_WATA": "💳 Bank card (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Failed to charge the payment",
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Cryptocurrency (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Payment has already been credited",
"HELEKET_PAYMENT_SUCCESS": "✅ Payment credited to your balance",
"HELEKET_STATUS_CHECK": "⏳ Waiting for payment",
"HELEKET_STATUS_PROCESS": "⚙️ Payment is being processed",
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Waiting for network confirmations",
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Incorrect amount paid",
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Not enough amount, waiting for additional payment",
"HELEKET_STATUS_PAID_OVER": "✅ Payment credited (overpaid)",
"HELEKET_STATUS_PAID": "✅ Payment credited",
"HELEKET_STATUS_CANCEL": "🚫 Payment was cancelled",
"HELEKET_STATUS_FAIL": "❌ Payment failed",
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Heleket system error",
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Refund in progress",
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Refund failed",
"HELEKET_STATUS_REFUND_PAID": "✅ Refund completed",
"HELEKET_STATUS_LOCKED": "🔒 Funds are locked",
"HELEKET_STATUS_UNKNOWN": "️ Payment status: {status}",
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
@@ -985,24 +1154,38 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</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",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"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",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>SBP (YooKassa)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Provider markup",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Provider discount",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Payment via Platega ({method_name})</b>\n\nEnter an amount from {min_amount} to {max_amount}.\nPayment is processed by Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Choose a Platega payment method:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Platega payments are temporarily unavailable",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ No active Platega methods configured",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Select a Platega payment method before entering the amount",
"PLATEGA_AMOUNT_TOO_LOW": "Minimum amount for Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Maximum amount for Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Failed to create Platega payment. Please try again later or contact support.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Payment via Platega ({method})</b>\n\n💰 Amount: {amount}\n🆔 Transaction ID: {transaction}\n\n📱 <b>Instructions:</b>\n1. Tap the Pay button\n2. Follow the payment provider instructions\n3. Confirm the transfer\n4. Funds will be credited automatically\n\n❓ If you have issues, contact {support}",
"PLATEGA_PAY_BUTTON": "💳 Pay via {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Payment already credited",
"PLATEGA_PAYMENT_STATUS": "Current payment status: {status}",
"PAY_NOW_BUTTON": "💳 Pay",
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
@@ -1058,7 +1241,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a referral code or promo code?</b>\n\nIf you have a promo code or referral link from a friend, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Invalid referral code or promo code",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Invalid referral code or promo code.\n\n💡 If you have a referral code or promo code, please double-check the spelling.\n⏭️ To continue registration without a code, use the /start command.",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Promo code activated!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Promo code accepted! It will be activated after registration is complete.",
"REFERRAL_CODE_SKIP": "⏭️ Skip",
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
@@ -1121,8 +1308,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:",
@@ -1150,7 +1343,7 @@
"SKIP_BUTTON": "Skip ➡️",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\n⚠️ <b>Important:</b> Balance top-up does not automatically activate your subscription. Please activate your subscription separately!\n\n🔄 If you have a saved subscription cart and auto-purchase is enabled, your subscription will be automatically purchased after balance top-up.\n\nThank you for topping up! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
@@ -1168,7 +1361,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\n️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
@@ -1245,6 +1438,8 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Disabled",
"SUB_STATUS_PENDING": "⏳ Pending activation",
"SUB_STATUS_NONE": "❌ Not available",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
@@ -1315,10 +1510,19 @@
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Unlimited",
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a trial plan{price_line}:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic}{devices_line}\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Devices:</b> {devices} pcs",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Activation price:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Not enough funds to activate the trial.\nRequired: {required}\nOn balance: {balance}\nMissing: {missing}\n\nTop up your balance and try again.",
"TRIAL_PAYMENT_FAILED": "We couldn't charge your balance to activate the trial. Please try again later.",
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
@@ -1347,5 +1551,59 @@
"WATA_TOPUP_PROMPT": "💳 <b>Pay via WATA</b>\n\nEnter the amount to top up. Minimum amount — {min_amount}, maximum — {max_amount}.\nPayments are processed through the secure WATA form.",
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
"WELCOME_FALLBACK": "Welcome, {user_name}!",
"YES": "✅ Yes"
"YES": "✅ Yes",
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "️ Your trial subscription is active for another {days} days. Purchasing a new plan will extend the validity.",
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "️ Your trial is still running (expires in {days} days). Buying a paid plan will extend it automatically.",
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Any available",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Selected",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Assigned automatically",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Quick purchase",
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Polls",
"ADMIN_POLLS_CREATE": " Create poll",
"ADMIN_POLLS_REWARD_ENABLED": "Reward: {amount}",
"ADMIN_POLLS_REWARD_DISABLED": "Reward disabled",
"ADMIN_POLLS_SEND": "📤 Send",
"ADMIN_POLLS_STATS": "📊 Stats",
"ADMIN_POLLS_DELETE": "🗑️ Delete",
"ADMIN_POLLS_BACK": "⬅️ Back to list",
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ Custom filters",
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Send",
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Polls</b>",
"ADMIN_POLLS_LIST_EMPTY": "No polls yet.",
"ADMIN_POLLS_QUESTIONS_COUNT": "Questions: {count}",
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Create poll</b>\n\nEnter poll title:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Poll creation cancelled.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Enter poll description. HTML is allowed.\nSend /skip to omit.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Description omitted.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ HTML error: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Enter reward amount in RUB. Send 0 to disable reward.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Invalid amount. Try again.",
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Send the question and answer options.\nEach line is a separate option.\nThe first line is the question text.\nSend /done when finished.",
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Add at least one question.",
"ADMIN_POLLS_CREATION_FINISHED": "✅ Poll “{title}” created. Questions: {count}. {reward}",
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Provide a question and at least two answer options.",
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Question added: “{question}”. Add another question or send /done.",
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Questions:</b>",
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Select audience for the poll:",
"ADMIN_POLLS_CUSTOM_PROMPT": "Choose an additional audience filter:",
"ADMIN_POLLS_SEND_CONFIRM": "📤 Send poll “{title}” to “{audience}”? Users: {count}",
"ADMIN_POLLS_SENDING": "📤 Sending poll...",
"ADMIN_POLLS_SEND_RESULT": "📤 Poll finished\nDelivered: {sent}\nFailed: {failed}\nSkipped: {skipped}\nTotal: {total}",
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Poll statistics</b>",
"ADMIN_POLLS_STATS_OVERVIEW": "Invited: {total}\nCompleted: {completed}\nRewards paid: {reward}",
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
"ADMIN_POLLS_CONFIRM_DELETE": "Delete poll “{title}”?",
"ADMIN_POLLS_DELETED": "🗑️ Poll deleted.",
"POLL_INVITATION_REWARD": "🎁 You will receive {amount} for participating.",
"POLL_INVITATION_START": "Tap the button below to answer the poll.",
"POLL_START_BUTTON": "📝 Take the poll",
"POLL_QUESTION_HEADER": "<b>Question {current}/{total}</b>",
"POLL_ALREADY_COMPLETED": "You have already completed this poll.",
"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.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
}
+284 -11
View File
@@ -3,6 +3,7 @@
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
"ADD_TRAFFIC_PROMPT": "📈 <b>Добавить трафик к подписке</b>\n\nТекущий лимит: {current_traffic}\nВыберите дополнительный трафик:",
"BUY_TRAFFIC_BUTTON": "📈 Докупить трафик",
"ADMIN_BACK_TO_ADMIN": "⬅️ Назад в админку",
"ADMIN_BACK_TO_LIST": "⬅️ К списку",
"ADMIN_BACK_TO_MAIN": "🏠 В главное меню",
@@ -48,6 +49,79 @@
"ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы",
"ADMIN_CAMPAIGN_STATS": "📊 Статистика",
"ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик",
"ADMIN_CONTESTS": "🏆 Конкурсы",
"ADMIN_CONTESTS_CREATE": "➕ Новый конкурс",
"ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.",
"ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы",
"ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы",
"ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы",
"ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.",
"ADMIN_CONTESTS_LIST_HEADER": "🏆 <b>Конкурсы</b>\n",
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\n\nВыберите действие:",
"ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой",
"ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Просто реферал",
"ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.",
"ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:",
"ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):",
"ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):",
"ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:",
"ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:",
"ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Можно несколько через запятую, формат ЧЧ:ММ (например, 12:00 или 12:00,18:00).",
"ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00",
"ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00",
"ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.",
"ADMIN_CONTEST_CREATED": "Конкурс создан!",
"ADMIN_CONTEST_PRIZE": "Приз: {prize}",
"ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}",
"ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен",
"ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен",
"ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: <b>{count}</b>",
"ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:",
"ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры",
"ADMIN_CONTEST_ENABLE": "▶️ Запустить",
"ADMIN_CONTEST_DISABLE": "⏸️ Остановить",
"ADMIN_CONTEST_START_MANUAL": "🧪 Ручной старт",
"ADMIN_CONTEST_EDIT_SUMMARY_TIMES": "🕒 Итоги в день",
"ADMIN_CONTEST_DELETE": "🗑 Удалить",
"ADMIN_CONTEST_DELETE_RESTRICT": "Удалять можно только завершённые конкурсы.",
"ADMIN_CONTEST_DELETED": "Конкурс удалён.",
"ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.",
"ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.",
"ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы",
"ADMIN_EDIT_PRIZE": "🏅 Приз (дни)",
"ADMIN_EDIT_MAX_WINNERS": "👥 Победителей",
"ADMIN_EDIT_ATTEMPTS": "🔁 Попытки",
"ADMIN_EDIT_TIMES": "⏰ Раундов/день",
"ADMIN_EDIT_SCHEDULE": "🕒 Расписание",
"ADMIN_EDIT_COOLDOWN": "⌛ Длительность",
"ADMIN_EDIT_PAYLOAD": "🧩 Payload",
"ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:",
"ADMIN_CONTEST_START_NOW": "Запустить раунд сейчас",
"ADMIN_RESET_ATTEMPTS": "🔄 Сбросить попытки",
"ADMIN_CLOSE_ROUND": "❌ Закрыть раунд",
"ADMIN_ROUND_STARTED": "Раунд запущен",
"ADMIN_UPDATED": "Обновлено",
"ADMIN_INVALID_NUMBER": "Некорректное число",
"ADMIN_INVALID_JSON": "Некорректный JSON",
"CONTEST_MENU_TITLE": "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:",
"CONTESTS_BUTTON": "🎲 Конкурсы",
"CONTEST_EMPTY": "Сейчас игр нет",
"CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.",
"CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.",
"CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.",
"CONTEST_UNKNOWN": "Тип конкурса не поддерживается.",
"CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:",
"CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:",
"CONTEST_SERVER_PROMPT": "Выбери сервер:",
"CONTEST_CIPHER_PROMPT": "Расшифруй: {q}",
"CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}",
"CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}",
"CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»",
"CONTEST_BLITZ_BUTTON": "Я здесь!",
"CONTEST_WIN": "🎉 Победа! ",
"CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.",
"CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!",
"ADMIN_CANCEL": "❌ Отмена",
"ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню",
"ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения",
@@ -130,6 +204,8 @@
"ADMIN_MAIN_SETTINGS": "⚙️ Настройки",
"ADMIN_MAIN_SUPPORT": "🛟 Поддержка",
"ADMIN_MAIN_SYSTEM": "🛠️ Система",
"ADMIN_MAIN_TRIALS": "🧪 Триалы",
"ADMIN_MAIN_PAYMENTS": "💳 Пополнения",
"ADMIN_MAIN_USERS_SUBSCRIPTIONS": "👥 Юзеры/Подписки",
"ADMIN_MESSAGES": "📨 Рассылки",
"ADMIN_MESSAGES_ALL_USERS": "📨 Всем пользователям",
@@ -164,6 +240,44 @@
"ADMIN_MONITORING_STOP": "⏸️ Остановить",
"ADMIN_MONITORING_STOP_HARD": "⏹️ Остановить",
"ADMIN_MONITORING_TEST_NOTIFICATIONS": "🧪 Тест уведомлений",
"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": "Ожидает оплаты",
"ADMIN_PAYMENT_STATUS_PROCESSING": "Обрабатывается",
"ADMIN_PAYMENT_STATUS_PAID": "Оплачен",
"ADMIN_PAYMENT_STATUS_FAILED": "Ошибка",
"ADMIN_PAYMENT_STATUS_CANCELED": "Отменён",
"ADMIN_PAYMENT_STATUS_UNKNOWN": "Статус неизвестен",
"ADMIN_PAYMENT_STATUS_ON_HOLD": "На удержании",
"ADMIN_PAYMENT_STATUS_EXPIRED": "Просрочен",
"ADMIN_PAYMENT_DETAILS_TITLE": "💳 <b>Детали платежа</b>",
"ADMIN_PAYMENT_AMOUNT": "Сумма",
"ADMIN_PAYMENT_CREATED": "Создан",
"ADMIN_PAYMENT_EXPIRES": "Истекает",
"ADMIN_PAYMENT_GATEWAY_STATUS": "Статус в платёжке",
"ADMIN_PAYMENT_GATEWAY_METHOD": "Метод оплаты",
"ADMIN_PAYMENT_GATEWAY_AMOUNT": "Сумма в платёжке",
"ADMIN_PAYMENT_GATEWAY_ACCOUNT": "Счёт плательщика",
"ADMIN_PAYMENT_GATEWAY_ID": "ID в платёжке",
"ADMIN_PAYMENT_PAYER_AMOUNT": "Оплачено",
"ADMIN_PAYMENT_CRYPTO_AMOUNT": "Сумма в криптовалюте",
"ADMIN_PAYMENT_HAS_LINK": "🔗 Ссылка на оплату доступна в кнопке выше.",
"ADMIN_PAYMENT_OPEN_LINK": "🔗 Открыть ссылку",
"ADMIN_PAYMENT_CHECK_BUTTON": "🔁 Проверить статус",
"ADMIN_PAYMENT_CHECK_HINT": "ℹ️ Можно запустить ручную проверку статуса.",
"ADMIN_PAYMENT_CHECK_NOT_AVAILABLE": "Для этого счёта ручная проверка недоступна.",
"ADMIN_PAYMENT_CHECK_FAILED": "Не удалось обновить статус платежа.",
"ADMIN_PAYMENT_CHECK_SUCCESS": "Статус обновлён: {status}",
"ADMIN_PAYMENT_CHECK_NO_CHANGES": "Статус не изменился после проверки.",
"ADMIN_PAYMENT_NOT_FOUND": "Платёж не найден.",
"ADMIN_NODE_DISABLE": "⏸️ Отключить",
"ADMIN_NODE_ENABLE": "▶️ Включить",
"ADMIN_NODE_RESTART": "🔄 Перезагрузить",
@@ -280,6 +394,7 @@
"ADMIN_PROMOCODE_TYPE_BALANCE": "💰 Баланс",
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -337,6 +452,17 @@
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
"ADMIN_PROMO_GROUP_PRIORITY_LINE": "🎯 Приоритет: {priority}",
"ADMIN_PROMO_GROUP_CREATE_PRIORITY_PROMPT": "Введите приоритет группы (0 = базовая, чем больше - тем выше приоритет):",
"ADMIN_PROMO_GROUP_EDIT_PRIORITY_PROMPT": "Введите новый приоритет (текущий: {current}):",
"ADMIN_PROMO_GROUP_EDIT_FIELD_PRIORITY": "🎯 Приоритет",
"ADMIN_PROMO_GROUP_INVALID_PRIORITY": "❌ Приоритет должен быть неотрицательным целым числом",
"ADMIN_USER_PROMO_GROUPS_PRIMARY": "⭐ Основная: {name} (Priority: {priority})",
"ADMIN_USER_PROMO_GROUPS_ADDITIONAL": "Дополнительные группы:",
"ADMIN_USER_PROMO_GROUPS_NONE": "У пользователя нет промогрупп",
"ADMIN_USER_PROMO_GROUP_ADDED": "✅ Группа «{name}» добавлена",
"ADMIN_USER_PROMO_GROUP_REMOVED": "🗑 Группа «{name}» удалена",
"ADMIN_USER_PROMO_GROUP_CANNOT_REMOVE_LAST": "❌ Нельзя удалить последнюю промогруппу",
"ADMIN_PROMO_OFFERS_TITLE": "🎯 <b>Промо-предложения</b>\n\nВыберите предложение для настройки:",
"ADMIN_PROMO_OFFER_ACTIVE_DURATION": "Скидка после активации действует {hours} ч.",
"ADMIN_PROMO_OFFER_ALLOWED": "Доступные категории:",
@@ -636,6 +762,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": "🔄 Полная синхронизация",
@@ -659,6 +786,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": "💳 По сумме трат",
@@ -679,6 +810,33 @@
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
"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} рефералов",
"ADMIN_USER_REFERRALS_EMPTY": "Рефералов пока нет.",
"ADMIN_USER_REFERRALS_EDIT_HINT": "✏️ Чтобы изменить список, нажмите «✏️ Редактировать» ниже.",
"ADMIN_USER_REFERRALS_EDIT_BUTTON": "✏️ Редактировать",
"ADMIN_USER_REFERRALS_EDIT_PROMPT": "✏️ <b>Редактирование рефералов</b>\n\nОтправьте список рефералов для пользователя <b>{name}</b> (ID: <code>{telegram_id}</code>):\n• Используйте TG ID или @username\n• Значения можно указывать через запятую, пробел или с новой строки\n• Чтобы очистить список, отправьте 0 или слово 'нет'\n\nИли нажмите кнопку ниже, чтобы отменить.",
"ADMIN_USER_REFERRALS_STATE_LOST": "❌ Не удалось определить пользователя. Попробуйте начать сначала.",
"ADMIN_USER_REFERRALS_NO_VALID": "❌ Не удалось найти ни одного пользователя по введённым данным.",
"ADMIN_USER_REFERRALS_INVALID_ENTRIES": "Не найдены: {values}",
"ADMIN_USER_REFERRALS_SELF_SKIPPED": "Пропущены значения пользователя: {values}",
"ADMIN_USER_REFERRALS_DUPLICATES": "Игнорированы дубли: {values}",
"ADMIN_USER_REFERRALS_UPDATE_ERROR": "❌ Не удалось обновить рефералов. Попробуйте позже.",
"ADMIN_USER_REFERRALS_UPDATED": "✅ Список рефералов обновлён.",
"ADMIN_USER_REFERRALS_UPDATED_TOTAL": "• Текущий список: {total}",
"ADMIN_USER_REFERRALS_UPDATED_ADDED": "• Добавлено: {count}",
"ADMIN_USER_REFERRALS_UPDATED_REMOVED": "• Удалено: {count}",
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
@@ -745,7 +903,11 @@
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
"BALANCE_TOPUP": "💳 Пополнить баланс",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\\n💡 <b>Требуется пополнение баланса</b>\\n\\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\\n\\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\\n\\nВыберите способ пополнения:",
"BALANCE_TOPUP_CART_REMINDER_DETAILED": "\n💡 <b>Требуется пополнение баланса</b>\n\nВ вашей корзине находятся товары на общую сумму {total_amount}, но на балансе недостаточно средств.\n\n💳 <b>Пополните баланс</b>, чтобы завершить покупку.\n\nВыберите способ пополнения:",
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS": "✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED": "✅ Подписка автоматически продлена на {period}.",
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS": "⏰ Новая дата окончания: {date}.",
"AUTO_PURCHASE_SUBSCRIPTION_HINT": "Перейдите в раздел «Моя подписка», чтобы получить ссылку и инструкции.",
"BALANCE_TOP_UP": "💳 Пополнить",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"BLOCK_FOREVER": "🚫 Заблокировать",
@@ -793,6 +955,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": "ℹ️ Управление серверами недоступно - доступен только один сервер",
@@ -818,6 +981,9 @@
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
"DEVICE_CHANGE_NO_REFUND_INFO": "ℹ️ Возврат средств не производится",
"DEVICE_CHANGE_RESULT_LINE": "📱 Было: {old} → Стало: {new}\n",
"DEVICES_INVALID_REQUEST": "❌ Некорректный запрос",
"DEVICES_INVALID_COUNT": "❌ Некорректное количество устройств",
"DEVICES_SELECTION_DISABLED": "⚠️ Выбор количества устройств недоступен",
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
"DEVICE_FETCH_ERROR": "❌ Ошибка получения устройств",
"DEVICE_FETCH_INFO_ERROR": "❌ Ошибка получения информации об устройствах",
@@ -964,7 +1130,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.",
@@ -974,8 +1139,27 @@
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
"PAYMENT_CARD_WATA": "💳 Банковская карта (WATA)",
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
"PAYMENT_PLATEGA": "💳 Platega",
"PAYMENT_CHARGE_ERROR": "⚠️ Ошибка списания средств",
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
"PAYMENT_HELEKET": "🪙 Криптовалюта (Heleket)",
"HELEKET_PAYMENT_ALREADY_PAID": "✅ Платёж уже зачислен",
"HELEKET_PAYMENT_SUCCESS": "✅ Платёж зачислен на баланс",
"HELEKET_STATUS_CHECK": "⏳ Ожидаем оплату",
"HELEKET_STATUS_PROCESS": "⚙️ Платёж обрабатывается",
"HELEKET_STATUS_CONFIRM_CHECK": "⛓ Ожидание подтверждений сети",
"HELEKET_STATUS_WRONG_AMOUNT": "❗️ Оплачена неверная сумма",
"HELEKET_STATUS_WRONG_AMOUNT_WAITING": "❗️ Недостаточная сумма, ожидаем доплату",
"HELEKET_STATUS_PAID_OVER": "✅ Платёж зачислен (с переплатой)",
"HELEKET_STATUS_PAID": "✅ Платёж зачислен",
"HELEKET_STATUS_CANCEL": "🚫 Платёж отменён",
"HELEKET_STATUS_FAIL": "❌ Ошибка при оплате",
"HELEKET_STATUS_SYSTEM_FAIL": "❌ Системная ошибка Heleket",
"HELEKET_STATUS_REFUND_PROCESS": "↩️ Возврат обрабатывается",
"HELEKET_STATUS_REFUND_FAIL": "⚠️ Ошибка возврата",
"HELEKET_STATUS_REFUND_PAID": "✅ Возврат выполнен",
"HELEKET_STATUS_LOCKED": "🔒 Средства заблокированы",
"HELEKET_STATUS_UNKNOWN": "ℹ️ Статус платежа: {status}",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
@@ -985,24 +1169,38 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</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": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"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",
"PAYMENT_METHOD_YOOKASSA_SBP_NAME": "🏦 <b>СБП (YooKassa)</b>",
"PAYMENT_HELEKET_MARKUP_LABEL": "Наценка провайдера",
"PAYMENT_HELEKET_DISCOUNT_LABEL": "Скидка провайдера",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
"PLATEGA_TOPUP_PROMPT": "💳 <b>Оплата через Platega ({method_name})</b>\n\nВведите сумму для пополнения от {min_amount} до {max_amount}.\nОплата происходит через Platega.",
"PLATEGA_SELECT_PAYMENT_METHOD": "Выберите способ оплаты Platega:",
"PLATEGA_TEMPORARILY_UNAVAILABLE": "❌ Оплата через Platega временно недоступна",
"PLATEGA_METHODS_NOT_CONFIGURED": "⚠️ На стороне Platega нет доступных методов оплаты",
"PLATEGA_METHOD_SELECTION_REQUIRED": "⚠️ Выберите способ оплаты Platega перед вводом суммы",
"PLATEGA_AMOUNT_TOO_LOW": "Минимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_AMOUNT_TOO_HIGH": "Максимальная сумма для оплаты через Platega: {amount}",
"PLATEGA_PAYMENT_ERROR": "❌ Ошибка создания платежа Platega. Попробуйте позже или обратитесь в поддержку.",
"PLATEGA_PAYMENT_INSTRUCTIONS": "💳 <b>Оплата через Platega ({method})</b>\n\n💰 Сумма: {amount}\n🆔 ID транзакции: {transaction}\n\n📱 <b>Инструкция:</b>\n1. Нажмите кнопку «Оплатить»\n2. Следуйте подсказкам платёжной системы\n3. Подтвердите перевод\n4. Средства зачислятся автоматически\n\n❓ Если возникнут проблемы, обратитесь в {support}",
"PLATEGA_PAY_BUTTON": "💳 Оплатить через {method}",
"PLATEGA_PAYMENT_ALREADY_CONFIRMED": "✅ Платёж уже зачислен",
"PLATEGA_PAYMENT_STATUS": "Текущий статус платежа: {status}",
"PAY_NOW_BUTTON": "💳 Оплатить",
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
@@ -1058,7 +1256,11 @@
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код или промокод?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
"REFERRAL_OR_PROMO_CODE_INVALID": "❌ Неверный реферальный код или промокод",
"REFERRAL_OR_PROMO_CODE_INVALID_HELP": "❌ Неверный реферальный код или промокод.\n\n💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без кода используйте команду /start",
"PROMOCODE_ACTIVATED_AT_REGISTRATION": "✅ Промокод активирован!\n\n{description}",
"PROMOCODE_ACCEPTED_WILL_ACTIVATE": "✅ Промокод принят! Он будет активирован после завершения регистрации.",
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
@@ -1121,8 +1323,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": "Выберите период:",
@@ -1150,7 +1358,7 @@
"SKIP_BUTTON": "⏭️ Пропустить",
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n\n🆔 ID транзакции: {transaction_id}...\n\n⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. Обязательно активируйте подписку отдельно!\n\n🔄 При наличии сохранённой корзины подписки и включенной автопокупке, подписка будет приобретена автоматически после пополнения баланса.\n\nСпасибо за пополнение! 🚀",
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
@@ -1168,7 +1376,7 @@
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n{subscription_url}",
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
@@ -1245,6 +1453,8 @@
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
"SUB_STATUS_DISABLED": "⚫ Отключена",
"SUB_STATUS_PENDING": "⏳ Ожидает активации",
"SUB_STATUS_NONE": "❌ Отсутствует",
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
@@ -1315,10 +1525,19 @@
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
"TRAFFIC_UNLIMITED_SHORT": "Безлимит",
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить тестовую подписку{price_line}:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic}{devices_line}\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
"TRIAL_AVAILABLE_DEVICES_LINE": "\n📱 <b>Устройства:</b> {devices} шт.",
"TRIAL_PAYMENT_PRICE_LINE": "\n💳 <b>Стоимость активации:</b> {price}",
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS": "⚠️ Недостаточно средств для активации триала.\nНеобходимо: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nПополните баланс и попробуйте снова.",
"TRIAL_PAYMENT_FAILED": "Не удалось списать средства для активации триала. Попробуйте позже.",
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
@@ -1347,5 +1566,59 @@
"WATA_TOPUP_PROMPT": "💳 <b>Оплата через WATA</b>\n\nВведите сумму пополнения. Минимальная сумма — {min_amount}, максимальная — {max_amount}.\nОплата происходит через защищенную форму WATA.",
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
"YES": "✅ Да"
"YES": "✅ Да",
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_ACTIVE": "ℹ️ У вас уже есть активная триальная подписка. Она будет действовать ещё {days} дн. После покупки к сроку добавится оплаченный период.",
"SIMPLE_SUBSCRIPTION_TRIAL_NOTICE_TRIAL": "ℹ️ У вас сейчас триальная подписка. Она истекает через {days} дн. Покупка новой продлит срок автоматически.",
"SIMPLE_SUBSCRIPTION_SERVER_ANY": "Любой доступный",
"SIMPLE_SUBSCRIPTION_SERVER_SELECTED": "Выбранный",
"SIMPLE_SUBSCRIPTION_SERVER_ASSIGNED": "Назначен автоматически",
"MENU_SIMPLE_SUBSCRIPTION": "⚡ Простая покупка",
"ADMIN_COMMUNICATIONS_POLLS": "🗳️ Опросы",
"ADMIN_POLLS_CREATE": "➕ Создать опрос",
"ADMIN_POLLS_REWARD_ENABLED": "Награда: {amount}",
"ADMIN_POLLS_REWARD_DISABLED": "Награда отключена",
"ADMIN_POLLS_SEND": "📤 Отправить",
"ADMIN_POLLS_STATS": "📊 Статистика",
"ADMIN_POLLS_DELETE": "🗑️ Удалить",
"ADMIN_POLLS_BACK": "⬅️ К списку",
"ADMIN_POLLS_CUSTOM_TARGET": "⚙️ По критериям",
"ADMIN_POLLS_SEND_CONFIRM_BUTTON": "✅ Отправить",
"ADMIN_POLLS_LIST_TITLE": "🗳️ <b>Опросы</b>",
"ADMIN_POLLS_LIST_EMPTY": "Опросов пока нет.",
"ADMIN_POLLS_QUESTIONS_COUNT": "Вопросов: {count}",
"ADMIN_POLLS_CREATION_TITLE_PROMPT": "🗳️ <b>Создание опроса</b>\n\nВведите заголовок опроса:",
"ADMIN_POLLS_CREATION_CANCELLED": "❌ Создание опроса отменено.",
"ADMIN_POLLS_CREATION_DESCRIPTION_PROMPT": "Введите описание опроса. HTML разрешён.\nОтправьте /skip, чтобы пропустить.",
"ADMIN_POLLS_CREATION_DESCRIPTION_SKIPPED": "Описание пропущено.",
"ADMIN_POLLS_CREATION_INVALID_HTML": "❌ Ошибка в HTML: {error}",
"ADMIN_POLLS_CREATION_REWARD_PROMPT": "Введите сумму награды в рублях. Отправьте 0 чтобы отключить награду.",
"ADMIN_POLLS_CREATION_REWARD_INVALID": "❌ Некорректная сумма. Попробуйте ещё раз.",
"ADMIN_POLLS_CREATION_QUESTION_PROMPT": "Введите вопрос и варианты ответов.\nКаждая строка — отдельный вариант.\nПервая строка — текст вопроса.\nОтправьте /done, когда вопросы будут добавлены.",
"ADMIN_POLLS_CREATION_NEEDS_QUESTION": "❌ Добавьте хотя бы один вопрос.",
"ADMIN_POLLS_CREATION_FINISHED": "✅ Опрос «{title}» создан. Вопросов: {count}. {reward}",
"ADMIN_POLLS_CREATION_MIN_OPTIONS": "❌ Нужен вопрос и минимум два варианта ответа.",
"ADMIN_POLLS_CREATION_ADDED_QUESTION": "Вопрос добавлен: «{question}». Добавьте следующий вопрос или отправьте /done.",
"ADMIN_POLLS_QUESTION_LIST_HEADER": "<b>Вопросы:</b>",
"ADMIN_POLLS_OPTION_BULLET": " • {option}",
"ADMIN_POLLS_SEND_CHOOSE_TARGET": "🎯 Выберите аудиторию для отправки опроса:",
"ADMIN_POLLS_CUSTOM_PROMPT": "Выберите дополнительный критерий аудитории:",
"ADMIN_POLLS_SEND_CONFIRM": "📤 Отправить опрос «{title}» аудитории «{audience}»? Пользователей: {count}",
"ADMIN_POLLS_SENDING": "📤 Запускаю отправку опроса...",
"ADMIN_POLLS_SEND_RESULT": "📤 Отправка завершена\nУспешно: {sent}\nОшибок: {failed}\nПропущено: {skipped}\nВсего: {total}",
"ADMIN_POLLS_STATS_HEADER": "📊 <b>Статистика опроса</b>",
"ADMIN_POLLS_STATS_OVERVIEW": "Всего приглашено: {total}\nЗавершили: {completed}\nВыплачено наград: {reward}",
"ADMIN_POLLS_STATS_OPTION_LINE": "• {option}: {count}",
"ADMIN_POLLS_CONFIRM_DELETE": "Вы уверены, что хотите удалить опрос «{title}»?",
"ADMIN_POLLS_DELETED": "🗑️ Опрос удалён.",
"POLL_INVITATION_REWARD": "🎁 За участие вы получите {amount}.",
"POLL_INVITATION_START": "Нажмите кнопку ниже, чтобы пройти опрос.",
"POLL_START_BUTTON": "📝 Пройти опрос",
"POLL_QUESTION_HEADER": "<b>Вопрос {current}/{total}</b>",
"POLL_ALREADY_COMPLETED": "Вы уже прошли этот опрос.",
"POLL_EMPTY": "Опрос пока недоступен.",
"POLL_ERROR": "Не удалось обработать опрос. Попробуйте позже.",
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
}

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