Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bc29c3421 | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 | |||
| 2d04f2aa28 | |||
| d6e79161e7 | |||
| 45fd543206 | |||
| ba0a5e9abd | |||
| d712ab8301 | |||
| 1e2a7e3096 | |||
| 64a684cd2f | |||
| e4c207ecff | |||
| 80914c1af7 | |||
| e1822800ab | |||
| 68773b7e77 | |||
| 10538e7351 | |||
| a9687912df | |||
| 46c1a69456 | |||
| bf2b2f1c56 | |||
| 9ac6da490d | |||
| ad87c5fb5e | |||
| 7ac73e5745 | |||
| 61be89743d | |||
| d174d9a927 | |||
| 4048aebb9f | |||
| bfd66c42c1 | |||
| 351c95bac1 | |||
| 1d43ae5e25 | |||
| 476b89fe8e | |||
| 14e13177b5 | |||
| 760c833b74 | |||
| 1a476c49c1 | |||
| 454b83138e | |||
| 2de438426a | |||
| 6039db997c | |||
| 940959c951 | |||
| e688110129 | |||
| 57dc1ff47f | |||
| fc42916b10 | |||
| 5893874776 | |||
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 | |||
| 2ad26a9156 | |||
| 3383b9c790 | |||
| 6acaf18203 | |||
| 019fbc12b6 | |||
| 5156d635f0 | |||
| f77922522a | |||
| 0db00a8f90 | |||
| fe54640885 | |||
| ec8eaf52bf | |||
| fe5f5ded96 | |||
| 2cb6d731e9 | |||
| 184c52d4ea | |||
| 8e85e244cb | |||
| 43a326a98c | |||
| d9de15a5a0 | |||
| 17ce64037f | |||
| 79793c47bb | |||
| 7091eb9c14 | |||
| dc1e96bbe9 | |||
| 1e37fd9dd2 | |||
| 9aa22af339 | |||
| 26637f0ae5 | |||
| 6d67cad3e7 | |||
| 90d9df8f0e | |||
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b | |||
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| 916ad9d567 |
+55
-13
@@ -152,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -187,6 +187,42 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
WEBHOOK_NOTIFY_USER_ENABLED=true
|
||||
# Отключение/активация подписки администратором
|
||||
WEBHOOK_NOTIFY_SUB_STATUS=true
|
||||
# Истечение подписки
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED=true
|
||||
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING=true
|
||||
# Достижение лимита трафика
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED=true
|
||||
# Сброс счётчика трафика
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
|
||||
# Удаление пользователя из панели
|
||||
WEBHOOK_NOTIFY_SUB_DELETED=true
|
||||
# Обновление ключей подписки (revoke)
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED=true
|
||||
# Первое подключение к VPN
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
|
||||
# Напоминание о неподключении
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED=true
|
||||
# Предупреждение о приближении к лимиту трафика
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
|
||||
# Подключение и отключение устройств
|
||||
WEBHOOK_NOTIFY_DEVICES=true
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
@@ -536,6 +572,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
# Ожидаемый origin для iframe (опционально, для безопасности)
|
||||
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
|
||||
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
|
||||
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
@@ -544,7 +582,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -660,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
# Режим главного меню:
|
||||
# default - классический режим работы бота (все кнопки внутри Telegram)
|
||||
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
|
||||
# разделы кабинета (/balance, /subscription, /referral и т.д.)
|
||||
# Требует MINIAPP_CUSTOM_URL
|
||||
# Алиасы для обратной совместимости: text, text_only, minimal
|
||||
MAIN_MENU_MODE=default
|
||||
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
|
||||
# primary - синий
|
||||
# success - зелёный
|
||||
# danger - красный
|
||||
# (пустое) - цвета по умолчанию для каждой секции
|
||||
CABINET_BUTTON_STYLE=
|
||||
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
|
||||
MENU_LAYOUT_ENABLED=false
|
||||
|
||||
@@ -674,7 +722,7 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
@@ -741,7 +789,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -830,7 +878,7 @@ 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
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -838,15 +886,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.7.1"
|
||||
".": "3.12.0"
|
||||
}
|
||||
|
||||
+200
@@ -1,5 +1,205 @@
|
||||
# Changelog
|
||||
|
||||
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
|
||||
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
|
||||
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
|
||||
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
|
||||
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
|
||||
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
|
||||
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
|
||||
|
||||
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
|
||||
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
|
||||
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.7.1" # x-release-please-version
|
||||
ARG VERSION="v3.12.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -611,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -819,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1056,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
+37
-5
@@ -63,7 +63,6 @@ from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -124,10 +123,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
@@ -215,6 +210,43 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info('Мониторинг техработ отключен настройками')
|
||||
|
||||
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
|
||||
|
||||
# Validate CONNECT_BUTTON_MODE dependencies
|
||||
if not settings.get_happ_cryptolink_redirect_template():
|
||||
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" не будет отображаться.'
|
||||
)
|
||||
elif settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
|
||||
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
|
||||
)
|
||||
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопка "Подключиться" не будет работать.'
|
||||
)
|
||||
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопки кабинета не смогут открывать разделы MiniApp. '
|
||||
'Установите MINIAPP_CUSTOM_URL.'
|
||||
)
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to load button styles cache: {e}')
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
|
||||
@@ -5,10 +5,12 @@ from fastapi import APIRouter
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
@@ -18,6 +20,7 @@ from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -86,7 +89,10 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
SECTIONS,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
|
||||
# ---- Schemas ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonSectionConfig(BaseModel):
|
||||
"""Configuration for a single button section."""
|
||||
|
||||
style: str = 'primary'
|
||||
icon_custom_emoji_id: str = ''
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
|
||||
class ButtonStylesResponse(BaseModel):
|
||||
"""Full button styles configuration (all 7 sections)."""
|
||||
|
||||
home: ButtonSectionConfig = ButtonSectionConfig()
|
||||
subscription: ButtonSectionConfig = ButtonSectionConfig()
|
||||
balance: ButtonSectionConfig = ButtonSectionConfig()
|
||||
referral: ButtonSectionConfig = ButtonSectionConfig()
|
||||
support: ButtonSectionConfig = ButtonSectionConfig()
|
||||
info: ButtonSectionConfig = ButtonSectionConfig()
|
||||
admin: ButtonSectionConfig = ButtonSectionConfig()
|
||||
|
||||
|
||||
MAX_LABEL_LENGTH = 100
|
||||
|
||||
|
||||
class ButtonSectionUpdate(BaseModel):
|
||||
"""Partial update for a single section (None = keep current)."""
|
||||
|
||||
style: str | None = None
|
||||
icon_custom_emoji_id: str | None = None
|
||||
enabled: bool | None = None
|
||||
labels: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ButtonStylesUpdate(BaseModel):
|
||||
"""Partial update — only include sections you want to change."""
|
||||
|
||||
home: ButtonSectionUpdate | None = None
|
||||
subscription: ButtonSectionUpdate | None = None
|
||||
balance: ButtonSectionUpdate | None = None
|
||||
referral: ButtonSectionUpdate | None = None
|
||||
support: ButtonSectionUpdate | None = None
|
||||
info: ButtonSectionUpdate | None = None
|
||||
admin: ButtonSectionUpdate | None = None
|
||||
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
return ButtonStylesResponse(
|
||||
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
merged[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
merged[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _build_response(merged)
|
||||
|
||||
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
# Load current state
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current: dict[str, dict] = {
|
||||
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
|
||||
}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in current and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
current[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
current[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
current[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply updates
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
changed_sections: list[str] = []
|
||||
|
||||
for section, updates in update_data.items():
|
||||
if section not in current or not isinstance(updates, dict):
|
||||
continue
|
||||
|
||||
if 'style' in updates:
|
||||
style_val = updates['style']
|
||||
if style_val not in ALLOWED_STYLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{style_val}" for section "{section}". '
|
||||
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
|
||||
)
|
||||
current[section]['style'] = style_val
|
||||
|
||||
if 'icon_custom_emoji_id' in updates:
|
||||
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
|
||||
current[section]['icon_custom_emoji_id'] = emoji_val
|
||||
|
||||
if 'enabled' in updates:
|
||||
current[section]['enabled'] = updates['enabled']
|
||||
|
||||
if 'labels' in updates:
|
||||
raw_labels = updates['labels'] or {}
|
||||
sanitized: dict[str, str] = {}
|
||||
for locale_key, label_val in raw_labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for section "{section}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
stripped = label_val.strip()
|
||||
if len(stripped) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
# Empty string = remove custom label (use default)
|
||||
if stripped:
|
||||
sanitized[locale_key] = stripped
|
||||
current[section]['labels'] = sanitized
|
||||
|
||||
changed_sections.append(section)
|
||||
|
||||
# Persist
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
|
||||
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
|
||||
|
||||
return _build_response(current)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
deactivate_active_pinned_message,
|
||||
get_active_pinned_message,
|
||||
set_active_pinned_message,
|
||||
unpin_active_pinned_message,
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
PinnedMessageListResponse,
|
||||
PinnedMessageResponse,
|
||||
PinnedMessageSettingsRequest,
|
||||
PinnedMessageUnpinResponse,
|
||||
PinnedMessageUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
|
||||
|
||||
# Broadcast cooldown: min 60 seconds between mass operations
|
||||
_BROADCAST_COOLDOWN_SECONDS = 60
|
||||
_last_broadcast_time: float = 0.0
|
||||
|
||||
|
||||
def _check_broadcast_cooldown() -> None:
|
||||
global _last_broadcast_time
|
||||
now = time.monotonic()
|
||||
elapsed = now - _last_broadcast_time
|
||||
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
|
||||
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
f'Broadcast cooldown active. Try again in {remaining} seconds.',
|
||||
)
|
||||
_last_broadcast_time = now
|
||||
|
||||
|
||||
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
|
||||
return PinnedMessageResponse(
|
||||
id=msg.id,
|
||||
content=msg.content,
|
||||
media_type=msg.media_type,
|
||||
media_file_id=msg.media_file_id,
|
||||
send_before_menu=msg.send_before_menu,
|
||||
send_on_every_start=msg.send_on_every_start,
|
||||
is_active=msg.is_active,
|
||||
created_by=msg.created_by,
|
||||
created_at=msg.created_at,
|
||||
updated_at=msg.updated_at,
|
||||
)
|
||||
|
||||
|
||||
_cached_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return _cached_bot
|
||||
|
||||
|
||||
# ============ List / Get Endpoints ============
|
||||
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
active_only: bool = Query(False),
|
||||
) -> PinnedMessageListResponse:
|
||||
"""Get list of pinned messages with pagination."""
|
||||
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
|
||||
count_query = select(func.count(PinnedMessage.id))
|
||||
|
||||
if active_only:
|
||||
query = query.where(PinnedMessage.is_active.is_(True))
|
||||
count_query = count_query.where(PinnedMessage.is_active.is_(True))
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PinnedMessageListResponse(
|
||||
items=[_serialize_pinned_message(msg) for msg in items],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
msg = await get_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Create / Update Endpoints ============
|
||||
|
||||
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Create a new pinned message.
|
||||
|
||||
Automatically deactivates previous active message.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if payload.broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
content = payload.content.strip()
|
||||
if not content and not payload.media:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
|
||||
|
||||
media_type = payload.media.type if payload.media else None
|
||||
media_file_id = payload.media.file_id if payload.media else None
|
||||
|
||||
try:
|
||||
msg = await set_active_pinned_message(
|
||||
db=db,
|
||||
content=content,
|
||||
created_by=admin.id,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
send_before_menu=payload.send_before_menu,
|
||||
send_on_every_start=payload.send_on_every_start,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if payload.broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.content is not None:
|
||||
sanitized = sanitize_html(payload.content)
|
||||
is_valid, error = validate_html_tags(sanitized)
|
||||
if not is_valid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
|
||||
msg.content = sanitized
|
||||
|
||||
if payload.media is not None:
|
||||
msg.media_type = payload.media.type
|
||||
msg.media_file_id = payload.media.file_id
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Active Message Actions (before /{message_id} POST routes) ============
|
||||
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
msg = await deactivate_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
_check_broadcast_cooldown()
|
||||
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
|
||||
|
||||
if was_active:
|
||||
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
|
||||
|
||||
return PinnedMessageUnpinResponse(
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
was_active=was_active,
|
||||
)
|
||||
|
||||
|
||||
# ============ Per-Message Actions ============
|
||||
|
||||
|
||||
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Activate a pinned message.
|
||||
|
||||
Deactivates the current active message and activates the specified one.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if msg.is_active:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
'Cannot delete active pinned message. Deactivate it first.',
|
||||
)
|
||||
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -22,12 +24,15 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
|
||||
|
||||
|
||||
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
|
||||
tariff_stats: TariffStats | None = None
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""System information for admin dashboard."""
|
||||
|
||||
bot_version: str
|
||||
python_version: str
|
||||
uptime_seconds: int
|
||||
users_total: int
|
||||
subscriptions_active: int
|
||||
|
||||
|
||||
# ============ Extended Stats Schemas ============
|
||||
|
||||
|
||||
@@ -309,6 +324,38 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
try:
|
||||
users_total_result = await db.execute(select(func.count()).select_from(User))
|
||||
users_total = users_total_result.scalar() or 0
|
||||
|
||||
subs_active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
subscriptions_active = subs_active_result.scalar() or 0
|
||||
|
||||
return SystemInfoResponse(
|
||||
bot_version=version_service.current_version,
|
||||
python_version=sys.version.split()[0],
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
users_total=users_total,
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get system info: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load system information',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
|
||||
@@ -412,21 +412,14 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
|
||||
@@ -12,20 +12,22 @@ from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
@@ -44,6 +46,7 @@ _cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
@@ -186,9 +189,14 @@ def _build_traffic_items(
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if search_lower not in (full_name or '').lower() and search_lower not in (username or '').lower():
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
@@ -223,6 +231,7 @@ def _build_traffic_items(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
@@ -322,15 +331,31 @@ async def get_traffic_usage(
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort:
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
@@ -346,6 +371,156 @@ async def get_traffic_usage(
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
@@ -386,6 +561,7 @@ async def export_traffic_csv(
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
@@ -434,12 +610,21 @@ async def export_traffic_csv(
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Devices': item.device_limit,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases: %s', e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
@@ -27,7 +27,9 @@ from app.database.crud.user import (
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -37,8 +39,10 @@ from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
DeviceInfo,
|
||||
DisableUserRequest,
|
||||
DisableUserResponse,
|
||||
FullDeleteUserRequest,
|
||||
@@ -46,6 +50,7 @@ from ..schemas.users import (
|
||||
PanelSyncStatusResponse,
|
||||
PanelUserInfo,
|
||||
PeriodPriceInfo,
|
||||
ResetDevicesResponse,
|
||||
ResetSubscriptionRequest,
|
||||
ResetSubscriptionResponse,
|
||||
ResetTrialRequest,
|
||||
@@ -55,10 +60,13 @@ from ..schemas.users import (
|
||||
SyncFromPanelResponse,
|
||||
SyncToPanelRequest,
|
||||
SyncToPanelResponse,
|
||||
TrafficPurchaseItem,
|
||||
UpdateBalanceRequest,
|
||||
UpdateBalanceResponse,
|
||||
UpdatePromoGroupRequest,
|
||||
UpdatePromoGroupResponse,
|
||||
UpdateReferralCommissionRequest,
|
||||
UpdateReferralCommissionResponse,
|
||||
UpdateRestrictionsRequest,
|
||||
UpdateRestrictionsResponse,
|
||||
UpdateSubscriptionRequest,
|
||||
@@ -68,6 +76,7 @@ from ..schemas.users import (
|
||||
UserAvailableTariffItem,
|
||||
UserAvailableTariffsResponse,
|
||||
UserDetailResponse,
|
||||
UserDevicesResponse,
|
||||
UserListItem,
|
||||
UserNodeUsageItem,
|
||||
UserNodeUsageResponse,
|
||||
@@ -157,13 +166,43 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
|
||||
|
||||
|
||||
async def _build_subscription_info_async(db: AsyncSession, subscription: Subscription) -> UserSubscriptionInfo:
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name asynchronously."""
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name and traffic purchases."""
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
return _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
|
||||
# Fetch traffic purchases
|
||||
now = datetime.utcnow()
|
||||
tp_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.order_by(TrafficPurchase.created_at.desc())
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
purchases = tp_result.scalars().all()
|
||||
|
||||
traffic_purchase_items = []
|
||||
for p in purchases:
|
||||
delta = p.expires_at - now
|
||||
days_remaining = max(0, delta.days)
|
||||
is_expired = now >= p.expires_at
|
||||
traffic_purchase_items.append(
|
||||
TrafficPurchaseItem(
|
||||
id=p.id,
|
||||
traffic_gb=p.traffic_gb,
|
||||
expires_at=p.expires_at,
|
||||
created_at=p.created_at,
|
||||
days_remaining=days_remaining,
|
||||
is_expired=is_expired,
|
||||
)
|
||||
)
|
||||
|
||||
info = _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
info.purchased_traffic_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
info.traffic_purchases = traffic_purchase_items
|
||||
return info
|
||||
|
||||
|
||||
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
|
||||
@@ -198,12 +237,16 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
@@ -213,7 +256,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
async with service.get_api_client() as api:
|
||||
panel_uuid = user.remnawave_uuid
|
||||
|
||||
# Try to find existing user
|
||||
# Try to find existing user by UUID first
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -221,6 +272,14 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (for OAuth users without telegram_id)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {
|
||||
@@ -256,6 +315,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': subscription.connected_squads or [],
|
||||
}
|
||||
@@ -612,15 +672,30 @@ async def get_user_panel_info(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured or not user.telegram_id:
|
||||
if not service.is_configured:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if not panel_users:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
panel_user = None
|
||||
|
||||
panel_user = panel_users[0]
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
|
||||
last_node_name = None
|
||||
@@ -1060,6 +1135,113 @@ async def update_user_subscription(
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'add_traffic':
|
||||
if not request.traffic_gb:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_gb parameter is required for add_traffic action',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} added {request.traffic_gb} GB traffic for user {user_id}')
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Added {request.traffic_gb} GB traffic (30 days)',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'remove_traffic':
|
||||
if not request.traffic_purchase_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_purchase_id parameter is required for remove_traffic action',
|
||||
)
|
||||
|
||||
# Find the traffic purchase
|
||||
tp_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.id == request.traffic_purchase_id,
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
traffic_purchase = tp_result.scalar_one_or_none()
|
||||
if not traffic_purchase:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Traffic purchase not found',
|
||||
)
|
||||
|
||||
removed_gb = traffic_purchase.traffic_gb
|
||||
|
||||
# Decrement counters
|
||||
subscription.traffic_limit_gb = max(0, subscription.traffic_limit_gb - removed_gb)
|
||||
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
subscription.purchased_traffic_gb = max(0, current_purchased - removed_gb)
|
||||
|
||||
# Delete the purchase record
|
||||
await db.delete(traffic_purchase)
|
||||
|
||||
# Recalculate traffic_reset_at from remaining active purchases
|
||||
now = datetime.utcnow()
|
||||
remaining_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
TrafficPurchase.expires_at > now,
|
||||
TrafficPurchase.id != request.traffic_purchase_id,
|
||||
)
|
||||
remaining_result = await db.execute(remaining_query)
|
||||
remaining_purchases = remaining_result.scalars().all()
|
||||
|
||||
if remaining_purchases:
|
||||
subscription.traffic_reset_at = min(p.expires_at for p in remaining_purchases)
|
||||
else:
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} removed traffic purchase {request.traffic_purchase_id} ({removed_gb} GB) for user {user_id}'
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Removed {removed_gb} GB traffic package',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'set_device_limit':
|
||||
if request.device_limit is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='device_limit parameter is required for set_device_limit action',
|
||||
)
|
||||
|
||||
subscription.device_limit = request.device_limit
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} set device limit to {request.device_limit} for user {user_id}')
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Device limit set to {request.device_limit}',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown action: {request.action}',
|
||||
@@ -1140,6 +1322,11 @@ async def get_user_available_tariffs(
|
||||
price_per_day_kopeks=tariff.price_per_day_kopeks,
|
||||
min_days=tariff.min_days,
|
||||
max_days=tariff.max_days,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
traffic_topup_enabled=tariff.traffic_topup_enabled,
|
||||
traffic_topup_packages=tariff.traffic_topup_packages or {},
|
||||
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
|
||||
is_available=is_available,
|
||||
requires_promo_group=requires_promo_group,
|
||||
)
|
||||
@@ -1326,6 +1513,173 @@ async def update_user_promo_group(
|
||||
)
|
||||
|
||||
|
||||
# === Referral Commission ===
|
||||
|
||||
|
||||
@router.post('/{user_id}/referral-commission', response_model=UpdateReferralCommissionResponse)
|
||||
async def update_user_referral_commission(
|
||||
user_id: int,
|
||||
request: UpdateReferralCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} changed referral commission for user {user_id}: {old_commission} -> {request.commission_percent}'
|
||||
)
|
||||
|
||||
return UpdateReferralCommissionResponse(
|
||||
success=True,
|
||||
old_commission_percent=old_commission,
|
||||
new_commission_percent=request.commission_percent,
|
||||
message='Referral commission updated',
|
||||
)
|
||||
|
||||
|
||||
# === Devices ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
|
||||
async def get_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user devices from Remnawave panel."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
return UserDevicesResponse()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return UserDevicesResponse()
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(user.remnawave_uuid)
|
||||
|
||||
devices = []
|
||||
for d in response.get('devices', []):
|
||||
hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if not hwid:
|
||||
continue
|
||||
devices.append(
|
||||
DeviceInfo(
|
||||
hwid=hwid,
|
||||
platform=d.get('platform') or d.get('platformType') or '',
|
||||
device_model=d.get('deviceModel') or d.get('model') or d.get('name') or '',
|
||||
created_at=d.get('updatedAt') or d.get('lastSeen') or d.get('createdAt'),
|
||||
)
|
||||
)
|
||||
|
||||
device_limit = 0
|
||||
if user.subscription:
|
||||
device_limit = user.subscription.device_limit or 0
|
||||
|
||||
return UserDevicesResponse(
|
||||
devices=devices,
|
||||
total=response.get('total', len(devices)),
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching devices for user {user_id}: {e}')
|
||||
return UserDevicesResponse()
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices/{hwid}', response_model=DeleteDeviceResponse)
|
||||
async def delete_user_device(
|
||||
user_id: int,
|
||||
hwid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a single device for user."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
success = await api.remove_device(user.remnawave_uuid, hwid)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} deleted device {hwid} for user {user_id}')
|
||||
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
|
||||
return DeleteDeviceResponse(success=False, message='Failed to delete device')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting device {hwid} for user {user_id}: {e}')
|
||||
return DeleteDeviceResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
async def reset_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all devices for user."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
devices_info = await api.get_user_devices(user.remnawave_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
total = len(devices)
|
||||
|
||||
if total == 0:
|
||||
return ResetDevicesResponse(success=True, message='No devices to reset', deleted_count=0)
|
||||
|
||||
deleted = 0
|
||||
for d in devices:
|
||||
device_hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if device_hwid:
|
||||
try:
|
||||
await api.remove_device(user.remnawave_uuid, device_hwid)
|
||||
deleted += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f'Admin {admin.id} reset devices for user {user_id}: {deleted}/{total}')
|
||||
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error resetting devices for user {user_id}: {e}')
|
||||
return ResetDevicesResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
# === Delete User ===
|
||||
|
||||
|
||||
@@ -1453,6 +1807,8 @@ async def reset_user_trial(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1523,6 +1879,8 @@ async def reset_user_subscription(
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1754,11 +2112,27 @@ async def get_user_sync_status(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured and user.telegram_id:
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
panel_user = None
|
||||
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if panel_user:
|
||||
panel_found = True
|
||||
panel_status = panel_user.status.value if panel_user.status else None
|
||||
panel_expire_at = panel_user.expire_at
|
||||
@@ -1884,27 +2258,30 @@ async def sync_user_from_panel(
|
||||
errors = []
|
||||
panel_info = None
|
||||
|
||||
# Email-only users cannot be synced from panel by telegram_id
|
||||
if not user.telegram_id:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='Cannot sync email-only user',
|
||||
errors=["Email-only users don't have telegram_id for panel lookup"],
|
||||
)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Find user in panel
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
# Find user in panel: UUID → telegram_id → email
|
||||
panel_user = None
|
||||
|
||||
if not panel_users:
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='User not found in panel',
|
||||
errors=['No user with this telegram_id found in Remnawave panel'],
|
||||
errors=['No user found in Remnawave panel by UUID, telegram_id, or email'],
|
||||
)
|
||||
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Build panel info
|
||||
active_squads = []
|
||||
if hasattr(panel_user, 'active_internal_squads') and panel_user.active_internal_squads:
|
||||
@@ -2113,19 +2490,31 @@ async def sync_user_to_panel(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
|
||||
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Try to find existing user in panel
|
||||
# Validate existing UUID
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning(f'User {user.id} has stale remnawave_uuid {panel_uuid}, clearing')
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -2133,6 +2522,14 @@ async def sync_user_to_panel(
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {'uuid': panel_uuid}
|
||||
@@ -2178,6 +2575,7 @@ async def sync_user_to_panel(
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': sub.connected_squads or [],
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -969,14 +970,13 @@ async def request_email_change(
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
For verified emails: sends a 6-digit verification code to the new email.
|
||||
For unverified emails: replaces the email directly and sends verification to the new address.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
detail='No email address to change',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
@@ -1000,6 +1000,68 @@ async def request_email_change(
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Unverified email: replace directly and send verification to new address
|
||||
if not user.email_verified:
|
||||
old_email = user.email
|
||||
user.email = request.new_email.lower()
|
||||
user.email_verified = False
|
||||
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.new_email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
|
||||
|
||||
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Email replaced, verification sent to new address',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=0,
|
||||
)
|
||||
|
||||
# Verified email: send code to new address for confirmation
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
@@ -23,6 +23,7 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -381,24 +382,49 @@ async def create_topup(
|
||||
)
|
||||
|
||||
elif request.payment_method == 'cryptobot':
|
||||
cryptobot_service = CryptoBotService()
|
||||
# Convert RUB to USDT (approximate)
|
||||
usdt_amount = amount_rubles / 100 # Approximate rate
|
||||
result = await cryptobot_service.create_invoice(
|
||||
amount=usdt_amount,
|
||||
asset='USDT',
|
||||
description=f'Balance top-up {amount_rubles:.2f} RUB',
|
||||
if not settings.is_cryptobot_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CryptoBot payment method is unavailable',
|
||||
)
|
||||
|
||||
try:
|
||||
rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception:
|
||||
rate = 0.0
|
||||
if not rate or rate <= 0:
|
||||
rate = 95.0
|
||||
|
||||
try:
|
||||
amount_usd = float(
|
||||
(Decimal(request.amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize(
|
||||
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to convert amount to USD',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
if result:
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get('web_app_invoice_url')
|
||||
result.get('bot_invoice_url')
|
||||
or result.get('mini_app_invoice_url')
|
||||
or result.get('bot_invoice_url')
|
||||
or result.get('pay_url')
|
||||
or result.get('web_app_invoice_url')
|
||||
)
|
||||
payment_id = str(result.get('invoice_id'))
|
||||
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +236,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +267,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -598,6 +598,8 @@ async def get_traffic_packages(
|
||||
result = []
|
||||
|
||||
for gb, price in packages.items():
|
||||
if price <= 0:
|
||||
continue
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=gb,
|
||||
@@ -619,12 +621,14 @@ async def get_traffic_packages(
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
if not pkg.get('enabled', True):
|
||||
continue
|
||||
if pkg['price'] <= 0:
|
||||
continue
|
||||
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
@@ -705,6 +709,11 @@ async def purchase_traffic(
|
||||
detail=f'Traffic package {request.gb}GB is not available',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB has no price configured',
|
||||
)
|
||||
|
||||
else:
|
||||
# Classic режим
|
||||
@@ -724,7 +733,7 @@ async def purchase_traffic(
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -732,6 +741,11 @@ async def purchase_traffic(
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic package has no price configured',
|
||||
)
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
@@ -1662,7 +1676,7 @@ async def submit_purchase(
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=None,
|
||||
period_days=selection.period_days,
|
||||
period_days=selection.period.days,
|
||||
was_trial_conversion=result.get('was_trial_conversion', False),
|
||||
amount_kopeks=pricing.final_total,
|
||||
purchase_type='renewal' if not is_new_subscription else None,
|
||||
@@ -2407,7 +2421,7 @@ async def save_traffic_cart(
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -3216,7 +3230,10 @@ async def update_countries(
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления счётчика серверов: {e}')
|
||||
|
||||
# Update connected squads
|
||||
user.subscription.connected_squads = selected_countries
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic schemas for cabinet pinned messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PinnedMessageMedia(BaseModel):
|
||||
type: str = Field(pattern=r'^(photo|video)$')
|
||||
file_id: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class PinnedMessageCreateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=4000)
|
||||
media: PinnedMessageMedia | None = None
|
||||
send_before_menu: bool = True
|
||||
send_on_every_start: bool = True
|
||||
broadcast: bool = False
|
||||
|
||||
|
||||
class PinnedMessageUpdateRequest(BaseModel):
|
||||
content: str | None = Field(None, max_length=4000)
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
media: PinnedMessageMedia | None = None
|
||||
|
||||
|
||||
class PinnedMessageSettingsRequest(BaseModel):
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
|
||||
|
||||
class PinnedMessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str | None
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
send_before_menu: bool
|
||||
send_on_every_start: bool
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PinnedMessageBroadcastResponse(BaseModel):
|
||||
message: PinnedMessageResponse
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
class PinnedMessageUnpinResponse(BaseModel):
|
||||
unpinned_count: int
|
||||
failed_count: int
|
||||
was_active: bool
|
||||
|
||||
|
||||
class PinnedMessageListResponse(BaseModel):
|
||||
items: list[PinnedMessageResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -13,6 +13,7 @@ class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
@@ -33,6 +34,18 @@ class TrafficUsageResponse(BaseModel):
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
data: dict[int, UserTrafficEnrichment]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
|
||||
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
|
||||
# === User Subscription Info ===
|
||||
|
||||
|
||||
class TrafficPurchaseItem(BaseModel):
|
||||
"""Individual traffic purchase record."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class UserSubscriptionInfo(BaseModel):
|
||||
"""User subscription information."""
|
||||
|
||||
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
@@ -285,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
# For toggle_autopay
|
||||
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
||||
|
||||
# For add_traffic action
|
||||
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
||||
|
||||
# For remove_traffic action
|
||||
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
||||
|
||||
# For create new subscription
|
||||
is_trial: bool | None = Field(None, description='Is trial subscription')
|
||||
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
||||
@@ -348,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateReferralCommissionRequest(BaseModel):
|
||||
"""Request to update user referral commission percent."""
|
||||
|
||||
commission_percent: int | None = Field(
|
||||
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
||||
)
|
||||
|
||||
|
||||
class UpdateReferralCommissionResponse(BaseModel):
|
||||
"""Response after referral commission update."""
|
||||
|
||||
success: bool
|
||||
old_commission_percent: int | None = None
|
||||
new_commission_percent: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
hwid: str
|
||||
platform: str = ''
|
||||
device_model: str = ''
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class UserDevicesResponse(BaseModel):
|
||||
"""User devices from panel."""
|
||||
|
||||
devices: list[DeviceInfo] = []
|
||||
total: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class DeleteDeviceResponse(BaseModel):
|
||||
"""Response after device deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_hwid: str | None = None
|
||||
|
||||
|
||||
class ResetDevicesResponse(BaseModel):
|
||||
"""Response after resetting all devices."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int = 0
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request to delete user."""
|
||||
|
||||
@@ -441,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
|
||||
@@ -119,7 +119,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +174,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +246,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +301,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -352,7 +372,7 @@ class EmailService:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
@@ -401,6 +421,15 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
|
||||
+52
-98
@@ -105,6 +105,25 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00'
|
||||
CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave
|
||||
|
||||
# RemnaWave incoming webhooks (real-time event delivery from backend)
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
|
||||
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
|
||||
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_STATUS: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED: bool = True
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_DELETED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED: bool = True
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
@@ -162,11 +181,6 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
|
||||
|
||||
@@ -181,7 +195,7 @@ class Settings(BaseSettings):
|
||||
# Режим продаж подписок:
|
||||
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE: str = 'classic'
|
||||
SALES_MODE: str = 'tariffs'
|
||||
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
@@ -339,12 +353,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -401,6 +409,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None
|
||||
MULENPAY_WEBSITE_URL: str | None = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_DISPLAY_NAME: str = 'PAL24'
|
||||
@@ -409,7 +418,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -508,8 +516,10 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
MAIN_MENU_MODE: str = 'default'
|
||||
CONNECT_BUTTON_MODE: str = 'guide'
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||
MINIAPP_CUSTOM_URL: str = ''
|
||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
||||
MINIAPP_PURCHASE_URL: str = ''
|
||||
@@ -531,7 +541,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -742,15 +752,16 @@ class Settings(BaseSettings):
|
||||
'default': 'default',
|
||||
'full': 'default',
|
||||
'standard': 'default',
|
||||
'text': 'text',
|
||||
'text_only': 'text',
|
||||
'textual': 'text',
|
||||
'minimal': 'text',
|
||||
'cabinet': 'cabinet',
|
||||
'text': 'cabinet',
|
||||
'text_only': 'cabinet',
|
||||
'textual': 'cabinet',
|
||||
'minimal': 'cabinet',
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {'default', 'text'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
|
||||
if mode not in {'default', 'cabinet'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@@ -1051,13 +1062,14 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
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('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
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_{identifier}'
|
||||
|
||||
return sanitized_username[:64]
|
||||
return sanitized_username[:36]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1095,6 +1107,13 @@ class Settings(BaseSettings):
|
||||
def get_remnawave_auto_sync_times(self) -> list[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def is_remnawave_webhook_enabled(self) -> bool:
|
||||
return (
|
||||
self.REMNAWAVE_WEBHOOK_ENABLED
|
||||
and bool(self.REMNAWAVE_WEBHOOK_SECRET)
|
||||
and len(self.REMNAWAVE_WEBHOOK_SECRET or '') >= 32
|
||||
)
|
||||
|
||||
def get_traffic_monitored_nodes(self) -> list[str]:
|
||||
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
|
||||
if not self.TRAFFIC_MONITORED_NODES:
|
||||
@@ -1182,22 +1201,12 @@ class Settings(BaseSettings):
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {'1', 'true', 'yes', 'on'}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -1359,8 +1368,12 @@ class Settings(BaseSettings):
|
||||
def get_main_menu_mode(self) -> str:
|
||||
return getattr(self, 'MAIN_MENU_MODE', 'default')
|
||||
|
||||
def is_cabinet_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'cabinet'
|
||||
|
||||
def is_text_main_menu_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'text'
|
||||
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
|
||||
return self.is_cabinet_mode()
|
||||
|
||||
def get_main_menu_miniapp_url(self) -> str | None:
|
||||
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
|
||||
@@ -1532,9 +1545,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1545,68 +1555,12 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_sales_mode(self) -> str:
|
||||
"""Возвращает текущий режим продаж."""
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
|
||||
|
||||
def get_trial_tariff_id(self) -> int:
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return max(0, self.TRIAL_TARIFF_ID)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
@@ -2448,7 +2402,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
|
||||
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
|
||||
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
@@ -362,6 +363,25 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
# Clean up stale UUIDs from tariff allowed_squads
|
||||
cleaned_tariffs = 0
|
||||
tariffs_result = await db.execute(select(Tariff))
|
||||
for tariff in tariffs_result.scalars().all():
|
||||
current = list(tariff.allowed_squads or [])
|
||||
if not current:
|
||||
continue
|
||||
filtered = [u for u in current if u not in removed_uuids]
|
||||
if len(filtered) != len(current):
|
||||
tariff.allowed_squads = filtered
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
cleaned_tariffs += 1
|
||||
logger.info(
|
||||
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
[u for u in current if u in removed_uuids],
|
||||
)
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
@@ -371,6 +391,12 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
|
||||
if cleaned_tariffs:
|
||||
logger.info(
|
||||
'🧹 Обновлены тарифы после удаления серверов: %s',
|
||||
cleaned_tariffs,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
|
||||
@@ -733,40 +759,90 @@ async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int
|
||||
|
||||
async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
for server_id in sorted(server_squad_ids):
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
for server_id in sorted(server_squad_ids):
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
async def update_server_user_counts(
|
||||
db: AsyncSession,
|
||||
add_ids: list[int] | None = None,
|
||||
remove_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Increment and decrement server user counters in a single sorted pass.
|
||||
|
||||
Prevents deadlocks by acquiring row locks in consistent ID order
|
||||
across both add and remove operations within one transaction.
|
||||
"""
|
||||
try:
|
||||
add_set = set(add_ids) if add_ids else set()
|
||||
remove_set = set(remove_ids) if remove_ids else set()
|
||||
|
||||
if not add_set and not remove_set:
|
||||
return
|
||||
|
||||
# IDs in both sets cancel out — skip them
|
||||
overlap = add_set & remove_set
|
||||
if overlap:
|
||||
add_set -= overlap
|
||||
remove_set -= overlap
|
||||
|
||||
all_ids = sorted(add_set | remove_set)
|
||||
if not all_ids:
|
||||
return
|
||||
|
||||
for server_id in all_ids:
|
||||
if server_id in add_set:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
if server_id in remove_set:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
if add_set:
|
||||
logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set))
|
||||
if remove_set:
|
||||
logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счетчиков серверов: %s', e)
|
||||
raise
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
@@ -23,6 +24,16 @@ from app.utils.timezone import format_local_datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WEBHOOK_GUARD_SECONDS = 60
|
||||
|
||||
|
||||
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
"""Return True if subscription was updated by webhook within guard window."""
|
||||
if not subscription.last_webhook_update_at:
|
||||
return False
|
||||
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
result = await db.execute(
|
||||
@@ -95,6 +106,25 @@ async def create_trial_subscription(
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||||
existing.status = SubscriptionStatus.ACTIVE.value
|
||||
existing.start_date = datetime.utcnow()
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
|
||||
existing.id,
|
||||
user_id,
|
||||
)
|
||||
return existing
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -265,23 +295,22 @@ async def replace_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,
|
||||
update_server_user_counts,
|
||||
)
|
||||
|
||||
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))
|
||||
remove_ids = await get_server_ids_by_uuids(db, list(squads_to_remove)) if squads_to_remove else []
|
||||
add_ids = await get_server_ids_by_uuids(db, list(squads_to_add)) if squads_to_add else []
|
||||
|
||||
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))
|
||||
if remove_ids or add_ids:
|
||||
await update_server_user_counts(
|
||||
db,
|
||||
add_ids=add_ids or None,
|
||||
remove_ids=remove_ids or None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s',
|
||||
@@ -328,9 +357,8 @@ async def extend_subscription(
|
||||
)
|
||||
|
||||
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
|
||||
is_tariff_change = (
|
||||
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
|
||||
)
|
||||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
|
||||
|
||||
if is_tariff_change:
|
||||
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}')
|
||||
@@ -411,17 +439,28 @@ async def extend_subscription(
|
||||
|
||||
if traffic_limit_gb is not None:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# Сбрасываем все докупки трафика при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
if is_tariff_change:
|
||||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
|
||||
)
|
||||
else:
|
||||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||||
logger.info(
|
||||
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
|
||||
)
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
@@ -586,6 +625,9 @@ async def decrement_subscription_server_counts(
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
# Save ID before any DB operations that might invalidate the ORM object
|
||||
sub_id = subscription.id
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
@@ -594,12 +636,12 @@ async def decrement_subscription_server_counts(
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
ids_from_links = await get_subscription_server_ids(db, sub_id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -613,7 +655,7 @@ async def decrement_subscription_server_counts(
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -623,12 +665,20 @@ async def decrement_subscription_server_counts(
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
|
||||
async with db.begin_nested():
|
||||
await remove_user_from_servers(db, list(server_ids))
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
|
||||
sub_id,
|
||||
list(server_ids),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -1932,13 +1982,14 @@ async def resume_daily_subscription(
|
||||
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
subscription.last_daily_charge_at = datetime.utcnow()
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
|
||||
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE')
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -37,6 +37,7 @@ async def create_transaction(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
external_id: str | None = None,
|
||||
is_completed: bool = True,
|
||||
created_at: datetime | None = None,
|
||||
) -> Transaction:
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
@@ -47,6 +48,7 @@ async def create_transaction(
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None,
|
||||
**({'created_at': created_at} if created_at else {}),
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
|
||||
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
|
||||
normalized = (language or '').strip().lower()
|
||||
if '-' in normalized:
|
||||
normalized = normalized.split('-', 1)[0]
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
"""
|
||||
Возвращает базовый SELECT для статистики трат пользователей.
|
||||
@@ -232,6 +239,7 @@ async def create_user_no_commit(
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
@@ -243,7 +251,7 @@ async def create_user_no_commit(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -277,6 +285,7 @@ async def create_user(
|
||||
) -> User:
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
attempts = 3
|
||||
|
||||
@@ -291,7 +300,7 @@ async def create_user(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
for field, value in kwargs.items():
|
||||
if field in ('first_name', 'last_name'):
|
||||
value = sanitize_telegram_name(value)
|
||||
if field == 'language':
|
||||
value = _normalize_language_code(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -492,6 +503,10 @@ async def subtract_user_balance(
|
||||
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
|
||||
logger.info(f' 📝 Описание: {description}')
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
log_context: dict[str, object] | None = None
|
||||
if consume_promo_offer:
|
||||
try:
|
||||
@@ -543,14 +558,13 @@ async def subtract_user_balance(
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -559,6 +573,10 @@ async def subtract_user_balance(
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
@@ -1060,6 +1078,7 @@ async def create_user_by_email(
|
||||
Created User object
|
||||
"""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
user = User(
|
||||
@@ -1071,7 +1090,7 @@ async def create_user_by_email(
|
||||
username=None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -1283,6 +1302,7 @@ async def create_user_by_oauth(
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
@@ -1297,7 +1317,7 @@ async def create_user_by_oauth(
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
|
||||
payment_method_type: str | None = None,
|
||||
yookassa_created_at: datetime | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> YooKassaPayment:
|
||||
) -> YooKassaPayment | None:
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
@@ -40,7 +41,17 @@ async def create_yookassa_payment(
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
|
||||
yookassa_payment_id,
|
||||
user_id,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
|
||||
+30
-15
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
@@ -1137,6 +1137,8 @@ class Subscription(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(DateTime, nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
@@ -1151,23 +1153,35 @@ class Subscription(Base):
|
||||
user = relationship('User', back_populates='subscription')
|
||||
tariff = relationship('Tariff', back_populates='subscriptions')
|
||||
discount_offers = relationship('DiscountOffer', back_populates='subscription')
|
||||
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
|
||||
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
|
||||
temporary_accesses = relationship(
|
||||
'SubscriptionTemporaryAccess', back_populates='subscription', passive_deletes=True
|
||||
)
|
||||
traffic_purchases = relationship(
|
||||
'TrafficPurchase', back_populates='subscription', passive_deletes=True, cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
return self.end_date is not None and self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
@@ -1180,12 +1194,12 @@ class Subscription(Base):
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'active'
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'trial'
|
||||
|
||||
@@ -1228,6 +1242,8 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
if self.end_date is None:
|
||||
return 0
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
@@ -1253,11 +1269,10 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
if not self.traffic_limit_gb:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
used = self.traffic_used_gb or 0.0
|
||||
return min((used / self.traffic_limit_gb) * 100, 100.0)
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
if self.end_date > datetime.utcnow():
|
||||
@@ -1768,7 +1783,7 @@ class SentNotification(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship('User', backref='sent_notifications')
|
||||
subscription = relationship('Subscription', backref='sent_notifications')
|
||||
subscription = relationship('Subscription', backref=backref('sent_notifications', passive_deletes=True))
|
||||
|
||||
|
||||
class SubscriptionEvent(Base):
|
||||
@@ -2058,7 +2073,7 @@ class SubscriptionServer(Base):
|
||||
|
||||
paid_price_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription = relationship('Subscription', backref='subscription_servers')
|
||||
subscription = relationship('Subscription', backref=backref('subscription_servers', passive_deletes=True))
|
||||
server_squad = relationship('ServerSquad', backref='subscription_servers')
|
||||
|
||||
|
||||
@@ -2398,7 +2413,7 @@ class ButtonClickLog(Base):
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_type = Column(String(20), nullable=True, index=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
@@ -3613,6 +3613,33 @@ async def add_subscription_crypto_link_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_last_webhook_update_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'last_webhook_update_at')
|
||||
if column_exists:
|
||||
logger.info('ℹ️ Колонка last_webhook_update_at уже существует')
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at TIMESTAMP'))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
|
||||
else:
|
||||
logger.error(f'Неподдерживаемый тип БД для добавления last_webhook_update_at: {db_type}')
|
||||
return False
|
||||
|
||||
logger.info('✅ Добавлена колонка last_webhook_update_at в таблицу subscriptions')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка добавления колонки last_webhook_update_at: {e}')
|
||||
return False
|
||||
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -7104,6 +7131,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_WEBHOOK_UPDATE_AT ===')
|
||||
webhook_column_ready = await add_subscription_last_webhook_update_column()
|
||||
if webhook_column_ready:
|
||||
logger.info('✅ Колонка last_webhook_update_at готова')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с колонкой last_webhook_update_at')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
|
||||
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
|
||||
|
||||
Vendored
-166
@@ -1,166 +0,0 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24APIError, Pal24Service
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode('utf-8')
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug('Pal24 webhook body не удалось распарсить как JSON')
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST'])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error('Pal24 webhook получен, но сервис не настроен')
|
||||
return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503
|
||||
|
||||
logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning('Пустой Pal24 webhook')
|
||||
return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400
|
||||
|
||||
try:
|
||||
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
|
||||
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({'status': 'error', 'reason': 'timeout'}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': 'internal_error'}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
return jsonify({'status': 'error', 'reason': 'not_processed'}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET'])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'enabled': settings.is_pal24_enabled(),
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route('/pal24/health', methods=['GET'])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'path': settings.PAL24_WEBHOOK_PATH,
|
||||
}
|
||||
), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Any | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning('Pal24 webhook server уже запущен')
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host='0.0.0.0',
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
'Pal24 webhook сервер запущен на %s:%s%s',
|
||||
'0.0.0.0',
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info('Останавливаем Pal24 webhook сервер')
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
Vendored
+28
-3
@@ -400,8 +400,9 @@ class RemnaWaveAPI:
|
||||
|
||||
if response.status >= 400:
|
||||
error_message = response_data.get('message', f'HTTP {response.status}')
|
||||
logger.error(f'API Error {response.status}: {error_message}')
|
||||
logger.error(f'Response: {response_text[:500]}')
|
||||
log = logger.warning if response.status in (502, 503, 504) else logger.error
|
||||
log('API Error %s: %s', response.status, error_message)
|
||||
log('Response: %s', response_text[:500])
|
||||
raise RemnaWaveAPIError(error_message, response.status, response_data)
|
||||
|
||||
return response_data
|
||||
@@ -999,6 +1000,30 @@ class RemnaWaveAPI:
|
||||
uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config')
|
||||
)
|
||||
|
||||
async def get_all_hwid_devices(self) -> dict[str, Any]:
|
||||
"""GET /api/hwid/devices — all devices for all users (paginated, max 1000/page)."""
|
||||
all_devices: list[dict[str, Any]] = []
|
||||
start = 0
|
||||
page_size = 1000
|
||||
|
||||
while True:
|
||||
response = await self._make_request('GET', '/api/hwid/devices', params={'start': start, 'size': page_size})
|
||||
data = response.get('response', {'devices': [], 'total': 0})
|
||||
devices = data.get('devices', [])
|
||||
total = data.get('total', 0)
|
||||
all_devices.extend(devices)
|
||||
|
||||
if len(all_devices) >= total or not devices:
|
||||
break
|
||||
start += len(devices)
|
||||
|
||||
return {'devices': all_devices, 'total': len(all_devices)}
|
||||
|
||||
async def get_all_panel_subscriptions(self) -> list[dict[str, Any]]:
|
||||
"""GET /api/subscriptions — all panel subscriptions."""
|
||||
response = await self._make_request('GET', '/api/subscriptions')
|
||||
return response.get('response') or []
|
||||
|
||||
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}')
|
||||
@@ -1260,5 +1285,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
|
||||
await api.get_system_stats()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'API connection test failed: {e}')
|
||||
logger.warning('API connection test failed: %s', e)
|
||||
return False
|
||||
|
||||
@@ -45,7 +45,7 @@ from app.services.pinned_message_service import (
|
||||
)
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -76,12 +76,14 @@ async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
|
||||
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
|
||||
CABINET_MINIAPP_BUTTON_KEYS = {
|
||||
'balance',
|
||||
'referrals',
|
||||
'promocode',
|
||||
'connect',
|
||||
'subscription',
|
||||
'support',
|
||||
'home',
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +108,12 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = button_config_map[button_key]
|
||||
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
|
||||
if settings.is_cabinet_mode() and button_key in CABINET_MINIAPP_BUTTON_KEYS:
|
||||
row_buttons.append(
|
||||
build_miniapp_or_callback_button(
|
||||
text=button_config['text'],
|
||||
callback_data=button_config['callback'],
|
||||
cabinet_path=BUTTON_KEY_TO_CABINET_PATH.get(button_key, ''),
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -41,17 +41,13 @@ def _build_notification_settings_view(language: str):
|
||||
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
|
||||
third_days = NotificationSettingsService.get_third_wave_trigger_days()
|
||||
|
||||
trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True))
|
||||
trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True))
|
||||
trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True))
|
||||
trial_channel_status = _format_toggle(config.get('trial_channel_unsubscribed', {}).get('enabled', True))
|
||||
expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True))
|
||||
second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True))
|
||||
third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True))
|
||||
|
||||
summary_text = (
|
||||
'🔔 <b>Уведомления пользователям</b>\n\n'
|
||||
f'• 1 час после триала: {trial_1h_status}\n'
|
||||
f'• 24 часа после триала: {trial_24h_status}\n'
|
||||
f'• Отписка от канала: {trial_channel_status}\n'
|
||||
f'• 1 день после истечения: {expired_1d_status}\n'
|
||||
f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n'
|
||||
@@ -62,26 +58,6 @@ def _build_notification_settings_view(language: str):
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{trial_channel_status} • Отписка от канала',
|
||||
@@ -170,76 +146,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
|
||||
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
|
||||
|
||||
if notification_type == 'trial_inactive_1h':
|
||||
template = texts.get(
|
||||
'TRIAL_INACTIVE_1H',
|
||||
(
|
||||
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
|
||||
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='subscription_connect',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
|
||||
callback_data='menu_support',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == 'trial_inactive_24h':
|
||||
template = texts.get(
|
||||
'TRIAL_INACTIVE_24H',
|
||||
(
|
||||
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
|
||||
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
|
||||
'\n\nНажмите кнопку ниже, чтобы подключиться.'
|
||||
),
|
||||
)
|
||||
message = template.format(
|
||||
price=price_30_days,
|
||||
end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='subscription_connect',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
|
||||
callback_data='menu_support',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
elif notification_type == 'trial_channel_unsubscribed':
|
||||
if notification_type == 'trial_channel_unsubscribed':
|
||||
template = texts.get(
|
||||
'TRIAL_CHANNEL_UNSUBSCRIBED',
|
||||
(
|
||||
@@ -535,48 +442,6 @@ async def admin_notify_settings(callback: CallbackQuery):
|
||||
await callback.answer('❌ Не удалось загрузить настройки', show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h')
|
||||
@admin_required
|
||||
async def toggle_trial_1h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
|
||||
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h')
|
||||
@admin_required
|
||||
async def preview_trial_1h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h')
|
||||
await callback.answer('✅ Пример отправлен')
|
||||
except Exception as exc:
|
||||
logger.error('Failed to send trial 1h preview: %s', exc)
|
||||
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h')
|
||||
@admin_required
|
||||
async def toggle_trial_24h_notification(callback: CallbackQuery):
|
||||
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
|
||||
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
|
||||
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
|
||||
await _render_notification_settings(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h')
|
||||
@admin_required
|
||||
async def preview_trial_24h_notification(callback: CallbackQuery):
|
||||
try:
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h')
|
||||
await callback.answer('✅ Пример отправлен')
|
||||
except Exception as exc:
|
||||
logger.error('Failed to send trial 24h preview: %s', exc)
|
||||
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel')
|
||||
@admin_required
|
||||
async def toggle_trial_channel_notification(callback: CallbackQuery):
|
||||
@@ -668,8 +533,6 @@ async def preview_all_notifications(callback: CallbackQuery):
|
||||
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
chat_id = callback.from_user.id
|
||||
for notification_type in [
|
||||
'trial_inactive_1h',
|
||||
'trial_inactive_24h',
|
||||
'trial_channel_unsubscribed',
|
||||
'expired_1d',
|
||||
'expired_2d',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -11,6 +12,14 @@ from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.validators import get_html_help_text, validate_html_tags
|
||||
|
||||
|
||||
def _safe_preview(html_text: str, limit: int = 500) -> str:
|
||||
"""Создаёт превью текста, безопасно обрезая HTML-теги."""
|
||||
plain = re.sub(r'<[^>]+>', '', html_text)
|
||||
if len(plain) <= limit:
|
||||
return plain
|
||||
return plain[:limit] + '...'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -79,7 +88,7 @@ async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state:
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
preview = _safe_preview(current_rules, 500)
|
||||
|
||||
text = (
|
||||
'✏️ <b>Редактирование правил</b>\n\n'
|
||||
@@ -139,7 +148,7 @@ async def process_rules_edit(message: types.Message, db_user: User, state: FSMCo
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
'📋 <b>Предварительный просмотр новых правил:</b>\n\n'
|
||||
f'{new_rules[:500]}...\n\n'
|
||||
f'{_safe_preview(new_rules, 500)}\n\n'
|
||||
f'⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n'
|
||||
f'Текст правил: {len(new_rules)} символов\n'
|
||||
f'Сохранить изменения?'
|
||||
|
||||
@@ -69,6 +69,10 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_updates_menu')
|
||||
await callback.answer()
|
||||
return
|
||||
logger.error(f'Ошибка показа меню обновлений: {e}')
|
||||
await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True)
|
||||
|
||||
@@ -118,6 +122,9 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS
|
||||
await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в check_updates')
|
||||
return
|
||||
logger.error(f'Ошибка проверки обновлений: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n'
|
||||
@@ -142,7 +149,6 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
version_info['repo_url']
|
||||
|
||||
current_info = '📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n'
|
||||
|
||||
@@ -198,6 +204,9 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('📝 Сообщение не изменено в show_version_info')
|
||||
return
|
||||
logger.error(f'Ошибка получения информации о версиях: {e}')
|
||||
await callback.message.edit_text(
|
||||
f'❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n'
|
||||
|
||||
+23
-75
@@ -901,16 +901,6 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
],
|
||||
]
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
modem_status = '✅' if getattr(subscription, 'modem_enabled', False) else '❌'
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'📡 Модем ({modem_status})', callback_data=f'admin_user_modem_{user_id}'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Кнопки тарифов в режиме тарифов
|
||||
if settings.is_tariffs_mode():
|
||||
keyboard.append(
|
||||
@@ -2602,8 +2592,15 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
with_active_sub = sum(1 for u in inactive_users if u.subscription and u.subscription.is_active)
|
||||
will_delete = len(inactive_users) - with_active_sub
|
||||
|
||||
text = '🗑️ <b>Неактивные пользователи</b>\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n'
|
||||
if with_active_sub > 0:
|
||||
text += f'🛡️ С активной подпиской (не будут удалены): {with_active_sub}\n'
|
||||
text += f'🗑️ Будет удалено: {will_delete}\n'
|
||||
text += '\n'
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
if user.telegram_id:
|
||||
@@ -2612,7 +2609,9 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link}\n'
|
||||
has_active = user.subscription and user.subscription.is_active
|
||||
sub_badge = ' 🛡️' if has_active else ''
|
||||
text += f'👤 {user_link}{sub_badge}\n'
|
||||
text += f'🆔 <code>{user_id_display}</code>\n'
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда'
|
||||
@@ -3629,65 +3628,6 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_modem(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Переключение модема для пользователя в админке."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer('❌ Пользователь не найден', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
if not subscription:
|
||||
await callback.answer('❌ У пользователя нет подписки', show_alert=True)
|
||||
return
|
||||
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if modem_enabled:
|
||||
# Отключаем модем
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
action_text = 'отключен'
|
||||
else:
|
||||
# Включаем модем
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
action_text = 'подключен'
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Обновляем в RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обновления RemnaWave при переключении модема: {e}')
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
modem_status = '✅ Подключен' if subscription.modem_enabled else '❌ Отключен'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'📡 <b>Модем {action_text}</b>\n\nСтатус модема: {modem_status}\nЛимит устройств: {subscription.device_limit}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='📱 Подписка и настройки', callback_data=f'admin_user_subscription_{user_id}'
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
logger.info(f'Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}')
|
||||
await callback.answer()
|
||||
|
||||
@@ -4255,10 +4195,14 @@ async def _calculate_subscription_period_price(
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
user_service = UserService()
|
||||
deleted_count = await user_service.cleanup_inactive_users(db)
|
||||
deleted_count, skipped_count = await user_service.cleanup_inactive_users(db)
|
||||
|
||||
text = f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}'
|
||||
if skipped_count > 0:
|
||||
text += f'\n⏭️ Пропущено (активная подписка): {skipped_count}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}',
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
|
||||
),
|
||||
@@ -4621,6 +4565,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
@@ -4634,6 +4580,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
user_id=target_user.id,
|
||||
)
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
create_kwargs = dict(
|
||||
@@ -4645,10 +4593,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
|
||||
else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id,
|
||||
email=target_user.email,
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads,
|
||||
)
|
||||
@@ -5559,8 +5509,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_'))
|
||||
|
||||
dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_'))
|
||||
|
||||
# Смена тарифа пользователя
|
||||
dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_'))
|
||||
|
||||
|
||||
+20
-1
@@ -25,6 +25,24 @@ async def handle_delete_ban_notification(
|
||||
await callback.answer('Не удалось удалить', show_alert=False)
|
||||
|
||||
|
||||
async def handle_webhook_notification_close(
|
||||
callback: types.CallbackQuery,
|
||||
):
|
||||
"""Удаляет webhook-уведомление при нажатии кнопки Закрыть."""
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning('Не удалось удалить webhook-уведомление: %s', e)
|
||||
try:
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_unknown_callback(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
@@ -86,8 +104,9 @@ async def show_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSess
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
# Удаление уведомлений о банах
|
||||
# Удаление уведомлений
|
||||
dp.callback_query.register(handle_delete_ban_notification, F.data == 'ban_notify:delete')
|
||||
dp.callback_query.register(handle_webhook_notification_close, F.data == 'webhook:close')
|
||||
|
||||
dp.callback_query.register(show_rules, F.data == 'menu_rules')
|
||||
|
||||
|
||||
@@ -84,14 +84,52 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Лимит на стакинг (макс активаций в день)
|
||||
if not promo_limiter.can_activate(message.from_user.id):
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
|
||||
|
||||
if result['success']:
|
||||
promo_limiter.record_activation(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.PROMOCODE_SUCCESS.format(description=result['description']),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
# Записываем неудачную попытку только для not_found (перебор)
|
||||
if result['error'] == 'not_found':
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
error_messages = {
|
||||
'not_found': texts.PROMOCODE_INVALID,
|
||||
'expired': texts.PROMOCODE_EXPIRED,
|
||||
@@ -104,6 +142,10 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
|
||||
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
|
||||
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
|
||||
),
|
||||
'daily_limit': texts.t(
|
||||
'PROMO_DAILY_LIMIT',
|
||||
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
|
||||
),
|
||||
'server_error': texts.ERROR,
|
||||
}
|
||||
|
||||
|
||||
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
|
||||
# (независимо от того, включён ли выбор устройств)
|
||||
if current_subscription:
|
||||
current_device_limit = current_subscription.device_limit or device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
if getattr(current_subscription, 'modem_enabled', False):
|
||||
current_device_limit = max(1, current_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
device_limit = max(device_limit, current_device_limit)
|
||||
|
||||
# Проверяем, включён ли модем у текущей подписки
|
||||
modem_enabled = False
|
||||
if current_subscription:
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
|
||||
# Подготовим параметры простой подписки
|
||||
subscription_params = {
|
||||
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
|
||||
# Сохраняем параметры в состояние
|
||||
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
|
||||
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
|
||||
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
|
||||
db_user.id,
|
||||
period_days,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
price_kopeks,
|
||||
|
||||
+155
-32
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
@@ -158,10 +159,27 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
potential_code = message.text.strip()
|
||||
if len(potential_code) < 4 or len(potential_code) > 20:
|
||||
if len(potential_code) < 3 or len(potential_code) > 50:
|
||||
return False
|
||||
|
||||
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
|
||||
if not validate_promo_format(potential_code):
|
||||
return False
|
||||
|
||||
# Rate-limit на перебор промокодов
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return True
|
||||
|
||||
# Сначала проверяем реферальный код
|
||||
referrer = await get_user_by_referral_code(db, potential_code)
|
||||
if referrer:
|
||||
@@ -216,7 +234,10 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
|
||||
|
||||
return True
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
|
||||
@@ -485,9 +506,24 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию')
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, update as sa_update
|
||||
|
||||
from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
@@ -502,9 +538,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
|
||||
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
|
||||
payment_models = [
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
]
|
||||
for payment_model in payment_models:
|
||||
await db.execute(
|
||||
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
|
||||
)
|
||||
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
@@ -887,14 +951,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' not in str(e):
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}')
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
|
||||
|
||||
logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}')
|
||||
|
||||
@@ -928,8 +989,26 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
language = data.get('language', DEFAULT_LANGUAGE)
|
||||
texts = get_texts(language)
|
||||
|
||||
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
|
||||
|
||||
code = message.text.strip()
|
||||
|
||||
# Валидация формата
|
||||
if not validate_promo_format(code):
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
return
|
||||
|
||||
# Rate-limit на перебор
|
||||
if promo_limiter.is_blocked(message.from_user.id):
|
||||
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'PROMO_RATE_LIMITED',
|
||||
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
|
||||
).format(cooldown=cooldown)
|
||||
)
|
||||
return
|
||||
|
||||
# Сначала проверяем, является ли это реферальным кодом
|
||||
referrer = await get_user_by_referral_code(db, code)
|
||||
if referrer:
|
||||
@@ -959,7 +1038,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
# Ни реферальный код, ни промокод не найдены
|
||||
# Ни реферальный код, ни промокод не найдены — записываем неудачу
|
||||
promo_limiter.record_failed_attempt(message.from_user.id)
|
||||
promo_limiter.cleanup()
|
||||
|
||||
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
|
||||
logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}')
|
||||
return
|
||||
@@ -1184,6 +1266,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await callback.message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1450,12 +1546,33 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
|
||||
user_has_subscription = user.subscription and getattr(user.subscription, 'is_active', False)
|
||||
if user_has_subscription:
|
||||
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
|
||||
else:
|
||||
keyboard = get_post_registration_keyboard(user.language)
|
||||
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except TelegramBadRequest as e:
|
||||
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
|
||||
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
|
||||
try:
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=None,
|
||||
)
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except Exception as fallback_err:
|
||||
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
|
||||
else:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
|
||||
else:
|
||||
@@ -1684,6 +1801,8 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
|
||||
):
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -1829,10 +1948,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -1858,13 +1973,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1924,10 +2040,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -1953,13 +2065,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1979,19 +2092,16 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -2000,9 +2110,22 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
|
||||
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
|
||||
else:
|
||||
logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}')
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в required_sub_channel_check: {e}')
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -64,13 +64,6 @@ from .links import (
|
||||
handle_connect_subscription,
|
||||
handle_open_subscription_link,
|
||||
)
|
||||
from .modem import (
|
||||
handle_modem_confirm,
|
||||
handle_modem_disable,
|
||||
handle_modem_enable,
|
||||
handle_modem_menu,
|
||||
register_modem_handlers,
|
||||
)
|
||||
from .notifications import (
|
||||
send_extension_notification,
|
||||
send_purchase_notification,
|
||||
@@ -172,10 +165,6 @@ __all__ = [
|
||||
'handle_happ_download_platform_choice',
|
||||
'handle_happ_download_request',
|
||||
'handle_manage_country',
|
||||
'handle_modem_confirm',
|
||||
'handle_modem_disable',
|
||||
'handle_modem_enable',
|
||||
'handle_modem_menu',
|
||||
'handle_no_traffic_packages',
|
||||
'handle_open_subscription_link',
|
||||
'handle_promo_offer_close',
|
||||
@@ -190,7 +179,6 @@ __all__ = [
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
'register_modem_handlers',
|
||||
'resume_subscription_checkout',
|
||||
'return_to_saved_cart',
|
||||
'save_cart_and_redirect_to_topup',
|
||||
|
||||
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
buttons.append(
|
||||
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
|
||||
callback_data='subscription_settings',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -416,6 +416,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
).format(
|
||||
amount=texts.format_price(price),
|
||||
period=period_label,
|
||||
months=period_label,
|
||||
)
|
||||
if total_discount > 0:
|
||||
cost_text += texts.t(
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
"""
|
||||
Хендлеры для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
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_insufficient_balance_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
get_modem_service,
|
||||
)
|
||||
from app.utils.decorators import error_handler, modem_available
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_modem_keyboard(language: str, modem_enabled: bool):
|
||||
"""Клавиатура управления модемом."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if modem_enabled:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_DISABLE_BUTTON', 'Отключить модем'), callback_data='modem_disable'
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_ENABLE_BUTTON', 'Подключить модем'), callback_data='modem_enable'
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data='subscription_settings')])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_modem_confirm_keyboard(language: str):
|
||||
"""Клавиатура подтверждения подключения модема."""
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t('MODEM_CONFIRM_BUTTON', 'Подтвердить подключение'), callback_data='modem_confirm'
|
||||
)
|
||||
],
|
||||
[types.InlineKeyboardButton(text=texts.CANCEL, callback_data='subscription_modem')],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Показывает меню управления модемом."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
modem_enabled = service.get_modem_enabled(subscription)
|
||||
modem_price = settings.get_modem_price_per_month()
|
||||
|
||||
if modem_enabled:
|
||||
status_text = texts.t('MODEM_STATUS_ENABLED', 'Подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_ENABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Модем подключен к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При отключении модема возврат средств не производится.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
else:
|
||||
status_text = texts.t('MODEM_STATUS_DISABLED', 'Не подключен')
|
||||
info_text = texts.t(
|
||||
'MODEM_INFO_DISABLED',
|
||||
(
|
||||
'<b>Модем</b>\n\n'
|
||||
'Статус: {status}\n\n'
|
||||
'Подключите модем к вашей подписке.\n'
|
||||
'Ежемесячная плата: {price}\n\n'
|
||||
'При подключении модема будет добавлено дополнительное устройство.'
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
info_text, reply_markup=get_modem_keyboard(db_user.language, modem_enabled), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Обработчик подключения модема - показывает информацию о цене."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
price_info = service.calculate_price(subscription)
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
|
||||
|
||||
if not has_funds:
|
||||
if price_info.has_discount:
|
||||
required_text = (
|
||||
f'{texts.format_price(price_info.final_price)} '
|
||||
f'(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)'
|
||||
)
|
||||
else:
|
||||
required_text = f'{texts.format_price(price_info.final_price)} (за {price_info.charged_months} мес)'
|
||||
|
||||
message_text = texts.t(
|
||||
'MODEM_INSUFFICIENT_FUNDS',
|
||||
(
|
||||
'<b>Недостаточно средств</b>\n\n'
|
||||
'Стоимость подключения модема: {required}\n'
|
||||
'На балансе: {balance}\n'
|
||||
'Не хватает: {missing}\n\n'
|
||||
'Выберите способ пополнения.'
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
warning_level = service.get_period_warning_level(price_info.remaining_days)
|
||||
|
||||
if warning_level == 'critical':
|
||||
warning_text = texts.t(
|
||||
'MODEM_SHORT_PERIOD_WARNING',
|
||||
'\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n'
|
||||
'После продления подписки модем нужно будет оплатить заново!',
|
||||
).format(days=price_info.remaining_days)
|
||||
elif warning_level == 'info':
|
||||
warning_text = texts.t(
|
||||
'MODEM_PERIOD_NOTE',
|
||||
'\nДо окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.',
|
||||
).format(days=price_info.remaining_days)
|
||||
else:
|
||||
warning_text = ''
|
||||
|
||||
if price_info.has_discount:
|
||||
price_text = texts.t(
|
||||
'MODEM_PRICE_WITH_DISCOUNT',
|
||||
'Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n'
|
||||
'Скидка {discount}%: -{discount_amount}',
|
||||
).format(
|
||||
base_price=texts.format_price(price_info.base_price),
|
||||
final_price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
discount=price_info.discount_percent,
|
||||
discount_amount=texts.format_price(price_info.discount_amount),
|
||||
)
|
||||
else:
|
||||
price_text = texts.t('MODEM_PRICE_NO_DISCOUNT', 'Стоимость: {price} (за {months} мес)').format(
|
||||
price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
)
|
||||
|
||||
confirm_text = texts.t(
|
||||
'MODEM_CONFIRM_ENABLE_BASE',
|
||||
(
|
||||
'<b>Подтверждение подключения модема</b>\n\n'
|
||||
'{price_text}\n\n'
|
||||
'При подключении модема:\n'
|
||||
'К подписке добавится дополнительное устройство\n'
|
||||
'Ежемесячная плата увеличится на {monthly_price}\n\n'
|
||||
'Подтвердить подключение?'
|
||||
),
|
||||
).format(
|
||||
price_text=price_text,
|
||||
monthly_price=texts.format_price(modem_price_per_month),
|
||||
)
|
||||
|
||||
end_date_str = price_info.end_date.strftime('%d.%m.%Y')
|
||||
period_info = texts.t('MODEM_PERIOD_INFO', '\nМодем действует до: <b>{end_date}</b> ({days} дн.)').format(
|
||||
end_date=end_date_str, days=price_info.remaining_days
|
||||
)
|
||||
|
||||
confirm_text += period_info + warning_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text, reply_markup=get_modem_confirm_keyboard(db_user.language), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_confirm(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Подтверждение и активация модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.enable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
error_messages = {
|
||||
ModemError.INSUFFICIENT_FUNDS: texts.t('MODEM_INSUFFICIENT_FUNDS_SHORT', 'Недостаточно средств на балансе'),
|
||||
ModemError.CHARGE_ERROR: texts.t('PAYMENT_CHARGE_ERROR', 'Ошибка списания средств'),
|
||||
ModemError.UPDATE_ERROR: texts.ERROR,
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
if result.error == ModemError.INSUFFICIENT_FUNDS:
|
||||
await callback.message.edit_text(
|
||||
error_text, reply_markup=get_back_keyboard(db_user.language, 'modem_enable'), parse_mode='HTML'
|
||||
)
|
||||
else:
|
||||
await callback.answer(error_text, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', False, True, result.charged_amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления о подключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_ENABLED_SUCCESS',
|
||||
('<b>Модем успешно подключен!</b>\n\nМодем активирован\nДобавлено устройство для модема\n'),
|
||||
)
|
||||
if result.charged_amount > 0:
|
||||
success_text += texts.t(
|
||||
'MODEM_CHARGED',
|
||||
'Списано: {amount}',
|
||||
).format(amount=texts.format_price(result.charged_amount))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_disable=True)
|
||||
async def handle_modem_disable(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
"""Отключение модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.disable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
await callback.answer(texts.ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, 'modem', True, False, 0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка отправки уведомления об отключении модема: {e}')
|
||||
|
||||
success_text = texts.t(
|
||||
'MODEM_DISABLED_SUCCESS',
|
||||
('<b>Модем отключен</b>\n\nМодем деактивирован\nВозврат средств не производится'),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text, reply_markup=get_back_keyboard(db_user.language, 'subscription_settings'), parse_mode='HTML'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_modem_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков модема."""
|
||||
dp.callback_query.register(handle_modem_menu, F.data == 'subscription_modem')
|
||||
|
||||
dp.callback_query.register(handle_modem_enable, F.data == 'modem_enable')
|
||||
|
||||
dp.callback_query.register(handle_modem_confirm, F.data == 'modem_confirm')
|
||||
|
||||
dp.callback_query.register(handle_modem_disable, F.data == 'modem_disable')
|
||||
@@ -404,15 +404,16 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
status_text = '⌛ Истекла'
|
||||
type_text = 'Платная подписка'
|
||||
|
||||
if subscription.traffic_limit_gb == 0:
|
||||
traffic_limit = subscription.traffic_limit_gb or 0
|
||||
if traffic_limit == 0:
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_text = '∞ Безлимитный'
|
||||
else:
|
||||
traffic_text = '∞ Безлимитный'
|
||||
elif settings.is_traffic_fixed():
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
else:
|
||||
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
|
||||
traffic_text = f'{traffic_limit} ГБ'
|
||||
|
||||
subscription_cost = await get_subscription_cost(subscription, db)
|
||||
|
||||
@@ -444,7 +445,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
|
||||
info_text += f'\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}'
|
||||
|
||||
# Отображаем докупленный трафик
|
||||
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
|
||||
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select as sql_select
|
||||
|
||||
@@ -426,14 +426,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
# Показываем лимит без модема + модем
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
device_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
@@ -1544,6 +1537,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
# В режиме тарифов проверяем наличие tariff_id
|
||||
if settings.is_tariffs_mode():
|
||||
if subscription.tariff_id:
|
||||
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and getattr(tariff, 'is_daily', False):
|
||||
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
|
||||
await show_subscription_info(callback, db_user, db)
|
||||
return
|
||||
|
||||
# У подписки есть тариф - перенаправляем на продление по тарифу
|
||||
from .tariff_purchase import show_tariff_extend
|
||||
|
||||
@@ -1603,11 +1605,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_base = devices_price_per_month * months_in_period
|
||||
@@ -1814,11 +1811,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (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(
|
||||
@@ -2420,26 +2412,31 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
|
||||
promo_offer_discount_percent = 0
|
||||
|
||||
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
|
||||
# Допускаем небольшое расхождение из-за округления (до 5%)
|
||||
price_difference = abs(final_price - cached_total_price)
|
||||
max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
|
||||
if price_difference > max_allowed_difference:
|
||||
# Слишком большое расхождение - блокируем покупку
|
||||
logger.error(
|
||||
f'Критическое расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
# Небольшое расхождение - логируем предупреждение но продолжаем
|
||||
logger.warning(
|
||||
f'Расхождение цены для пользователя {db_user.telegram_id}: '
|
||||
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
|
||||
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
|
||||
price_difference = final_price - cached_total_price
|
||||
if price_difference > 0:
|
||||
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
|
||||
if price_difference > max_allowed_increase:
|
||||
logger.error(
|
||||
f'Цена выросла для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
|
||||
f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). '
|
||||
f'Покупка заблокирована.'
|
||||
)
|
||||
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
|
||||
return
|
||||
if price_difference > 100: # допуск 1₽
|
||||
logger.warning(
|
||||
f'Небольшой рост цены для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
)
|
||||
elif price_difference < -100: # цена снизилась более чем на 1₽
|
||||
logger.info(
|
||||
f'Цена снизилась для пользователя {db_user.telegram_id}: '
|
||||
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
|
||||
f'Используем пересчитанную цену.'
|
||||
f'Применяем новую цену.'
|
||||
)
|
||||
|
||||
# Используем пересчитанную цену
|
||||
@@ -3060,13 +3057,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
|
||||
'',
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
devices_limit_display = f'{visible_device_limit} + модем'
|
||||
else:
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
@@ -3129,11 +3120,15 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
# Прикрепляем тариф к подписке для CRUD функций
|
||||
subscription.tariff = tariff
|
||||
|
||||
# Переключаем статус паузы
|
||||
# Определяем, нужно ли возобновление: пауза пользователя ИЛИ остановка системой (disabled/expired)
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
was_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
|
||||
needs_resume = was_paused or is_inactive
|
||||
|
||||
# При возобновлении проверяем баланс
|
||||
if was_paused:
|
||||
if needs_resume:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price > 0 and db_user.balance_kopeks < daily_price:
|
||||
await callback.answer(
|
||||
@@ -3145,10 +3140,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
)
|
||||
return
|
||||
|
||||
subscription = await toggle_daily_subscription_pause(db, subscription)
|
||||
if needs_resume:
|
||||
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
|
||||
from app.database.crud.subscription import resume_daily_subscription
|
||||
|
||||
if was_paused:
|
||||
# Была пауза, теперь возобновили
|
||||
subscription = await resume_daily_subscription(db, subscription)
|
||||
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
|
||||
# Синхронизируем с Remnawave - активируем пользователя
|
||||
try:
|
||||
@@ -3165,10 +3161,9 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}')
|
||||
else:
|
||||
# Была активна, теперь на паузе
|
||||
# Подписка активна, ставим на паузу
|
||||
subscription = await toggle_daily_subscription_pause(db, subscription)
|
||||
message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!')
|
||||
# При паузе можно отключить пользователя в Remnawave (опционально)
|
||||
# Пока оставляем активным, т.к. пауза - это только остановка списания
|
||||
|
||||
await callback.answer(message, show_alert=True)
|
||||
|
||||
@@ -3227,6 +3222,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Сохраняем ID до начала транзакции (на случай detached session)
|
||||
user_id_snapshot = db_user.id
|
||||
|
||||
# Создаем триальную подписку
|
||||
subscription: Subscription | None = None
|
||||
remnawave_user = None
|
||||
@@ -3388,22 +3386,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Unexpected error during paid trial activation for user %s: %s',
|
||||
db_user.id,
|
||||
user_id_snapshot,
|
||||
error,
|
||||
)
|
||||
# Пытаемся откатить и вернуть деньги
|
||||
if subscription:
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
# Откатываем сессию чтобы очистить PendingRollbackError
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
# Пытаемся вернуть деньги
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
except Exception as refund_error:
|
||||
logger.error(
|
||||
'Failed to refund trial payment for user %s: %s',
|
||||
user_id_snapshot,
|
||||
refund_error,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
@@ -4099,11 +4108,6 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
|
||||
|
||||
# Регистрируем обработчики модема
|
||||
from .modem import register_modem_handlers
|
||||
|
||||
register_modem_handlers(dp)
|
||||
|
||||
# Регистрируем обработчики покупки по тарифам
|
||||
from .tariff_purchase import register_tariff_purchase_handlers
|
||||
|
||||
@@ -4138,10 +4142,6 @@ async def handle_simple_subscription_purchase(
|
||||
if current_subscription and current_subscription.is_active:
|
||||
# При продлении используем текущие устройства подписки, а не дефолтные
|
||||
extend_device_limit = current_subscription.device_limit or simple_device_limit
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно
|
||||
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
|
||||
if modem_enabled:
|
||||
extend_device_limit = max(1, extend_device_limit - 1)
|
||||
# Используем максимум из текущего и дефолтного
|
||||
extend_device_limit = max(simple_device_limit, extend_device_limit)
|
||||
|
||||
@@ -4155,7 +4155,6 @@ async def handle_simple_subscription_purchase(
|
||||
device_limit=extend_device_limit,
|
||||
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
|
||||
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
|
||||
modem_enabled=modem_enabled,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -4275,7 +4274,6 @@ async def _extend_existing_subscription(
|
||||
device_limit: int,
|
||||
traffic_limit_gb: int,
|
||||
squad_uuid: str,
|
||||
modem_enabled: bool = False,
|
||||
):
|
||||
"""Продлевает существующую подписку."""
|
||||
from datetime import datetime, timedelta
|
||||
@@ -4293,7 +4291,6 @@ async def _extend_existing_subscription(
|
||||
'device_limit': device_limit,
|
||||
'traffic_limit_gb': traffic_limit_gb,
|
||||
'squad_uuid': squad_uuid,
|
||||
'modem_enabled': modem_enabled,
|
||||
}
|
||||
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
|
||||
db,
|
||||
@@ -4302,17 +4299,15 @@ async def _extend_existing_subscription(
|
||||
resolved_squad_uuid=squad_uuid,
|
||||
)
|
||||
logger.warning(
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
|
||||
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
|
||||
db_user.id,
|
||||
price_kopeks,
|
||||
price_breakdown.get('base_price', 0),
|
||||
price_breakdown.get('traffic_price', 0),
|
||||
price_breakdown.get('devices_price', 0),
|
||||
price_breakdown.get('modem_price', 0),
|
||||
price_breakdown.get('servers_price', 0),
|
||||
price_breakdown.get('total_discount', 0),
|
||||
device_limit,
|
||||
modem_enabled,
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
|
||||
@@ -1424,10 +1424,23 @@ async def confirm_daily_tariff_purchase(
|
||||
# ==================== Продление по тарифу ====================
|
||||
|
||||
|
||||
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
|
||||
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
|
||||
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
|
||||
if additional <= 0:
|
||||
return 0
|
||||
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
|
||||
if device_price <= 0:
|
||||
return 0
|
||||
months = max(1, round(period_days / 30))
|
||||
return additional * device_price * months
|
||||
|
||||
|
||||
def get_tariff_extend_keyboard(
|
||||
tariff: Tariff,
|
||||
language: str,
|
||||
db_user: User | None = None,
|
||||
subscription_device_limit: int | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
|
||||
texts = get_texts(language)
|
||||
@@ -1438,6 +1451,10 @@ def get_tariff_extend_keyboard(
|
||||
period = int(period_str)
|
||||
price = prices[period_str]
|
||||
|
||||
# Добавляем стоимость дополнительных устройств
|
||||
if subscription_device_limit is not None:
|
||||
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
|
||||
|
||||
# Получаем скидку для конкретного периода
|
||||
discount_percent = 0
|
||||
if db_user:
|
||||
@@ -1508,13 +1525,17 @@ async def show_tariff_extend(
|
||||
if has_period_discounts:
|
||||
discount_hint = '\n🎁 <i>Скидки зависят от выбранного периода</i>'
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n\n'
|
||||
'Выберите период продления:',
|
||||
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
|
||||
reply_markup=get_tariff_extend_keyboard(
|
||||
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -1531,6 +1552,12 @@ async def select_tariff_extend_period(
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split(':')
|
||||
tariff_id = int(parts[1])
|
||||
|
||||
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
|
||||
if len(parts) < 3:
|
||||
await show_tariff_extend(callback, db_user, db)
|
||||
return
|
||||
|
||||
period = int(parts[2])
|
||||
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
@@ -1538,12 +1565,16 @@ async def select_tariff_extend_period(
|
||||
await callback.answer('Тариф недоступен', show_alert=True)
|
||||
return
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
|
||||
|
||||
# Получаем скидку для выбранного периода
|
||||
discount_percent = _get_user_period_discount(db_user, period)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1560,7 +1591,7 @@ async def select_tariff_extend_period(
|
||||
f'✅ <b>Подтверждение продления</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Период: {_format_period(period)}\n'
|
||||
f'{discount_text}\n'
|
||||
f'💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n'
|
||||
@@ -1572,9 +1603,6 @@ async def select_tariff_extend_period(
|
||||
else:
|
||||
missing = final_price - user_balance
|
||||
|
||||
# Получаем текущую подписку для сохранения в корзину
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
# Сохраняем данные корзины для автопокупки после пополнения
|
||||
cart_data = {
|
||||
'cart_mode': 'extend',
|
||||
@@ -1588,7 +1616,7 @@ async def select_tariff_extend_period(
|
||||
'return_to_cart': True,
|
||||
'description': f'Продление тарифа {tariff.name} на {period} дней',
|
||||
'traffic_limit_gb': tariff.traffic_limit_gb,
|
||||
'device_limit': tariff.device_limit,
|
||||
'device_limit': actual_device_limit,
|
||||
'allowed_squads': tariff.allowed_squads or [],
|
||||
'discount_percent': discount_percent,
|
||||
}
|
||||
@@ -1641,12 +1669,15 @@ async def confirm_tariff_extend(
|
||||
await callback.answer('Подписка не найдена', show_alert=True)
|
||||
return
|
||||
|
||||
actual_device_limit = subscription.device_limit or tariff.device_limit
|
||||
|
||||
data = await state.get_data()
|
||||
discount_percent = data.get('extend_discount_percent', 0)
|
||||
|
||||
# Получаем цену
|
||||
# Получаем цену (тариф + дополнительные устройства)
|
||||
prices = tariff.period_prices or {}
|
||||
base_price = prices.get(str(period), 0)
|
||||
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
|
||||
final_price = _apply_promo_discount(base_price, discount_percent)
|
||||
|
||||
# Проверяем баланс
|
||||
@@ -1724,7 +1755,7 @@ async def confirm_tariff_extend(
|
||||
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
|
||||
f'📦 Тариф: <b>{tariff.name}</b>\n'
|
||||
f'📊 Трафик: {traffic}\n'
|
||||
f'📱 Устройств: {tariff.device_limit}\n'
|
||||
f'📱 Устройств: {actual_device_limit}\n'
|
||||
f'📅 Добавлено: {_format_period(period)}\n'
|
||||
f'💰 Списано: {_format_price_kopeks(final_price)}',
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
|
||||
+67
-113
@@ -3,12 +3,12 @@ import logging
|
||||
import time
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InaccessibleMessage
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import Ticket, TicketStatus, User
|
||||
@@ -63,12 +63,20 @@ async def show_ticket_priority_selection(
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:'),
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
prompt_text = texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:')
|
||||
cancel_kb = get_ticket_cancel_keyboard(db_user.language)
|
||||
prompt_msg = callback.message
|
||||
try:
|
||||
await callback.message.edit_text(prompt_text, reply_markup=cancel_kb)
|
||||
except TelegramBadRequest:
|
||||
# Предыдущее сообщение — фото (нет текста для edit_text), удаляем и шлём новое
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
prompt_msg = await callback.message.answer(prompt_text, reply_markup=cancel_kb)
|
||||
# Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые
|
||||
await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.message_id)
|
||||
await state.update_data(prompt_chat_id=prompt_msg.chat.id, prompt_message_id=prompt_msg.message_id)
|
||||
await state.set_state(TicketStates.waiting_for_title)
|
||||
await callback.answer()
|
||||
|
||||
@@ -92,58 +100,18 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
|
||||
if len(title) < 5:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
caption=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:')
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
if len(title) > 255:
|
||||
texts = get_texts(db_user.language)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
caption=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
)
|
||||
text_val = texts.t(
|
||||
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
|
||||
)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
return
|
||||
|
||||
# Глобальный блок
|
||||
@@ -166,29 +134,8 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
|
||||
await state.update_data(title=title)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
caption=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=text_val,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'),
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_message)
|
||||
|
||||
@@ -263,12 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
)
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(text_msg)
|
||||
else:
|
||||
await message.answer(text_msg)
|
||||
await state.clear()
|
||||
@@ -285,24 +230,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
err_text = texts.t(
|
||||
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
caption=err_text,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
parse_mode=None,
|
||||
)
|
||||
else:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
text=err_text,
|
||||
reply_markup=get_ticket_cancel_keyboard(db_user.language),
|
||||
)
|
||||
else:
|
||||
await message.answer(err_text)
|
||||
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
@@ -356,15 +284,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
]
|
||||
)
|
||||
if prompt_chat_id and prompt_message_id:
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await message.bot.edit_message_caption(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
caption=creation_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=prompt_chat_id,
|
||||
message_id=prompt_message_id,
|
||||
@@ -372,6 +292,8 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
else:
|
||||
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
|
||||
|
||||
@@ -765,6 +687,28 @@ async def user_delete_message(callback: types.CallbackQuery):
|
||||
await callback.answer('✅')
|
||||
|
||||
|
||||
async def _edit_or_send(
|
||||
message: types.Message,
|
||||
chat_id: int | None,
|
||||
message_id: int | None,
|
||||
text: str,
|
||||
language: str,
|
||||
) -> None:
|
||||
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
|
||||
if chat_id and message_id:
|
||||
try:
|
||||
await message.bot.edit_message_text(
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
reply_markup=get_ticket_cancel_keyboard(language),
|
||||
)
|
||||
return
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
|
||||
|
||||
|
||||
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
|
||||
try:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
@@ -782,10 +726,20 @@ async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_u
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
|
||||
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
await state.set_state(TicketStates.waiting_for_reply)
|
||||
await callback.answer()
|
||||
|
||||
+141
-38
@@ -47,8 +47,6 @@ async def get_main_menu_keyboard_async(
|
||||
Иначе делегирует в синхронную версию.
|
||||
"""
|
||||
if settings.MENU_LAYOUT_ENABLED:
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.menu_layout_service import MenuContext, MenuLayoutService
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
@@ -247,6 +245,8 @@ _LANGUAGE_DISPLAY_NAMES = {
|
||||
'zh-hant': '🇹🇼 中文 (繁體)',
|
||||
'vi': '🇻🇳 Tiếng Việt',
|
||||
'vi-vn': '🇻🇳 Tiếng Việt',
|
||||
'fa': '🇮🇷 فارسی',
|
||||
'fa-ir': '🇮🇷 فارسی',
|
||||
}
|
||||
|
||||
|
||||
@@ -362,32 +362,101 @@ def get_language_selection_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _build_text_main_menu_keyboard(
|
||||
def _build_cabinet_main_menu_keyboard(
|
||||
language: str,
|
||||
texts,
|
||||
*,
|
||||
is_admin: bool,
|
||||
is_moderator: bool,
|
||||
balance_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
"""Build the main-menu keyboard for Cabinet mode.
|
||||
|
||||
if miniapp_url:
|
||||
profile_button = InlineKeyboardButton(
|
||||
text=profile_text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
Each button opens the corresponding section of the cabinet frontend
|
||||
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
|
||||
"""
|
||||
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
from app.utils.miniapp_buttons import (
|
||||
CALLBACK_TO_CABINET_STYLE,
|
||||
_resolve_style,
|
||||
build_cabinet_url,
|
||||
)
|
||||
|
||||
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
|
||||
cached_styles = get_cached_button_styles()
|
||||
|
||||
def _cabinet_button(
|
||||
text: str,
|
||||
path: str,
|
||||
callback_fallback: str,
|
||||
*,
|
||||
style: str | None = None,
|
||||
icon_custom_emoji_id: str | None = None,
|
||||
) -> InlineKeyboardButton:
|
||||
url = build_cabinet_url(path)
|
||||
if url:
|
||||
section = CALLBACK_TO_SECTION.get(callback_fallback)
|
||||
section_cfg = cached_styles.get(section or '', {}) if section else {}
|
||||
|
||||
# 'default' in per-section config means "no color" — do not fall through.
|
||||
if style:
|
||||
resolved = _resolve_style(style)
|
||||
elif section_cfg.get('style'):
|
||||
resolved = _resolve_style(section_cfg['style'])
|
||||
else:
|
||||
resolved = global_style or _resolve_style(CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
|
||||
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
|
||||
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=url),
|
||||
style=resolved,
|
||||
icon_custom_emoji_id=resolved_emoji or None,
|
||||
)
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
|
||||
|
||||
# -- Primary action row: Cabinet home --
|
||||
home_cfg = cached_styles.get('home', {})
|
||||
if home_cfg.get('enabled', True):
|
||||
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [
|
||||
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
|
||||
]
|
||||
else:
|
||||
profile_button = InlineKeyboardButton(
|
||||
text=profile_text,
|
||||
callback_data='menu_profile_unavailable',
|
||||
)
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]]
|
||||
# -- Section buttons as paired rows --
|
||||
paired: list[InlineKeyboardButton] = []
|
||||
|
||||
if settings.is_language_selection_enabled():
|
||||
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')])
|
||||
# Subscription (green — main action)
|
||||
sub_cfg = cached_styles.get('subscription', {})
|
||||
if sub_cfg.get('enabled', True):
|
||||
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
|
||||
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
|
||||
|
||||
# Balance
|
||||
bal_cfg = cached_styles.get('balance', {})
|
||||
if bal_cfg.get('enabled', True):
|
||||
safe_balance = balance_kopeks or 0
|
||||
# Custom label overrides the whole text including balance amount
|
||||
custom_bal = bal_cfg.get('labels', {}).get(language, '')
|
||||
if custom_bal:
|
||||
balance_text = custom_bal
|
||||
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
else:
|
||||
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
|
||||
balance=texts.format_price(safe_balance),
|
||||
)
|
||||
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
|
||||
|
||||
# Referrals (if enabled)
|
||||
ref_cfg = cached_styles.get('referral', {})
|
||||
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
|
||||
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
|
||||
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
|
||||
|
||||
# Support
|
||||
support_enabled = False
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
@@ -396,11 +465,33 @@ def _build_text_main_menu_keyboard(
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
|
||||
if support_enabled:
|
||||
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')])
|
||||
sup_cfg = cached_styles.get('support', {})
|
||||
if support_enabled and sup_cfg.get('enabled', True):
|
||||
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
|
||||
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
|
||||
|
||||
# Info
|
||||
info_cfg = cached_styles.get('info', {})
|
||||
if info_cfg.get('enabled', True):
|
||||
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', 'ℹ️ Инфо')
|
||||
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
|
||||
|
||||
# Language selection (stays as callback — not a cabinet section)
|
||||
if settings.is_language_selection_enabled():
|
||||
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
|
||||
|
||||
# Lay out in pairs
|
||||
for i in range(0, len(paired), 2):
|
||||
keyboard_rows.append(paired[i : i + 2])
|
||||
|
||||
# Admin / Moderator
|
||||
admin_cfg = cached_styles.get('admin', {})
|
||||
if is_admin:
|
||||
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')])
|
||||
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
|
||||
if admin_cfg.get('enabled', True):
|
||||
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
|
||||
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
|
||||
keyboard_rows.append(admin_buttons)
|
||||
elif is_moderator:
|
||||
keyboard_rows.append([InlineKeyboardButton(text='🧑⚖️ Модерация', callback_data='moderator_panel')])
|
||||
|
||||
@@ -423,12 +514,13 @@ def get_main_menu_keyboard(
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
return _build_text_main_menu_keyboard(
|
||||
if settings.is_cabinet_mode():
|
||||
return _build_cabinet_main_menu_keyboard(
|
||||
language,
|
||||
texts,
|
||||
is_admin=is_admin,
|
||||
is_moderator=is_moderator,
|
||||
balance_kopeks=balance_kopeks,
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
@@ -1000,9 +1092,15 @@ def get_subscription_keyboard(
|
||||
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
|
||||
|
||||
if is_daily_tariff:
|
||||
# Для суточного тарифа показываем кнопку паузы/возобновления
|
||||
# Для суточного тарифа: проверяем статус подписки
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
sub_status = getattr(subscription, 'status', None)
|
||||
is_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
if is_paused:
|
||||
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
|
||||
|
||||
if is_inactive or is_paused:
|
||||
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
|
||||
pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку')
|
||||
else:
|
||||
pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку')
|
||||
@@ -1789,6 +1887,8 @@ def get_add_traffic_keyboard(
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
@@ -1798,7 +1898,7 @@ def get_add_traffic_keyboard(
|
||||
period_text = f' (за {months_multiplier} мес)'
|
||||
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
|
||||
|
||||
if not enabled_packages:
|
||||
return InlineKeyboardMarkup(
|
||||
@@ -1826,17 +1926,20 @@ def get_add_traffic_keyboard(
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'♾️ Безлимитный трафик - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'♾️ Unlimited traffic - {total_price // 100} ₽{period_text}'
|
||||
elif language == 'ru':
|
||||
elif use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1861,6 +1964,8 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent: Процент скидки
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not packages:
|
||||
return InlineKeyboardMarkup(
|
||||
@@ -1877,8 +1982,8 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
|
||||
buttons = []
|
||||
|
||||
# Сортируем пакеты по размеру
|
||||
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
|
||||
# Сортируем пакеты по размеру, исключаем пакеты с нулевой ценой
|
||||
sorted_packages = sorted(((gb, p) for gb, p in packages.items() if p > 0), key=lambda x: x[0])
|
||||
|
||||
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
|
||||
@@ -1888,15 +1993,18 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
period_text = ' /мес' if language == 'ru' else ' /mo'
|
||||
period_text = ' /мес' if use_russian_fallback else ' /mo'
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {discounted_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and discount_value > 0:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -2610,11 +2718,6 @@ def get_updated_subscription_settings_keyboard(
|
||||
]
|
||||
)
|
||||
|
||||
if settings.is_modem_enabled() and not has_tariff:
|
||||
keyboard.append(
|
||||
[InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
|
||||
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ('ru', 'en'):
|
||||
for locale_code in ('ru', 'en', 'fa'):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
|
||||
target_path = destination / f'{locale_code}.json'
|
||||
|
||||
|
||||
@@ -1548,6 +1548,9 @@
|
||||
"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_DESCRIPTION": "Trial subscription payment",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.",
|
||||
"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",
|
||||
@@ -1695,5 +1698,24 @@
|
||||
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!"
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Subscription activated</b>\n\nYour subscription is active again. Enjoy!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Traffic limit reached</b>\n\nYou have used all available traffic. Purchase additional traffic or wait for a reset.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Traffic reset</b>\n\nYour traffic has been reset. Full limit is available again.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Subscription deleted</b>\n\nYour subscription was deleted from the panel.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Subscription key updated</b>\n\nYour connection credentials have been updated. Use the new link to connect.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Subscription expires in 3 days</b>\n\nDon't forget to renew your subscription to keep VPN access.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Subscription expires in 2 days</b>\n\nRenew your subscription in advance to maintain VPN access.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Subscription expires tomorrow!</b>\n\nLess than 24 hours left. Renew your subscription now.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Subscription expired yesterday</b>\n\nYour subscription has ended. Renew now to restore VPN access.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>First connection!</b>\n\nYou connected to the VPN for the first time. Welcome!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>{percent}% traffic used</b>\n\nYou have used a significant portion of your traffic. Monitor your usage.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Renew subscription",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription is active but no VPN connection has been made. Connect to start using the service.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
|
||||
"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",
|
||||
@@ -1716,5 +1719,24 @@
|
||||
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!"
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Достигнут лимит трафика</b>\n\nВы исчерпали весь доступный трафик. Докупите трафик или дождитесь сброса.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафик сброшен</b>\n\nВаш трафик обнулён. Лимит снова доступен в полном объёме.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Подписка удалена</b>\n\nВаша подписка была удалена из панели.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ подписки обновлён</b>\n\nВаши данные подключения были обновлены. Используйте новую ссылку для подключения.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Подписка истекает через 3 дня</b>\n\nНе забудьте продлить подписку, чтобы не потерять доступ.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Подписка истекает через 2 дня</b>\n\nПродлите подписку заранее, чтобы сохранить доступ к VPN.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Подписка истекает завтра!</b>\n\nОсталось менее 24 часов. Продлите подписку прямо сейчас.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Подписка истекла вчера</b>\n\nВаша подписка завершена. Продлите сейчас, чтобы вернуть доступ к VPN.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Первое подключение!</b>\n\nВы впервые подключились к VPN. Добро пожаловать!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Использован {percent}% трафика</b>\n\nВы использовали значительную часть трафика. Следите за расходом.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Продлить подписку",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК вашей подписке подключено новое устройство: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
|
||||
}
|
||||
|
||||
@@ -1479,6 +1479,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.",
|
||||
"TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.",
|
||||
"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",
|
||||
@@ -1583,5 +1586,23 @@
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
|
||||
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Досягнуто ліміт трафіку</b>\n\nВи вичерпали весь доступний трафік. Докупіть трафік або дочекайтесь скидання.",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафік скинуто</b>\n\nВаш трафік обнулено. Ліміт знову доступний у повному обсязі.",
|
||||
"WEBHOOK_SUB_DELETED": "🗑️ <b>Підписку видалено</b>\n\nВашу підписку було видалено з панелі.",
|
||||
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ підписки оновлено</b>\n\nВаші дані підключення були оновлені. Використовуйте нове посилання для підключення.",
|
||||
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Підписка закінчується через 3 дні</b>\n\nНе забудьте продовжити підписку, щоб не втратити доступ.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Підписка закінчується через 2 дні</b>\n\nПродовжте підписку заздалегідь, щоб зберегти доступ до VPN.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Підписка закінчується завтра!</b>\n\nЗалишилось менше 24 годин. Продовжте підписку прямо зараз.",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Підписка закінчилась вчора</b>\n\nВаша підписка завершена. Продовжте зараз, щоб повернути доступ до VPN.",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Перше підключення!</b>\n\nВи вперше підключились до VPN. Ласкаво просимо!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Використано {percent}% трафіку</b>\n\nВи використали значну частину трафіку. Слідкуйте за витратою.",
|
||||
"WEBHOOK_RENEW_BUTTON": "🔄 Продовжити підписку",
|
||||
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Ви ще не підключились</b>\n\nВаша підписка активна, але VPN-з'єднання не було встановлено. Підключіться, щоб почати користуватися.",
|
||||
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новий пристрій</b>\n\nДо вашої підписки підключено новий пристрій: <code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED": "📱 <b>Пристрій видалено</b>\n\nПристрій відключено від підписки: <code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити"
|
||||
}
|
||||
|
||||
@@ -1477,6 +1477,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"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•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
@@ -1807,6 +1810,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"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•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
@@ -1913,5 +1919,23 @@
|
||||
"MODEM_PERIOD_NOTE":"\nℹ️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
|
||||
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"WEBHOOK_SUB_EXPIRED":"❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_DISABLED":"🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
|
||||
"WEBHOOK_SUB_ENABLED":"✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
|
||||
"WEBHOOK_SUB_LIMITED":"⚠️ <b>已达到流量限制</b>\n\n您已用完所有可用流量。请购买额外流量或等待重置。",
|
||||
"WEBHOOK_SUB_TRAFFIC_RESET":"🔄 <b>流量已重置</b>\n\n您的流量已重置。完整限额再次可用。",
|
||||
"WEBHOOK_SUB_DELETED":"🗑️ <b>订阅已删除</b>\n\n您的订阅已从面板中删除。",
|
||||
"WEBHOOK_SUB_REVOKED":"🔑 <b>订阅密钥已更新</b>\n\n您的连接凭证已更新。请使用新链接进行连接。",
|
||||
"WEBHOOK_SUB_EXPIRES_72H":"⏳ <b>订阅将在3天后到期</b>\n\n请记得续订以保持VPN访问。",
|
||||
"WEBHOOK_SUB_EXPIRES_48H":"⏳ <b>订阅将在2天后到期</b>\n\n请提前续订以保持VPN访问。",
|
||||
"WEBHOOK_SUB_EXPIRES_24H":"🔴 <b>订阅明天到期!</b>\n\n不到24小时。请立即续订。",
|
||||
"WEBHOOK_SUB_EXPIRED_24H_AGO":"💤 <b>订阅昨天已过期</b>\n\n您的订阅已结束。请立即续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_FIRST_CONNECTED":"🎉 <b>首次连接!</b>\n\n您首次连接了VPN。欢迎!",
|
||||
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD":"📊 <b>已使用{percent}%流量</b>\n\n您已使用了大部分流量。请注意使用量。",
|
||||
"WEBHOOK_RENEW_BUTTON":"🔄 续订订阅",
|
||||
"WEBHOOK_USER_NOT_CONNECTED":"📡 <b>尚未连接</b>\n\n您的订阅已激活,但尚未建立VPN连接。请连接以开始使用服务。",
|
||||
"WEBHOOK_DEVICE_ADDED":"📱 <b>新设备</b>\n\n您的订阅已添加新设备:<code>{device}</code>",
|
||||
"WEBHOOK_DEVICE_DELETED":"📱 <b>设备已移除</b>\n\n设备已从您的订阅中移除:<code>{device}</code>",
|
||||
"WEBHOOK_CLOSE_BUTTON":"✖️ 关闭"
|
||||
}
|
||||
|
||||
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
|
||||
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
|
||||
),
|
||||
},
|
||||
'fa': {
|
||||
'traffic_pattern': '📊 {size} گیگابایت - {price}',
|
||||
'unlimited_pattern': '📊 نامحدود - {price}',
|
||||
'support_info': (
|
||||
'\n🛟 <b>پشتیبانی</b>\n\n'
|
||||
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
|
||||
'👤 {support_username}\n\n'
|
||||
'• 🎫 ایجاد تیکت\n'
|
||||
'• 📋 تیکتهای من\n'
|
||||
'• 💬 تماس مستقیم\n'
|
||||
),
|
||||
},
|
||||
'en': {
|
||||
'traffic_pattern': '📊 {size} GB - {price}',
|
||||
'unlimited_pattern': '📊 Unlimited - {price}',
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError
|
||||
@@ -219,6 +220,15 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f'Callback data: {event.data}')
|
||||
raise
|
||||
except TelegramForbiddenError:
|
||||
# User blocked the bot — normal, not an error
|
||||
logger.debug('AuthMiddleware: bot blocked by user, skipping')
|
||||
return None
|
||||
except TelegramBadRequest as e:
|
||||
if 'query is too old' in str(e):
|
||||
logger.debug('AuthMiddleware: callback query expired, skipping')
|
||||
return None
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в AuthMiddleware: {e}')
|
||||
logger.error(f'Event type: {type(event)}')
|
||||
|
||||
@@ -204,6 +204,6 @@ class ButtonStatsMiddleware(BaseMiddleware):
|
||||
button_text=button_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Ошибка записи клика в БД {button_id}: {e}')
|
||||
logger.warning(f'Ошибка записи клика в БД {button_id}: {e}')
|
||||
except Exception as e:
|
||||
logger.debug(f'Ошибка создания сессии БД для логирования клика: {e}')
|
||||
logger.warning(f'Ошибка создания сессии БД для логирования клика: {e}')
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
import redis.asyncio as aioredis
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
||||
|
||||
@@ -109,6 +109,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.debug('❌ telegram_id не найден, пропускаем')
|
||||
return await handler(event, data)
|
||||
|
||||
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
||||
if isinstance(event, CallbackQuery) and event.data in (
|
||||
'webhook:close',
|
||||
'ban_notify:delete',
|
||||
'noop',
|
||||
'current_page',
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
@@ -188,6 +197,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}')
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}')
|
||||
return await handler(event, data)
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}')
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -12,10 +12,26 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThrottlingMiddleware(BaseMiddleware):
|
||||
def __init__(self, rate_limit: float = 0.5):
|
||||
"""
|
||||
Двухуровневый rate-limiter:
|
||||
1. Общий троттлинг — 0.5 сек между любыми сообщениями (UX)
|
||||
2. /start burst-лимит — макс N вызовов за окно (anti-spam)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rate_limit: float = 0.5,
|
||||
start_max_calls: int = 3,
|
||||
start_window: float = 60.0,
|
||||
):
|
||||
self.rate_limit = rate_limit
|
||||
self.user_buckets: dict[int, float] = {}
|
||||
|
||||
# /start anti-spam: sliding window per user
|
||||
self.start_max_calls = start_max_calls
|
||||
self.start_window = start_window
|
||||
self.start_buckets: dict[int, list[float]] = {}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
@@ -30,10 +46,37 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
now = time.time()
|
||||
|
||||
# --- /start burst rate-limit ---
|
||||
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
|
||||
timestamps = self.start_buckets.get(user_id, [])
|
||||
# Оставляем только вызовы внутри окна
|
||||
timestamps = [ts for ts in timestamps if now - ts < self.start_window]
|
||||
|
||||
if len(timestamps) >= self.start_max_calls:
|
||||
cooldown = int(self.start_window - (now - timestamps[0])) + 1
|
||||
logger.warning(
|
||||
'Rate-limit /start для %s: %d вызовов за %ds (лимит %d)',
|
||||
user_id,
|
||||
len(timestamps),
|
||||
int(self.start_window),
|
||||
self.start_max_calls,
|
||||
)
|
||||
try:
|
||||
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
|
||||
except Exception:
|
||||
pass
|
||||
self.start_buckets[user_id] = timestamps
|
||||
return None
|
||||
|
||||
timestamps.append(now)
|
||||
self.start_buckets[user_id] = timestamps
|
||||
|
||||
# --- Общий троттлинг (0.5 сек) ---
|
||||
last_call = self.user_buckets.get(user_id, 0)
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f'🚫 Throttling для пользователя {user_id}')
|
||||
logger.warning(f'Throttling для пользователя {user_id}')
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
@@ -61,9 +104,17 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
# Периодическая очистка старых записей
|
||||
cleanup_threshold = now - 60
|
||||
self.user_buckets = {
|
||||
uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold
|
||||
}
|
||||
# Очистка /start бакетов (раз в ~60 сек, лениво)
|
||||
if len(self.start_buckets) > 500:
|
||||
self.start_buckets = {
|
||||
uid: [ts for ts in tss if now - ts < self.start_window]
|
||||
for uid, tss in self.start_buckets.items()
|
||||
if any(now - ts < self.start_window for ts in tss)
|
||||
}
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
@@ -1216,6 +1216,21 @@ class AdminNotificationService:
|
||||
def _is_enabled(self) -> bool:
|
||||
return self.enabled and bool(self.chat_id)
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
"""Public check for whether admin notifications are configured and active."""
|
||||
return self._is_enabled()
|
||||
|
||||
async def send_webhook_notification(self, text: str) -> bool:
|
||||
"""Send a generic webhook/infrastructure notification to admin chat.
|
||||
|
||||
Used by RemnaWaveWebhookService for node, service, and CRM events.
|
||||
The caller is responsible for HTML-escaping all untrusted data in `text`.
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text)
|
||||
|
||||
def _get_payment_method_display(self, payment_method: str | None) -> str:
|
||||
if not payment_method:
|
||||
return '💰 С баланса'
|
||||
@@ -1519,7 +1534,6 @@ class AdminNotificationService:
|
||||
'traffic': '📊 ДОКУПКА ТРАФИКА',
|
||||
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
|
||||
'servers': '🌐 СМЕНА СЕРВЕРОВ',
|
||||
'modem': '📡 МОДЕМ',
|
||||
}
|
||||
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
|
||||
|
||||
@@ -1555,10 +1569,6 @@ class AdminNotificationService:
|
||||
message_lines.append(f'🔄 {old_formatted} → {new_formatted}')
|
||||
elif update_type == 'devices':
|
||||
message_lines.append(f'🔄 {old_value} → {new_value} устр.')
|
||||
elif update_type == 'modem':
|
||||
old_state = '✅ Вкл' if old_value else '❌ Выкл'
|
||||
new_state = '✅ Вкл' if new_value else '❌ Выкл'
|
||||
message_lines.append(f'🔄 {old_state} → {new_state}')
|
||||
else:
|
||||
message_lines.append(f'🔄 {old_value} → {new_value}')
|
||||
|
||||
@@ -1623,8 +1633,6 @@ class AdminNotificationService:
|
||||
if isinstance(value, list):
|
||||
return f'{len(value)} серверов'
|
||||
return str(value)
|
||||
if update_type == 'modem':
|
||||
return '✅ Включён' if value else '❌ Выключен'
|
||||
return str(value)
|
||||
|
||||
async def send_bulk_ban_notification(
|
||||
|
||||
+271
-63
@@ -2,12 +2,14 @@ import asyncio
|
||||
import gzip
|
||||
import json as json_lib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import date as dt_date, datetime, time as dt_time, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -15,6 +17,7 @@ import aiofiles
|
||||
import pyzipper
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -24,14 +27,41 @@ from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
BroadcastHistory,
|
||||
ButtonClickLog,
|
||||
CloudPaymentsPayment,
|
||||
ContestAttempt,
|
||||
ContestRound,
|
||||
ContestTemplate,
|
||||
CryptoBotPayment,
|
||||
DiscountOffer,
|
||||
FaqPage,
|
||||
FaqSetting,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
MonitoringLog,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PaymentMethodConfig,
|
||||
PinnedMessage,
|
||||
PlategaPayment,
|
||||
Poll,
|
||||
PollAnswer,
|
||||
PollOption,
|
||||
PollQuestion,
|
||||
PollResponse,
|
||||
PrivacyPolicy,
|
||||
PromoCode,
|
||||
PromoCodeUse,
|
||||
PromoGroup,
|
||||
PromoOfferLog,
|
||||
PromoOfferTemplate,
|
||||
PublicOffer,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ReferralEarning,
|
||||
SentNotification,
|
||||
ServerSquad,
|
||||
@@ -39,19 +69,33 @@ from app.database.models import (
|
||||
Squad,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionEvent,
|
||||
SubscriptionServer,
|
||||
SubscriptionTemporaryAccess,
|
||||
SupportAuditLog,
|
||||
SystemSetting,
|
||||
Tariff,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
TicketNotification,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
User,
|
||||
UserMessage,
|
||||
UserPromoGroup,
|
||||
WataPayment,
|
||||
WebApiToken,
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
WelcomeText,
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
WithdrawalRequest,
|
||||
YooKassaPayment,
|
||||
payment_method_promo_groups,
|
||||
server_squad_promo_groups,
|
||||
tariff_promo_groups,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,6 +166,53 @@ class BackupService:
|
||||
TicketMessage,
|
||||
SupportAuditLog,
|
||||
WebApiToken,
|
||||
# --- Payment providers (FK: users, transactions) ---
|
||||
HeleketPayment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
# --- Settings/content ---
|
||||
PaymentMethodConfig,
|
||||
PrivacyPolicy,
|
||||
PublicOffer,
|
||||
FaqSetting,
|
||||
FaqPage,
|
||||
PinnedMessage,
|
||||
MainMenuButton,
|
||||
MenuLayoutHistory,
|
||||
# --- User data (FK: users, promo_groups, subscriptions) ---
|
||||
UserPromoGroup,
|
||||
TrafficPurchase,
|
||||
SubscriptionEvent,
|
||||
SubscriptionTemporaryAccess,
|
||||
PromoOfferTemplate,
|
||||
PromoOfferLog,
|
||||
# --- Referral/contests (FK: users) ---
|
||||
WithdrawalRequest,
|
||||
ReferralContest,
|
||||
ReferralContestEvent,
|
||||
ReferralContestVirtualParticipant,
|
||||
ContestTemplate,
|
||||
ContestRound,
|
||||
ContestAttempt,
|
||||
# --- Polls (FK chain: polls -> questions -> options -> answers) ---
|
||||
Poll,
|
||||
PollQuestion,
|
||||
PollOption,
|
||||
PollResponse,
|
||||
PollAnswer,
|
||||
# --- Webhooks ---
|
||||
Webhook,
|
||||
WebhookDelivery,
|
||||
# --- Wheel (FK chain: configs -> prizes -> spins) ---
|
||||
WheelConfig,
|
||||
WheelPrize,
|
||||
WheelSpin,
|
||||
# --- Support ---
|
||||
TicketNotification,
|
||||
ButtonClickLog,
|
||||
]
|
||||
|
||||
self.backup_models_ordered = self._base_backup_models.copy()
|
||||
@@ -131,6 +222,8 @@ class BackupService:
|
||||
|
||||
self.association_tables = {
|
||||
'server_squad_promo_groups': server_squad_promo_groups,
|
||||
'tariff_promo_groups': tariff_promo_groups,
|
||||
'payment_method_promo_groups': payment_method_promo_groups,
|
||||
}
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
@@ -509,10 +602,17 @@ class BackupService:
|
||||
|
||||
if value is None:
|
||||
record_dict[column.name] = None
|
||||
elif isinstance(value, datetime):
|
||||
elif isinstance(value, (datetime, dt_date, dt_time)):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif isinstance(value, Decimal):
|
||||
record_dict[column.name] = float(value)
|
||||
elif isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
||||
record_dict[column.name] = 0.0
|
||||
elif isinstance(value, (list, dict)):
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
try:
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
except TypeError:
|
||||
record_dict[column.name] = str(value)
|
||||
elif hasattr(value, '__dict__'):
|
||||
record_dict[column.name] = str(value)
|
||||
else:
|
||||
@@ -538,7 +638,7 @@ class BackupService:
|
||||
|
||||
except Exception as exc:
|
||||
logger.error('Ошибка при экспорте данных: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
|
||||
files_info: list[dict[str, Any]] = []
|
||||
@@ -623,7 +723,7 @@ class BackupService:
|
||||
|
||||
mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r'
|
||||
with tarfile.open(backup_path, mode) as tar:
|
||||
tar.extractall(temp_path)
|
||||
tar.extractall(temp_path, filter='data')
|
||||
|
||||
metadata_path = temp_path / 'metadata.json'
|
||||
if not metadata_path.exists():
|
||||
@@ -785,20 +885,31 @@ class BackupService:
|
||||
logger.info('📁 Снимок директории data восстановлен')
|
||||
|
||||
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
|
||||
allowed_base = self.data_dir.resolve()
|
||||
|
||||
for file_info in files_info:
|
||||
relative_path = file_info.get('relative_path')
|
||||
target_path = Path(file_info.get('path', ''))
|
||||
if not relative_path or not target_path:
|
||||
continue
|
||||
|
||||
source_file = temp_path / relative_path
|
||||
target_resolved = target_path.resolve()
|
||||
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
|
||||
logger.warning('Заблокирована запись за пределами data_dir: %s', target_path)
|
||||
continue
|
||||
|
||||
source_file = (temp_path / relative_path).resolve()
|
||||
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
|
||||
logger.warning('Path traversal в relative_path: %s', relative_path)
|
||||
continue
|
||||
|
||||
if not source_file.exists():
|
||||
logger.warning('Файл %s отсутствует в архиве', relative_path)
|
||||
continue
|
||||
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_path)
|
||||
logger.info('📁 Файл %s восстановлен', target_path)
|
||||
target_resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
|
||||
logger.info('📁 Файл %s восстановлен', target_resolved)
|
||||
|
||||
async def _restore_database_payload(
|
||||
self,
|
||||
@@ -914,7 +1025,7 @@ class BackupService:
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.error('Ошибка при восстановлении: %s', exc)
|
||||
raise exc
|
||||
raise
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
@@ -987,17 +1098,39 @@ class BackupService:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
db.add(instance)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(instance)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
'Дубликат пользователя (id=%s, telegram_id=%s), пропускаем',
|
||||
processed_data.get('id'),
|
||||
processed_data.get('telegram_id'),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
db.add(instance)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(instance)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
'Дубликат пользователя (telegram_id=%s), пропускаем',
|
||||
processed_data.get('telegram_id'),
|
||||
)
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при восстановлении пользователя: {e}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
await db.commit()
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError as e:
|
||||
logger.warning('IntegrityError при flush пользователей, откатываем: %s', e)
|
||||
await db.rollback()
|
||||
logger.info('✅ Пользователи без реферальных связей восстановлены')
|
||||
|
||||
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
|
||||
@@ -1031,7 +1164,7 @@ class BackupService:
|
||||
logger.error(f'Ошибка при обновлении реферальной связи: {e}')
|
||||
continue
|
||||
|
||||
await db.commit()
|
||||
await db.flush()
|
||||
logger.info('✅ Реферальные связи обновлены')
|
||||
|
||||
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
|
||||
@@ -1058,6 +1191,18 @@ class BackupService:
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = datetime.utcnow()
|
||||
elif column_type_str == 'TIME' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_time.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}')
|
||||
processed_data[key] = dt_time(hour=12, minute=0)
|
||||
elif column_type_str == 'DATE' and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = dt_date.fromisoformat(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
|
||||
processed_data[key] = None
|
||||
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
|
||||
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif (
|
||||
@@ -1089,11 +1234,8 @@ class BackupService:
|
||||
|
||||
return processed_data
|
||||
|
||||
def _get_primary_key_column(self, model) -> str | None:
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
return col.name
|
||||
return None
|
||||
def _get_primary_key_columns(self, model) -> list[str]:
|
||||
return [col.name for col in model.__table__.columns if col.primary_key]
|
||||
|
||||
async def _export_association_tables(self, db: AsyncSession) -> dict[str, list[dict[str, Any]]]:
|
||||
association_data: dict[str, list[dict[str, Any]]] = {}
|
||||
@@ -1119,63 +1261,65 @@ class BackupService:
|
||||
restored_tables = 0
|
||||
restored_records = 0
|
||||
|
||||
if 'server_squad_promo_groups' in association_data:
|
||||
restored = await self._restore_server_squad_promo_groups(
|
||||
db, association_data['server_squad_promo_groups'], clear_existing
|
||||
for table_name, table_obj in self.association_tables.items():
|
||||
if table_name not in association_data:
|
||||
continue
|
||||
col_names = [col.name for col in table_obj.columns]
|
||||
restored = await self._restore_association_table(
|
||||
db, table_obj, table_name, association_data[table_name], clear_existing, col_names
|
||||
)
|
||||
restored_tables += 1
|
||||
restored_records += restored
|
||||
|
||||
return restored_tables, restored_records
|
||||
|
||||
async def _restore_server_squad_promo_groups(
|
||||
self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool
|
||||
async def _restore_association_table(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
table_obj,
|
||||
table_name: str,
|
||||
records: list[dict[str, Any]],
|
||||
clear_existing: bool,
|
||||
col_names: list[str],
|
||||
) -> int:
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
if clear_existing:
|
||||
await db.execute(server_squad_promo_groups.delete())
|
||||
await db.execute(table_obj.delete())
|
||||
|
||||
restored = 0
|
||||
|
||||
for record in records:
|
||||
server_id = record.get('server_squad_id')
|
||||
promo_id = record.get('promo_group_id')
|
||||
values = {col: record.get(col) for col in col_names}
|
||||
|
||||
if server_id is None or promo_id is None:
|
||||
logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record)
|
||||
if any(v is None for v in values.values()):
|
||||
logger.warning('Пропущена некорректная запись %s: %s', table_name, record)
|
||||
continue
|
||||
|
||||
try:
|
||||
first_col = col_names[0]
|
||||
exists_stmt = (
|
||||
select(server_squad_promo_groups.c.server_squad_id)
|
||||
.where(
|
||||
server_squad_promo_groups.c.server_squad_id == server_id,
|
||||
server_squad_promo_groups.c.promo_group_id == promo_id,
|
||||
)
|
||||
select(table_obj.c[first_col])
|
||||
.where(*[table_obj.c[col] == values[col] for col in col_names])
|
||||
.limit(1)
|
||||
)
|
||||
existing = await db.execute(exists_stmt)
|
||||
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
logger.debug(
|
||||
'Запись server_squad_promo_groups (%s, %s) уже существует',
|
||||
server_id,
|
||||
promo_id,
|
||||
)
|
||||
logger.debug('Запись %s %s уже существует', table_name, values)
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id)
|
||||
)
|
||||
restored += 1
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
await db.execute(table_obj.insert().values(**values))
|
||||
restored += 1
|
||||
except IntegrityError:
|
||||
logger.warning('Пропускаем связь %s %s (FK или дубликат)', table_name, values)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e
|
||||
)
|
||||
await db.rollback()
|
||||
raise e
|
||||
logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e)
|
||||
raise
|
||||
|
||||
return restored
|
||||
|
||||
@@ -1205,21 +1349,31 @@ class BackupService:
|
||||
logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки')
|
||||
processed_data['tariff_id'] = None
|
||||
|
||||
primary_key_col = self._get_primary_key_column(model)
|
||||
pk_cols = self._get_primary_key_columns(model)
|
||||
|
||||
if primary_key_col and primary_key_col in processed_data:
|
||||
existing_record = await db.execute(
|
||||
select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col])
|
||||
)
|
||||
if pk_cols and all(col in processed_data for col in pk_cols):
|
||||
where_clause = [getattr(model, col) == processed_data[col] for col in pk_cols]
|
||||
existing_record = await db.execute(select(model).where(*where_clause))
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
if key not in pk_cols:
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
db.add(instance)
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Unique constraint conflict — record exists with different PK
|
||||
logger.warning(
|
||||
'Дубликат по уникальному ключу в %s (PK=%s), пропускаем',
|
||||
table_name,
|
||||
{col: processed_data.get(col) for col in pk_cols},
|
||||
)
|
||||
continue
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
@@ -1229,17 +1383,69 @@ class BackupService:
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка восстановления записи в {table_name}: {e}')
|
||||
logger.error(f'Проблемные данные: {record_data}')
|
||||
await db.rollback()
|
||||
raise e
|
||||
raise
|
||||
|
||||
return restored_count
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None):
|
||||
tables_order = [
|
||||
# --- Association tables (no FK deps on them, safe to delete first) ---
|
||||
'server_squad_promo_groups',
|
||||
'tariff_promo_groups',
|
||||
'payment_method_promo_groups',
|
||||
# --- Polls (child -> parent order) ---
|
||||
'poll_answers',
|
||||
'poll_responses',
|
||||
'poll_options',
|
||||
'poll_questions',
|
||||
'polls',
|
||||
# --- Wheel (child -> parent) ---
|
||||
'wheel_spins',
|
||||
'wheel_prizes',
|
||||
'wheel_configs',
|
||||
# --- Contests (child -> parent) ---
|
||||
'contest_attempts',
|
||||
'contest_rounds',
|
||||
'contest_templates',
|
||||
'referral_contest_virtual_participants',
|
||||
'referral_contest_events',
|
||||
'referral_contests',
|
||||
# --- Webhooks ---
|
||||
'webhook_deliveries',
|
||||
'webhooks',
|
||||
# --- Promo offers ---
|
||||
'promo_offer_logs',
|
||||
'promo_offer_templates',
|
||||
'subscription_temporary_access',
|
||||
# --- User engagement ---
|
||||
'subscription_events',
|
||||
'traffic_purchases',
|
||||
'user_promo_groups',
|
||||
'withdrawal_requests',
|
||||
# --- Support extras ---
|
||||
'ticket_notifications',
|
||||
'button_click_logs',
|
||||
# --- Payment providers ---
|
||||
'heleket_payments',
|
||||
'wata_payments',
|
||||
'platega_payments',
|
||||
'cloudpayments_payments',
|
||||
'freekassa_payments',
|
||||
'kassa_ai_payments',
|
||||
# --- Content/config ---
|
||||
'pinned_messages',
|
||||
'main_menu_buttons',
|
||||
'menu_layout_history',
|
||||
'faq_pages',
|
||||
'faq_settings',
|
||||
'privacy_policies',
|
||||
'public_offers',
|
||||
'payment_method_configs',
|
||||
# --- Original tables (preserved order) ---
|
||||
'support_audit_logs',
|
||||
'ticket_messages',
|
||||
'tickets',
|
||||
'support_audit_logs',
|
||||
'cabinet_refresh_tokens',
|
||||
'advertising_campaign_registrations',
|
||||
'advertising_campaigns',
|
||||
'subscription_servers',
|
||||
@@ -1408,9 +1614,11 @@ class BackupService:
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
backup_path = (self.backup_dir / backup_filename).resolve()
|
||||
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
|
||||
return False, '❌ Недопустимое имя файла бекапа'
|
||||
|
||||
if not backup_path.exists():
|
||||
if not backup_path.is_file():
|
||||
return False, f'❌ Файл бекапа не найден: {backup_filename}'
|
||||
|
||||
backup_path.unlink()
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import calculate_prorated_price
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModemError(Enum):
|
||||
"""Типы ошибок при работе с модемом."""
|
||||
|
||||
NO_SUBSCRIPTION = 'no_subscription'
|
||||
TRIAL_SUBSCRIPTION = 'trial_subscription'
|
||||
MODEM_DISABLED = 'modem_disabled'
|
||||
ALREADY_ENABLED = 'already_enabled'
|
||||
NOT_ENABLED = 'not_enabled'
|
||||
INSUFFICIENT_FUNDS = 'insufficient_funds'
|
||||
CHARGE_ERROR = 'charge_error'
|
||||
UPDATE_ERROR = 'update_error'
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemAvailabilityResult:
|
||||
"""Результат проверки доступности модема."""
|
||||
|
||||
available: bool
|
||||
error: ModemError | None = None
|
||||
modem_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemPriceResult:
|
||||
"""Результат расчёта цены модема."""
|
||||
|
||||
base_price: int
|
||||
final_price: int
|
||||
discount_percent: int
|
||||
discount_amount: int
|
||||
charged_months: int
|
||||
remaining_days: int
|
||||
end_date: datetime
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
return self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemEnableResult:
|
||||
"""Результат подключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
charged_amount: int = 0
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemDisableResult:
|
||||
"""Результат отключения модема."""
|
||||
|
||||
success: bool
|
||||
error: ModemError | None = None
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
# Константы для предупреждений о сроке действия
|
||||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||||
MODEM_WARNING_DAYS_INFO = 30
|
||||
|
||||
|
||||
class ModemService:
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Инкапсулирует всю бизнес-логику:
|
||||
- Проверки доступности
|
||||
- Расчёт цен и скидок
|
||||
- Подключение/отключение модема
|
||||
- Синхронизация с RemnaWave
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscription_service = SubscriptionService()
|
||||
|
||||
@staticmethod
|
||||
def is_modem_feature_enabled() -> bool:
|
||||
"""Проверяет, включена ли функция модема в настройках."""
|
||||
return settings.is_modem_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_modem_enabled(subscription: Subscription | None) -> bool:
|
||||
"""Безопасно получает статус модема из подписки."""
|
||||
if subscription is None:
|
||||
return False
|
||||
return getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
def check_availability(
|
||||
self, user: User, for_enable: bool = False, for_disable: bool = False
|
||||
) -> ModemAvailabilityResult:
|
||||
"""
|
||||
Проверяет доступность модема для пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||||
for_disable: Проверка для отключения (модем должен быть включен)
|
||||
|
||||
Returns:
|
||||
ModemAvailabilityResult с результатом проверки
|
||||
"""
|
||||
subscription = user.subscription
|
||||
modem_enabled = self.get_modem_enabled(subscription)
|
||||
|
||||
if not subscription:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.NO_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.TRIAL_SUBSCRIPTION, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if not self.is_modem_feature_enabled():
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.MODEM_DISABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_enable and modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False, error=ModemError.ALREADY_ENABLED, modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_disable and not modem_enabled:
|
||||
return ModemAvailabilityResult(available=False, error=ModemError.NOT_ENABLED, modem_enabled=modem_enabled)
|
||||
|
||||
return ModemAvailabilityResult(available=True, modem_enabled=modem_enabled)
|
||||
|
||||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||||
"""
|
||||
Рассчитывает стоимость подключения модема.
|
||||
|
||||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||||
и применяет скидки в зависимости от периода.
|
||||
|
||||
Args:
|
||||
subscription: Подписка пользователя
|
||||
|
||||
Returns:
|
||||
ModemPriceResult с детализацией цены
|
||||
"""
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
base_price, charged_months = calculate_prorated_price(
|
||||
modem_price_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||||
if discount_percent > 0:
|
||||
discount_amount = base_price * discount_percent // 100
|
||||
final_price = base_price - discount_amount
|
||||
else:
|
||||
discount_amount = 0
|
||||
final_price = base_price
|
||||
|
||||
return ModemPriceResult(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
charged_months=charged_months,
|
||||
remaining_days=remaining_days,
|
||||
end_date=subscription.end_date,
|
||||
)
|
||||
|
||||
def check_balance(self, user: User, price: int) -> tuple[bool, int]:
|
||||
"""
|
||||
Проверяет достаточность баланса.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
price: Требуемая сумма
|
||||
|
||||
Returns:
|
||||
Tuple[достаточно ли средств, недостающая сумма]
|
||||
"""
|
||||
if price <= 0:
|
||||
return True, 0
|
||||
|
||||
if user.balance_kopeks >= price:
|
||||
return True, 0
|
||||
|
||||
missing = price - user.balance_kopeks
|
||||
return False, missing
|
||||
|
||||
async def enable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemEnableResult:
|
||||
"""
|
||||
Подключает модем к подписке.
|
||||
|
||||
Выполняет:
|
||||
1. Расчёт цены
|
||||
2. Проверку баланса
|
||||
3. Списание средств
|
||||
4. Создание транзакции
|
||||
5. Обновление подписки
|
||||
6. Синхронизацию с RemnaWave
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemEnableResult с результатом операции
|
||||
"""
|
||||
price_info = self.calculate_price(subscription)
|
||||
price = price_info.final_price
|
||||
|
||||
has_funds, _ = self.check_balance(user, price)
|
||||
if not has_funds:
|
||||
return ModemEnableResult(success=False, error=ModemError.INSUFFICIENT_FUNDS)
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(db, user, price, 'Подключение модема')
|
||||
|
||||
if not success:
|
||||
return ModemEnableResult(success=False, error=ModemError.CHARGE_ERROR)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f'Подключение модема на {price_info.charged_months} мес',
|
||||
)
|
||||
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}₽')
|
||||
|
||||
return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemEnableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
async def disable_modem(self, db: AsyncSession, user: User, subscription: Subscription) -> ModemDisableResult:
|
||||
"""
|
||||
Отключает модем от подписки.
|
||||
|
||||
Возврат средств не производится.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemDisableResult с результатом операции
|
||||
"""
|
||||
try:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'Пользователь {user_id_display} отключил модем')
|
||||
|
||||
return ModemDisableResult(success=True, new_device_limit=subscription.device_limit)
|
||||
|
||||
except Exception as e:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}')
|
||||
await db.rollback()
|
||||
return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR)
|
||||
|
||||
@staticmethod
|
||||
def get_period_warning_level(remaining_days: int) -> str | None:
|
||||
"""
|
||||
Определяет уровень предупреждения о сроке действия.
|
||||
|
||||
Args:
|
||||
remaining_days: Оставшиеся дни подписки
|
||||
|
||||
Returns:
|
||||
"critical" если <= 7 дней
|
||||
"info" если <= 30 дней
|
||||
None если больше 30 дней
|
||||
"""
|
||||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||||
return 'critical'
|
||||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||||
return 'info'
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance для использования в хендлерах
|
||||
_modem_service: ModemService | None = None
|
||||
|
||||
|
||||
def get_modem_service() -> ModemService:
|
||||
"""Возвращает singleton экземпляр ModemService."""
|
||||
global _modem_service
|
||||
if _modem_service is None:
|
||||
_modem_service = ModemService()
|
||||
return _modem_service
|
||||
+108
-148
@@ -5,8 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import FSInputFile
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -108,13 +107,17 @@ class MonitoringService:
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
|
||||
try:
|
||||
return await self.bot.send_photo(
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
result = await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
_cache_logo_file_id(result)
|
||||
return result
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning(
|
||||
'Не удалось отправить сообщение с логотипом пользователю %s: %s. Отправляем текстовое сообщение.',
|
||||
@@ -218,7 +221,6 @@ class MonitoringService:
|
||||
await self._check_expired_subscriptions(db)
|
||||
await self._check_expiring_subscriptions(db)
|
||||
await self._check_trial_expiring_soon(db)
|
||||
await self._check_trial_inactivity_notifications(db)
|
||||
await self._check_trial_channel_subscriptions(db)
|
||||
await self._check_expired_subscription_followups(db)
|
||||
if settings.ENABLE_AUTOPAY:
|
||||
@@ -259,9 +261,18 @@ class MonitoringService:
|
||||
|
||||
async def _check_expired_subscriptions(self, db: AsyncSession):
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск expire подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
from app.database.crud.subscription import expire_subscription
|
||||
|
||||
await expire_subscription(db, subscription)
|
||||
@@ -285,6 +296,15 @@ class MonitoringService:
|
||||
|
||||
async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None:
|
||||
try:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}')
|
||||
@@ -296,6 +316,14 @@ class MonitoringService:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-check guard after refresh (webhook could have committed between first check and refresh)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно (после refresh)',
|
||||
subscription.id,
|
||||
)
|
||||
return None
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
|
||||
|
||||
@@ -475,78 +503,9 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}')
|
||||
|
||||
async def _check_trial_inactivity_notifications(self, db: AsyncSession):
|
||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||
return
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
one_hour_ago = now - timedelta(hours=1)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
Subscription.is_trial == True,
|
||||
Subscription.start_date.isnot(None),
|
||||
Subscription.start_date <= one_hour_ago,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
sent_1h = 0
|
||||
sent_24h = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
if (subscription.traffic_used_gb or 0) > 0:
|
||||
continue
|
||||
|
||||
start_date = subscription.start_date
|
||||
if not start_date:
|
||||
continue
|
||||
|
||||
time_since_start = now - start_date
|
||||
|
||||
if NotificationSettingsService.is_trial_inactive_1h_enabled() and timedelta(
|
||||
hours=1
|
||||
) <= time_since_start < timedelta(hours=24):
|
||||
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_1h'):
|
||||
success = await self._send_trial_inactive_notification(user, subscription, 1)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'trial_inactive_1h')
|
||||
sent_1h += 1
|
||||
|
||||
if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(
|
||||
hours=24
|
||||
):
|
||||
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_24h'):
|
||||
success = await self._send_trial_inactive_notification(user, subscription, 24)
|
||||
if success:
|
||||
await record_notification(db, user.id, subscription.id, 'trial_inactive_24h')
|
||||
sent_24h += 1
|
||||
|
||||
if sent_1h or sent_24h:
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
'trial_inactivity_notifications',
|
||||
f'Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа',
|
||||
{'sent_1h': sent_1h, 'sent_24h': sent_24h},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
|
||||
|
||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
@@ -633,6 +592,12 @@ class MonitoringService:
|
||||
continue
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
@@ -667,6 +632,12 @@ class MonitoringService:
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск реактивации trial подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -1004,6 +975,15 @@ class MonitoringService:
|
||||
failed_count = 0
|
||||
|
||||
for subscription in autopay_subscriptions:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск автоплатежа подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
@@ -1234,6 +1214,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об истечении подписки пользователю %s: %s',
|
||||
@@ -1283,84 +1270,16 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
|
||||
try:
|
||||
texts = get_texts(user.language)
|
||||
if hours >= 24:
|
||||
template = texts.get(
|
||||
'TRIAL_INACTIVE_24H',
|
||||
(
|
||||
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
|
||||
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
|
||||
'\n\nНажмите кнопку ниже, чтобы подключиться.'
|
||||
),
|
||||
)
|
||||
else:
|
||||
template = texts.get(
|
||||
'TRIAL_INACTIVE_1H',
|
||||
(
|
||||
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
|
||||
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
|
||||
),
|
||||
)
|
||||
|
||||
message = template.format(
|
||||
price=settings.format_price(settings.PRICE_30_DAYS),
|
||||
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='subscription_connect',
|
||||
)
|
||||
],
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support'
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self._send_message_with_logo(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'уведомление о бездействии на тесте'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
|
||||
'Ошибка отправки уведомления об окончании тестовой подписки пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
@@ -1421,6 +1340,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as error:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об отписке от канала пользователю %s: %s',
|
||||
@@ -1485,6 +1411,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки напоминания об истекшей подписке пользователю %s: %s',
|
||||
@@ -1577,6 +1510,13 @@ class MonitoringService:
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки скидочного уведомления пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки скидочного уведомления пользователю %s: %s',
|
||||
@@ -1601,6 +1541,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления об автоплатеже пользователю %s: %s',
|
||||
@@ -1638,6 +1584,12 @@ class MonitoringService:
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning(
|
||||
'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
user.telegram_id,
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s',
|
||||
@@ -1871,11 +1823,19 @@ class MonitoringService:
|
||||
}
|
||||
|
||||
async def force_check_subscriptions(self, db: AsyncSession) -> dict[str, int]:
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
try:
|
||||
expired_subscriptions = await get_expired_subscriptions(db)
|
||||
expired_count = 0
|
||||
|
||||
for subscription in expired_subscriptions:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск force-check подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
await deactivate_subscription(db, subscription)
|
||||
expired_count += 1
|
||||
|
||||
|
||||
@@ -57,6 +57,21 @@ class NotificationType(Enum):
|
||||
EMAIL_VERIFICATION = 'email_verification'
|
||||
PASSWORD_RESET = 'password_reset'
|
||||
|
||||
# Webhook subscription events
|
||||
WEBHOOK_SUB_EXPIRED = 'webhook_sub_expired'
|
||||
WEBHOOK_SUB_DISABLED = 'webhook_sub_disabled'
|
||||
WEBHOOK_SUB_ENABLED = 'webhook_sub_enabled'
|
||||
WEBHOOK_SUB_LIMITED = 'webhook_sub_limited'
|
||||
WEBHOOK_SUB_TRAFFIC_RESET = 'webhook_sub_traffic_reset'
|
||||
WEBHOOK_SUB_DELETED = 'webhook_sub_deleted'
|
||||
WEBHOOK_SUB_REVOKED = 'webhook_sub_revoked'
|
||||
WEBHOOK_SUB_EXPIRING = 'webhook_sub_expiring'
|
||||
WEBHOOK_SUB_FIRST_CONNECTED = 'webhook_sub_first_connected'
|
||||
WEBHOOK_SUB_BANDWIDTH_THRESHOLD = 'webhook_sub_bandwidth_threshold'
|
||||
WEBHOOK_USER_NOT_CONNECTED = 'webhook_user_not_connected'
|
||||
WEBHOOK_DEVICE_ADDED = 'webhook_device_added'
|
||||
WEBHOOK_DEVICE_DELETED = 'webhook_device_deleted'
|
||||
|
||||
# Other
|
||||
BROADCAST = 'broadcast'
|
||||
PAYMENT_RECEIVED = 'payment_received'
|
||||
|
||||
@@ -18,8 +18,6 @@ class NotificationSettingsService:
|
||||
_loaded: bool = False
|
||||
|
||||
_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
'trial_inactive_1h': {'enabled': True},
|
||||
'trial_inactive_24h': {'enabled': True},
|
||||
'trial_channel_unsubscribed': {'enabled': True},
|
||||
'expired_1d': {'enabled': True},
|
||||
'expired_second_wave': {
|
||||
@@ -122,23 +120,6 @@ class NotificationSettingsService:
|
||||
def is_enabled(cls, key: str) -> bool:
|
||||
return bool(cls._get(key).get('enabled', True))
|
||||
|
||||
# Trial inactivity helpers
|
||||
@classmethod
|
||||
def is_trial_inactive_1h_enabled(cls) -> bool:
|
||||
return cls.is_enabled('trial_inactive_1h')
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled('trial_inactive_1h', enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_inactive_24h_enabled(cls) -> bool:
|
||||
return cls.is_enabled('trial_inactive_24h')
|
||||
|
||||
@classmethod
|
||||
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
|
||||
return cls.set_enabled('trial_inactive_24h', enabled)
|
||||
|
||||
@classmethod
|
||||
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
|
||||
return cls.is_enabled('trial_channel_unsubscribed')
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.cloudpayments_service import CloudPaymentsAPIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -238,6 +237,7 @@ class CloudPaymentsPaymentMixin:
|
||||
payment_method=PaymentMethod.CLOUDPAYMENTS,
|
||||
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
payment.transaction_id = transaction.id
|
||||
@@ -262,22 +262,11 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.exception('Ошибка отправки уведомления CloudPayments: %s', error)
|
||||
|
||||
# Auto-purchase if enabled
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка автопокупки после CloudPayments: %s', error)
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
# Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений
|
||||
await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error)
|
||||
|
||||
return True
|
||||
|
||||
async def process_cloudpayments_fail_webhook(
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import MissingGreenlet
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import get_db
|
||||
from app.database.database import AsyncSessionLocal, get_db
|
||||
from app.database.models import Subscription
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
@@ -46,12 +49,26 @@ class PaymentCommonMixin:
|
||||
and not getattr(subscription, 'is_trial', False)
|
||||
and getattr(subscription, 'is_active', False)
|
||||
)
|
||||
except MissingGreenlet as error:
|
||||
logger.warning(
|
||||
'Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s',
|
||||
getattr(user, 'id', None),
|
||||
error,
|
||||
)
|
||||
except MissingGreenlet:
|
||||
# user вне сессии — загружаем подписку отдельным запросом
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Subscription.status, Subscription.is_trial, Subscription.end_date)
|
||||
.where(Subscription.user_id == user.id)
|
||||
.order_by(Subscription.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row:
|
||||
is_active = row.status == 'active' and row.end_date > datetime.utcnow()
|
||||
has_active_subscription = bool(is_active and not row.is_trial)
|
||||
except Exception as db_error:
|
||||
logger.warning(
|
||||
'Не удалось загрузить подписку пользователя %s из БД: %s',
|
||||
getattr(user, 'id', None),
|
||||
db_error,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - защитный код
|
||||
logger.error(
|
||||
'Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s',
|
||||
@@ -171,79 +188,18 @@ class PaymentCommonMixin:
|
||||
try:
|
||||
payment_method = payment_method_title or 'Банковская карта (YooKassa)'
|
||||
|
||||
# Проверяем, нужно ли показывать яркое предупреждение об активации
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Определяем статус подписки для выбора правильной кнопки
|
||||
has_active_subscription = False
|
||||
if user_snapshot:
|
||||
try:
|
||||
subscription = user_snapshot.subscription
|
||||
has_active_subscription = bool(
|
||||
subscription
|
||||
and not getattr(subscription, 'is_trial', False)
|
||||
and getattr(subscription, 'is_active', False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Яркое сообщение с восклицательными знаками
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
|
||||
# Формируем клавиатуру с кнопками действий
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
# Кнопка активации или продления в зависимости от статуса
|
||||
if has_active_subscription:
|
||||
# Активная платная подписка - показываем продление и изменение устройств
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔄 ПРОДЛИТЬ ПОДПИСКУ',
|
||||
callback_data='subscription_extend',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='📱 Изменить количество устройств',
|
||||
callback_data='subscription_change_devices',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Нет подписки или истекла - показываем только активацию
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ',
|
||||
callback_data='menu_buy',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
else:
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.subscription_renewal_service import (
|
||||
@@ -157,8 +156,10 @@ class CryptoBotPaymentMixin:
|
||||
cryptobot_crud = import_module('app.database.crud.cryptobot')
|
||||
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
if not payment:
|
||||
logger.error('CryptoBot платеж не найден в БД: %s', invoice_id)
|
||||
return False
|
||||
logger.warning(
|
||||
'CryptoBot платеж не найден в БД: %s (возвращаем 200 чтобы остановить ретраи)', invoice_id
|
||||
)
|
||||
return True
|
||||
|
||||
if payment.status == 'paid':
|
||||
logger.info('CryptoBot платеж %s уже обработан', invoice_id)
|
||||
@@ -252,6 +253,7 @@ class CryptoBotPaymentMixin:
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(updated_payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await cryptobot_crud.link_cryptobot_payment_to_transaction(db, invoice_id, transaction.id)
|
||||
@@ -361,26 +363,7 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and bot_instance and not activation_notification_sent:
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.freekassa_service import freekassa_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -280,6 +279,7 @@ class FreekassaPaymentMixin:
|
||||
payment_method=PaymentMethod.FREEKASSA,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
@@ -388,23 +388,7 @@ class FreekassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -261,7 +260,10 @@ class HeleketPaymentMixin:
|
||||
invoice_message = metadata.get('invoice_message') or {}
|
||||
invoice_message_removed = False
|
||||
|
||||
if getattr(self, 'bot', None) and invoice_message:
|
||||
status_normalized = (status or '').lower()
|
||||
is_final = status_normalized in {'paid', 'paid_over', 'cancel', 'fail', 'system_fail', 'refund_paid'}
|
||||
|
||||
if getattr(self, 'bot', None) and invoice_message and is_final:
|
||||
chat_id = invoice_message.get('chat_id')
|
||||
message_id = invoice_message.get('message_id')
|
||||
if chat_id and message_id:
|
||||
@@ -301,7 +303,6 @@ class HeleketPaymentMixin:
|
||||
)
|
||||
return updated_payment
|
||||
|
||||
status_normalized = (status or '').lower()
|
||||
if status_normalized not in {'paid', 'paid_over'}:
|
||||
logger.info('Heleket платеж %s в статусе %s, зачисление не требуется', updated_payment.uuid, status)
|
||||
return updated_payment
|
||||
@@ -324,6 +325,7 @@ class HeleketPaymentMixin:
|
||||
payment_method=PaymentMethod.HELEKET,
|
||||
external_id=updated_payment.uuid,
|
||||
is_completed=True,
|
||||
created_at=getattr(updated_payment, 'created_at', None),
|
||||
)
|
||||
|
||||
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
|
||||
@@ -452,22 +454,6 @@ class HeleketPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с автоактивацией для пользователя %s: %s',
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.kassa_ai_service import kassa_ai_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -273,6 +272,7 @@ class KassaAiPaymentMixin:
|
||||
payment_method=PaymentMethod.KASSA_AI,
|
||||
external_id=str(intid) if intid else payment.order_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
@@ -339,34 +339,14 @@ class KassaAiPaymentMixin:
|
||||
try:
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Яркое сообщение для тупых
|
||||
from aiogram import types
|
||||
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', callback_data='menu_buy')],
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Стандартное сообщение (как было раньше)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
@@ -404,23 +384,7 @@ class KassaAiPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -76,7 +75,7 @@ class MulenPayPaymentMixin:
|
||||
uuid=payment_uuid,
|
||||
items=items,
|
||||
language=language or settings.MULENPAY_LANGUAGE,
|
||||
website_url=settings.WEBHOOK_URL,
|
||||
website_url=settings.MULENPAY_WEBSITE_URL or settings.WEBHOOK_URL,
|
||||
)
|
||||
|
||||
if not response:
|
||||
@@ -254,6 +253,7 @@ class MulenPayPaymentMixin:
|
||||
payment_method=PaymentMethod.MULENPAY,
|
||||
external_id=payment.uuid,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_mulenpay_payment_to_transaction(
|
||||
@@ -390,28 +390,7 @@ class MulenPayPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if (
|
||||
has_saved_cart
|
||||
and getattr(self, 'bot', None)
|
||||
and not activation_notification_sent
|
||||
and user.telegram_id
|
||||
):
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -386,6 +385,7 @@ class Pal24PaymentMixin:
|
||||
payment_method=PaymentMethod.PAL24,
|
||||
external_id=str(payment_id) if payment_id else payment.bill_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
|
||||
@@ -489,23 +489,7 @@ class Pal24PaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -367,6 +366,7 @@ class PlategaPaymentMixin:
|
||||
payment_method=PaymentMethod.PLATEGA,
|
||||
external_id=transaction_external_id or payment.correlation_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
created_transaction = True
|
||||
|
||||
@@ -470,23 +470,7 @@ class PlategaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -534,26 +533,7 @@ class TelegramStarsMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
|
||||
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
@@ -473,6 +472,7 @@ class WataPaymentMixin:
|
||||
payment_method=PaymentMethod.WATA,
|
||||
external_id=transaction_external_id or payment.payment_link_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
|
||||
@@ -575,23 +575,7 @@ class WataPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили
|
||||
if has_saved_cart and getattr(self, 'bot', None) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -398,6 +397,21 @@ class YooKassaPaymentMixin:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import YooKassaPayment as YKPayment
|
||||
|
||||
# Lock the payment row to prevent concurrent double-processing
|
||||
locked_result = await db.execute(select(YKPayment).where(YKPayment.id == payment.id).with_for_update())
|
||||
payment = locked_result.scalar_one()
|
||||
|
||||
# Fast-path: already processed
|
||||
if getattr(payment, 'transaction_id', None):
|
||||
logger.info(
|
||||
'Платеж YooKassa %s уже обработан (transaction_id=%s), пропускаем.',
|
||||
payment.yookassa_payment_id,
|
||||
payment.transaction_id,
|
||||
)
|
||||
return True
|
||||
|
||||
payment_module = import_module('app.services.payment_service')
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
@@ -590,6 +604,7 @@ class YooKassaPaymentMixin:
|
||||
payment_method=PaymentMethod.YOOKASSA,
|
||||
external_id=payment.yookassa_payment_id,
|
||||
is_completed=True,
|
||||
created_at=getattr(payment, 'created_at', None),
|
||||
)
|
||||
|
||||
if not getattr(payment, 'transaction_id', None):
|
||||
@@ -847,78 +862,59 @@ class YooKassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
# Если включен яркий промпт активации, пропускаем старое уведомление
|
||||
# т.к. оно будет отправлено через _send_payment_success_notification
|
||||
if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏠 Главное меню',
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏠 Главное меню',
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n'
|
||||
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n'
|
||||
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}',
|
||||
|
||||
@@ -189,6 +189,9 @@ async def broadcast_pinned_message(
|
||||
)
|
||||
failed_count += 1
|
||||
break
|
||||
else:
|
||||
# All retry attempts exhausted (TelegramRetryAfter on every attempt)
|
||||
failed_count += 1
|
||||
|
||||
for i in range(0, len(recipient_telegram_ids), 30):
|
||||
batch = recipient_telegram_ids[i : i + 30]
|
||||
@@ -251,23 +254,6 @@ async def unpin_active_pinned_message(
|
||||
unpinned_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
except TelegramRetryAfter as retry_error:
|
||||
delay = min(retry_error.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
'RetryAfter while unpinning for user %s, waiting %s seconds',
|
||||
telegram_id,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
# Повторная попытка после ожидания
|
||||
try:
|
||||
success = await _unpin_message_for_user(bot, telegram_id)
|
||||
if success:
|
||||
unpinned_count += 1
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка открепления сообщения у пользователя %s: %s',
|
||||
@@ -311,6 +297,12 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
|
||||
pass
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
except TelegramRetryAfter as e:
|
||||
await asyncio.sleep(min(e.retry_after + 1, 30))
|
||||
try:
|
||||
await bot.unpin_all_chat_messages(chat_id=chat_id)
|
||||
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
|
||||
pass
|
||||
|
||||
try:
|
||||
if pinned_message.media_type == 'photo' and pinned_message.media_file_id:
|
||||
@@ -345,6 +337,9 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
except TelegramRetryAfter as e:
|
||||
await asyncio.sleep(min(e.retry_after + 1, 30))
|
||||
raise # Propagate to caller's retry loop
|
||||
except TelegramBadRequest as error:
|
||||
logger.warning(
|
||||
'Некорректный запрос при отправке закрепленного сообщения в чат %s: %s',
|
||||
@@ -361,18 +356,38 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
|
||||
return False
|
||||
|
||||
|
||||
async def _unpin_message_for_user(bot: Bot, chat_id: int) -> bool:
|
||||
try:
|
||||
await bot.unpin_all_chat_messages(chat_id=chat_id)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
except TelegramBadRequest:
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Не удалось открепить сообщение у пользователя %s: %s',
|
||||
chat_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
async def _unpin_message_for_user(bot: Bot, chat_id: int, max_retries: int = 3) -> bool:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await bot.unpin_all_chat_messages(chat_id=chat_id)
|
||||
return True
|
||||
except TelegramRetryAfter as e:
|
||||
if attempt < max_retries - 1:
|
||||
delay = min(e.retry_after + 1, 30)
|
||||
logger.warning(
|
||||
'RetryAfter при откреплении для %s, ожидание %s сек (попытка %d/%d)',
|
||||
chat_id,
|
||||
delay,
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.warning(
|
||||
'Не удалось открепить сообщение у %s после %d попыток (flood control)',
|
||||
chat_id,
|
||||
max_retries,
|
||||
)
|
||||
return False
|
||||
except TelegramForbiddenError:
|
||||
return False
|
||||
except TelegramBadRequest:
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Не удалось открепить сообщение у пользователя %s: %s',
|
||||
chat_id,
|
||||
error,
|
||||
)
|
||||
return False
|
||||
return False
|
||||
|
||||
@@ -54,6 +54,18 @@ class PromoCodeService:
|
||||
if existing_use:
|
||||
return {'success': False, 'error': 'already_used_by_user'}
|
||||
|
||||
# Лимит на количество активаций за день (анти-стакинг)
|
||||
from app.database.crud.promocode import count_user_recent_activations
|
||||
|
||||
recent_count = await count_user_recent_activations(db, user_id, hours=24)
|
||||
if recent_count >= 5:
|
||||
logger.warning(
|
||||
'Promo stacking limit: user %s has %d activations in 24h',
|
||||
self._format_user_log(user),
|
||||
recent_count,
|
||||
)
|
||||
return {'success': False, 'error': 'daily_limit'}
|
||||
|
||||
# Проверка "только для первой покупки"
|
||||
if getattr(promocode, 'first_purchase_only', False):
|
||||
if getattr(user, 'has_had_paid_subscription', False):
|
||||
|
||||
@@ -1070,22 +1070,15 @@ class RemnaWaveService:
|
||||
)
|
||||
|
||||
if updated_subscriptions:
|
||||
# Update in consistent ID order to prevent deadlocks
|
||||
counter_updates = {}
|
||||
if source_decrement:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == source_server.id)
|
||||
.values(
|
||||
current_users=func.greatest(
|
||||
ServerSquad.current_users - source_decrement,
|
||||
0,
|
||||
)
|
||||
)
|
||||
)
|
||||
counter_updates[source_server.id] = func.greatest(ServerSquad.current_users - source_decrement, 0)
|
||||
if target_increment:
|
||||
counter_updates[target_server.id] = ServerSquad.current_users + target_increment
|
||||
for sid in sorted(counter_updates):
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == target_server.id)
|
||||
.values(current_users=ServerSquad.current_users + target_increment)
|
||||
update(ServerSquad).where(ServerSquad.id == sid).values(current_users=counter_updates[sid])
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
@@ -1335,9 +1328,18 @@ class RemnaWaveService:
|
||||
pending_uuid_mutations.clear()
|
||||
try:
|
||||
await db.rollback() # Выполняем rollback при ошибке
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# After rollback all ORM objects in the session are expired.
|
||||
# Accessing their attributes triggers a lazy load which fails
|
||||
# in async context (greenlet_spawn error). Break the loop to
|
||||
# prevent cascading failures for every remaining user.
|
||||
logger.warning(
|
||||
'⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)',
|
||||
i + 1,
|
||||
len(unique_panel_users),
|
||||
)
|
||||
break
|
||||
|
||||
else:
|
||||
if uuid_mutation and uuid_mutation.has_changes():
|
||||
@@ -1454,10 +1456,20 @@ class RemnaWaveService:
|
||||
for telegram_id, db_user in users_to_deactivate:
|
||||
cleanup_mutation: _UUIDMapMutation | None = None
|
||||
try:
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
subscription = db_user.subscription
|
||||
|
||||
# Skip if recently updated by webhook
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
if subscription and is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
|
||||
|
||||
if db_user.remnawave_uuid and hwid_api_client:
|
||||
try:
|
||||
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
|
||||
@@ -1664,7 +1676,7 @@ class RemnaWaveService:
|
||||
|
||||
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, is_recently_updated_by_webhook
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Всегда используем async CRUD запрос для получения подписки,
|
||||
@@ -1675,6 +1687,14 @@ class RemnaWaveService:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
return
|
||||
|
||||
# Skip if recently updated by webhook (prevent stale data overwrite)
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск синхронизации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
return
|
||||
|
||||
panel_status = panel_user.get('status', 'ACTIVE')
|
||||
expire_at_str = panel_user.get('expireAt', '')
|
||||
|
||||
@@ -1682,38 +1702,47 @@ class RemnaWaveService:
|
||||
# expire_at приходит в UTC (naive) из _parse_remnawave_date
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
# subscription.end_date хранится в локальной таймзоне (MSK)
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
# Обновляем end_date только если пользователь ACTIVE в панели.
|
||||
# Для EXPIRED/DISABLED панель может содержать искусственную дату
|
||||
# (установленную _safe_expire_at_for_panel при sync_users_to_panel),
|
||||
# которая не должна перезаписывать реальную дату окончания подписки.
|
||||
if panel_status == 'ACTIVE':
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
)
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
f'панель не ACTIVE (статус: {panel_status})'
|
||||
)
|
||||
|
||||
current_time = self._now_utc()
|
||||
@@ -1861,6 +1890,8 @@ class RemnaWaveService:
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
create_kwargs = dict(
|
||||
@@ -1895,6 +1926,15 @@ class RemnaWaveService:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
logger.debug(f'Найден пользователь {user.telegram_id} в панели: {panel_uuid}')
|
||||
|
||||
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
logger.debug(
|
||||
f'Найден пользователь {user.email} в панели по email: {panel_uuid}'
|
||||
)
|
||||
|
||||
if panel_uuid:
|
||||
update_kwargs = dict(
|
||||
uuid=panel_uuid,
|
||||
@@ -2479,12 +2519,20 @@ class RemnaWaveService:
|
||||
await self._update_subscription_from_panel_data(db, user, panel_user)
|
||||
stats['updated'] += 1
|
||||
elif subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
is_recently_updated_by_webhook,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
|
||||
subscription.id,
|
||||
)
|
||||
else:
|
||||
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
|
||||
await deactivate_subscription(db, subscription)
|
||||
stats['updated'] += 1
|
||||
|
||||
except Exception as sub_error:
|
||||
logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}')
|
||||
@@ -2527,6 +2575,8 @@ class RemnaWaveService:
|
||||
user = subscription.user
|
||||
issues_fixed = 0
|
||||
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
|
||||
current_time = self._now_utc()
|
||||
# Конвертируем end_date в UTC для корректного сравнения
|
||||
end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
@@ -2535,6 +2585,7 @@ class RemnaWaveService:
|
||||
if (
|
||||
end_date_utc + expiry_buffer <= current_time
|
||||
and subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and not is_recently_updated_by_webhook(subscription)
|
||||
):
|
||||
time_since_expiry = current_time - end_date_utc
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
"""
|
||||
Service for processing incoming RemnaWave backend webhooks.
|
||||
|
||||
Handles all webhook scopes: user, user_hwid_devices, node, service, crm.
|
||||
User events update subscription state and notify the user.
|
||||
Admin events (node, service, crm) send alerts to the admin notification chat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import PendingRollbackError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
decrement_subscription_server_counts,
|
||||
expire_subscription,
|
||||
get_subscription_by_user_id,
|
||||
reactivate_subscription,
|
||||
update_subscription_usage,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id, get_user_by_remnawave_uuid, get_user_by_telegram_id
|
||||
from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.notification_delivery_service import NotificationType, notification_delivery_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping from locale text_key to NotificationType for unified delivery
|
||||
_TEXT_KEY_TO_NOTIFICATION_TYPE: dict[str, NotificationType] = {
|
||||
'WEBHOOK_SUB_EXPIRED': NotificationType.WEBHOOK_SUB_EXPIRED,
|
||||
'WEBHOOK_SUB_DISABLED': NotificationType.WEBHOOK_SUB_DISABLED,
|
||||
'WEBHOOK_SUB_ENABLED': NotificationType.WEBHOOK_SUB_ENABLED,
|
||||
'WEBHOOK_SUB_LIMITED': NotificationType.WEBHOOK_SUB_LIMITED,
|
||||
'WEBHOOK_SUB_TRAFFIC_RESET': NotificationType.WEBHOOK_SUB_TRAFFIC_RESET,
|
||||
'WEBHOOK_SUB_DELETED': NotificationType.WEBHOOK_SUB_DELETED,
|
||||
'WEBHOOK_SUB_REVOKED': NotificationType.WEBHOOK_SUB_REVOKED,
|
||||
'WEBHOOK_SUB_EXPIRES_72H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRES_48H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRES_24H': NotificationType.WEBHOOK_SUB_EXPIRING,
|
||||
'WEBHOOK_SUB_EXPIRED_24H_AGO': NotificationType.WEBHOOK_SUB_EXPIRED,
|
||||
'WEBHOOK_SUB_FIRST_CONNECTED': NotificationType.WEBHOOK_SUB_FIRST_CONNECTED,
|
||||
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD': NotificationType.WEBHOOK_SUB_BANDWIDTH_THRESHOLD,
|
||||
'WEBHOOK_USER_NOT_CONNECTED': NotificationType.WEBHOOK_USER_NOT_CONNECTED,
|
||||
'WEBHOOK_DEVICE_ADDED': NotificationType.WEBHOOK_DEVICE_ADDED,
|
||||
'WEBHOOK_DEVICE_DELETED': NotificationType.WEBHOOK_DEVICE_DELETED,
|
||||
}
|
||||
|
||||
# Mapping from locale text_key to the Settings toggle that controls it
|
||||
_TEXT_KEY_TO_SETTING: dict[str, str] = {
|
||||
'WEBHOOK_SUB_EXPIRED': 'WEBHOOK_NOTIFY_SUB_EXPIRED',
|
||||
'WEBHOOK_SUB_DISABLED': 'WEBHOOK_NOTIFY_SUB_STATUS',
|
||||
'WEBHOOK_SUB_ENABLED': 'WEBHOOK_NOTIFY_SUB_STATUS',
|
||||
'WEBHOOK_SUB_LIMITED': 'WEBHOOK_NOTIFY_SUB_LIMITED',
|
||||
'WEBHOOK_SUB_TRAFFIC_RESET': 'WEBHOOK_NOTIFY_TRAFFIC_RESET',
|
||||
'WEBHOOK_SUB_DELETED': 'WEBHOOK_NOTIFY_SUB_DELETED',
|
||||
'WEBHOOK_SUB_REVOKED': 'WEBHOOK_NOTIFY_SUB_REVOKED',
|
||||
'WEBHOOK_SUB_EXPIRES_72H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
|
||||
'WEBHOOK_SUB_EXPIRES_48H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
|
||||
'WEBHOOK_SUB_EXPIRES_24H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
|
||||
'WEBHOOK_SUB_EXPIRED_24H_AGO': 'WEBHOOK_NOTIFY_SUB_EXPIRED',
|
||||
'WEBHOOK_SUB_FIRST_CONNECTED': 'WEBHOOK_NOTIFY_FIRST_CONNECTED',
|
||||
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD': 'WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD',
|
||||
'WEBHOOK_USER_NOT_CONNECTED': 'WEBHOOK_NOTIFY_NOT_CONNECTED',
|
||||
'WEBHOOK_DEVICE_ADDED': 'WEBHOOK_NOTIFY_DEVICES',
|
||||
'WEBHOOK_DEVICE_DELETED': 'WEBHOOK_NOTIFY_DEVICES',
|
||||
}
|
||||
|
||||
# Admin event display names for notification messages
|
||||
_ADMIN_NODE_EVENTS: dict[str, str] = {
|
||||
'node.created': '🟢 Нода создана',
|
||||
'node.modified': '🔧 Нода изменена',
|
||||
'node.disabled': '🔴 Нода отключена',
|
||||
'node.enabled': '🟢 Нода включена',
|
||||
'node.deleted': '🗑️ Нода удалена',
|
||||
'node.connection_lost': '🚨 Потеряно соединение с нодой',
|
||||
'node.connection_restored': '✅ Соединение с нодой восстановлено',
|
||||
'node.traffic_notify': '📊 Уведомление о трафике ноды',
|
||||
}
|
||||
|
||||
_ADMIN_SERVICE_EVENTS: dict[str, str] = {
|
||||
'service.panel_started': '🚀 Панель RemnaWave запущена',
|
||||
'service.login_attempt_failed': '🔐 Неудачная попытка входа в панель',
|
||||
'service.login_attempt_success': '🔓 Успешный вход в панель',
|
||||
'service.subpage_config_changed': '📄 Конфиг страницы подписки изменён',
|
||||
}
|
||||
|
||||
_ADMIN_CRM_EVENTS: dict[str, str] = {
|
||||
'crm.infra_billing_node_payment_in_7_days': '💳 Оплата ноды через 7 дней',
|
||||
'crm.infra_billing_node_payment_in_48hrs': '💳 Оплата ноды через 48 часов',
|
||||
'crm.infra_billing_node_payment_in_24hrs': '⚠️ Оплата ноды через 24 часа',
|
||||
'crm.infra_billing_node_payment_due_today': '🔴 Оплата ноды сегодня',
|
||||
'crm.infra_billing_node_payment_overdue_24hrs': '❗ Просрочка оплаты ноды: 24 часа',
|
||||
'crm.infra_billing_node_payment_overdue_48hrs': '❗ Просрочка оплаты ноды: 48 часов',
|
||||
'crm.infra_billing_node_payment_overdue_7_days': '🚨 Просрочка оплаты ноды: 7 дней',
|
||||
}
|
||||
|
||||
_ADMIN_ERROR_EVENTS: dict[str, str] = {
|
||||
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
|
||||
}
|
||||
|
||||
|
||||
class RemnaWaveWebhookService:
|
||||
"""Processes incoming webhooks from RemnaWave backend."""
|
||||
|
||||
def __init__(self, bot: Bot) -> None:
|
||||
self.bot = bot
|
||||
self._admin_service = AdminNotificationService(bot)
|
||||
|
||||
# User-scoped handlers: require user resolution
|
||||
self._user_handlers: dict[str, Any] = {
|
||||
'user.expired': self._handle_user_expired,
|
||||
'user.disabled': self._handle_user_disabled,
|
||||
'user.enabled': self._handle_user_enabled,
|
||||
'user.limited': self._handle_user_limited,
|
||||
'user.traffic_reset': self._handle_user_traffic_reset,
|
||||
'user.modified': self._handle_user_modified,
|
||||
'user.deleted': self._handle_user_deleted,
|
||||
'user.revoked': self._handle_user_revoked,
|
||||
'user.created': self._handle_user_created,
|
||||
'user.expires_in_72_hours': self._handle_expires_in_72h,
|
||||
'user.expires_in_48_hours': self._handle_expires_in_48h,
|
||||
'user.expires_in_24_hours': self._handle_expires_in_24h,
|
||||
'user.expired_24_hours_ago': self._handle_expired_24h_ago,
|
||||
'user.first_connected': self._handle_first_connected,
|
||||
'user.bandwidth_usage_threshold_reached': self._handle_bandwidth_threshold,
|
||||
'user.not_connected': self._handle_user_not_connected,
|
||||
'user_hwid_devices.added': self._handle_device_added,
|
||||
'user_hwid_devices.deleted': self._handle_device_deleted,
|
||||
}
|
||||
|
||||
# Admin-scoped handlers: no user resolution, notify admin chat
|
||||
self._admin_handlers: dict[str, str] = {
|
||||
**_ADMIN_NODE_EVENTS,
|
||||
**_ADMIN_SERVICE_EVENTS,
|
||||
**_ADMIN_CRM_EVENTS,
|
||||
**_ADMIN_ERROR_EVENTS,
|
||||
}
|
||||
|
||||
def is_admin_event(self, event_name: str) -> bool:
|
||||
"""Check if the event is admin-scoped (no DB session needed)."""
|
||||
return event_name in self._admin_handlers
|
||||
|
||||
async def process_event(self, db: AsyncSession | None, event_name: str, data: dict) -> bool:
|
||||
"""Route event to the appropriate handler.
|
||||
|
||||
Returns True if the event was processed, False if skipped/unknown.
|
||||
db may be None for admin events that don't require database access.
|
||||
"""
|
||||
# Check admin-scoped handlers (no DB needed)
|
||||
if event_name in self._admin_handlers:
|
||||
return await self._process_admin_event(event_name, data)
|
||||
|
||||
# Check user-scoped handlers (require DB session)
|
||||
user_handler = self._user_handlers.get(event_name)
|
||||
if user_handler:
|
||||
if db is None:
|
||||
logger.error('RemnaWave webhook: DB session required for user event %s', event_name)
|
||||
return False
|
||||
return await self._process_user_event(db, event_name, data, user_handler)
|
||||
|
||||
logger.debug('Unhandled RemnaWave webhook event: %s', event_name)
|
||||
return False
|
||||
|
||||
async def _process_user_event(self, db: AsyncSession, event_name: str, data: dict, handler: Any) -> bool:
|
||||
"""Resolve user and execute user-scoped handler."""
|
||||
user, subscription = await self._resolve_user_and_subscription(db, data)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'RemnaWave webhook: user not found for event %s, data telegramId=%s uuid=%s',
|
||||
event_name,
|
||||
data.get('telegramId'),
|
||||
data.get('uuid'),
|
||||
)
|
||||
return False
|
||||
|
||||
user_id = user.id
|
||||
try:
|
||||
await handler(db, user, subscription, data)
|
||||
return True
|
||||
except (StaleDataError, PendingRollbackError):
|
||||
logger.warning(
|
||||
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
|
||||
event_name,
|
||||
user_id,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
logger.debug('Rollback after webhook handler error also failed')
|
||||
return False
|
||||
|
||||
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
|
||||
"""Format and send admin notification for infrastructure events."""
|
||||
if not self._admin_service.is_enabled:
|
||||
logger.debug('Admin notifications disabled, skipping event %s', event_name)
|
||||
return True
|
||||
|
||||
title = self._admin_handlers.get(event_name, event_name)
|
||||
|
||||
# Build message from event data (escape all untrusted values to prevent HTML injection)
|
||||
lines = [f'<b>{title}</b>']
|
||||
|
||||
# Extract common fields
|
||||
name = html.escape(data.get('name') or data.get('nodeName') or data.get('username') or '')
|
||||
if name:
|
||||
lines.append(f'Имя: <code>{name}</code>')
|
||||
|
||||
address = html.escape(data.get('address') or data.get('ip') or '')
|
||||
if address:
|
||||
lines.append(f'Адрес: <code>{address}</code>')
|
||||
|
||||
port = data.get('port')
|
||||
if port:
|
||||
lines.append(f'Порт: <code>{html.escape(str(port))}</code>')
|
||||
|
||||
version = html.escape(data.get('version') or data.get('panelVersion') or '')
|
||||
if version:
|
||||
lines.append(f'Версия: <code>{version}</code>')
|
||||
|
||||
# CRM billing fields
|
||||
amount = html.escape(str(data.get('amount') or data.get('price') or ''))
|
||||
if amount:
|
||||
lines.append(f'Сумма: <code>{amount}</code>')
|
||||
|
||||
due_date = html.escape(data.get('dueDate') or data.get('paymentDate') or '')
|
||||
if due_date:
|
||||
lines.append(f'Дата: <code>{due_date}</code>')
|
||||
|
||||
# Login attempt fields
|
||||
ip_addr = html.escape(data.get('ipAddress') or data.get('ip') or '')
|
||||
if ip_addr and not address:
|
||||
lines.append(f'IP: <code>{ip_addr}</code>')
|
||||
|
||||
message = html.escape(data.get('message') or '')
|
||||
if message:
|
||||
lines.append(f'Сообщение: {message}')
|
||||
|
||||
# Subpage config fields
|
||||
subpage = data.get('subpageConfig')
|
||||
if isinstance(subpage, dict):
|
||||
action = subpage.get('action', '')
|
||||
action_labels = {'CREATED': 'Создан', 'UPDATED': 'Обновлён', 'DELETED': 'Удалён'}
|
||||
lines.append(f'Действие: {action_labels.get(action, html.escape(str(action)))}')
|
||||
sub_uuid = subpage.get('uuid', '')
|
||||
if sub_uuid:
|
||||
lines.append(f'UUID: <code>{html.escape(str(sub_uuid))}</code>')
|
||||
|
||||
try:
|
||||
await self._admin_service.send_webhook_notification('\n'.join(lines))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception('Failed to send admin notification for event %s', event_name)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _resolve_user_and_subscription(
|
||||
self, db: AsyncSession, data: dict
|
||||
) -> tuple[User | None, Subscription | None]:
|
||||
"""Find bot user by telegramId or uuid from webhook payload.
|
||||
|
||||
Handles both user-scope events (top-level telegramId/uuid) and
|
||||
device-scope events (userUuid, or nested user.telegramId/user.uuid).
|
||||
"""
|
||||
user: User | None = None
|
||||
|
||||
# Try top-level telegramId first
|
||||
telegram_id = data.get('telegramId')
|
||||
if telegram_id:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(telegram_id))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Try top-level uuid
|
||||
if not user:
|
||||
uuid = data.get('uuid') or data.get('userUuid')
|
||||
if uuid:
|
||||
user = await get_user_by_remnawave_uuid(db, uuid)
|
||||
|
||||
# Try nested user object (e.g. user_hwid_devices events)
|
||||
if not user:
|
||||
nested_user = data.get('user')
|
||||
if isinstance(nested_user, dict):
|
||||
nested_tid = nested_user.get('telegramId')
|
||||
if nested_tid:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, int(nested_tid))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if not user:
|
||||
nested_uuid = nested_user.get('uuid')
|
||||
if nested_uuid:
|
||||
user = await get_user_by_remnawave_uuid(db, nested_uuid)
|
||||
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
return user, subscription
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notification helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_url(value: str) -> bool:
|
||||
"""Basic URL validation to prevent stored XSS via crafted URLs."""
|
||||
if not value or len(value) > 2048:
|
||||
return False
|
||||
return bool(re.match(r'^https?://', value))
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_link(value: str) -> bool:
|
||||
"""Validate URL or deep link (happ://, vless://, ss://, etc.)."""
|
||||
if not value or len(value) > 4096:
|
||||
return False
|
||||
return bool(re.match(r'^[a-zA-Z][a-zA-Z0-9+\-.]*://', value))
|
||||
|
||||
def _get_renew_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('WEBHOOK_RENEW_BUTTON', 'Renew subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_extend')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_subscription_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='menu_subscription')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_connect_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
button_text = texts.get('CONNECT_BUTTON', 'Connect')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_connect')],
|
||||
]
|
||||
)
|
||||
|
||||
def _get_traffic_keyboard(self, user: User) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language)
|
||||
buy_text = texts.get('BUY_TRAFFIC_BUTTON', 'Buy traffic')
|
||||
sub_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[build_miniapp_or_callback_button(text=buy_text, callback_data='buy_traffic')],
|
||||
[build_miniapp_or_callback_button(text=sub_text, callback_data='menu_subscription')],
|
||||
]
|
||||
)
|
||||
|
||||
async def _notify_user(
|
||||
self,
|
||||
user: User,
|
||||
text_key: str,
|
||||
*,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
format_kwargs: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Send a notification to user via appropriate channel.
|
||||
|
||||
Telegram users receive a bot message; email-only users receive
|
||||
an email and/or WebSocket notification through the unified
|
||||
notification delivery service.
|
||||
|
||||
Respects WEBHOOK_NOTIFY_USER_ENABLED master toggle and
|
||||
per-event toggles from Settings.
|
||||
"""
|
||||
if not settings.WEBHOOK_NOTIFY_USER_ENABLED:
|
||||
logger.debug('Webhook user notifications disabled globally, skipping %s', text_key)
|
||||
return
|
||||
|
||||
setting_key = _TEXT_KEY_TO_SETTING.get(text_key)
|
||||
if setting_key and not getattr(settings, setting_key, True):
|
||||
logger.debug('Webhook notification %s disabled via %s', text_key, setting_key)
|
||||
return
|
||||
|
||||
texts = get_texts(user.language)
|
||||
message = texts.get(text_key)
|
||||
if not message:
|
||||
logger.warning('Missing locale key %s for language %s', text_key, user.language)
|
||||
return
|
||||
|
||||
if format_kwargs:
|
||||
try:
|
||||
message = message.format(**format_kwargs)
|
||||
except (KeyError, IndexError):
|
||||
logger.warning('Failed to format message %s with kwargs %s', text_key, format_kwargs)
|
||||
return
|
||||
|
||||
# Append "Close" button to every webhook notification keyboard
|
||||
close_text = texts.get('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть')
|
||||
close_row = [InlineKeyboardButton(text=close_text, callback_data='webhook:close')]
|
||||
if reply_markup:
|
||||
reply_markup = InlineKeyboardMarkup(
|
||||
inline_keyboard=[*reply_markup.inline_keyboard, close_row],
|
||||
)
|
||||
else:
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=[close_row])
|
||||
|
||||
notification_type = _TEXT_KEY_TO_NOTIFICATION_TYPE.get(text_key)
|
||||
if not notification_type:
|
||||
logger.warning('No NotificationType mapping for text_key %s', text_key)
|
||||
return
|
||||
|
||||
context = {'text_key': text_key, **(format_kwargs or {})}
|
||||
|
||||
try:
|
||||
await notification_delivery_service.send_notification(
|
||||
user=user,
|
||||
notification_type=notification_type,
|
||||
context=context,
|
||||
bot=self.bot,
|
||||
telegram_message=message,
|
||||
telegram_markup=reply_markup,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Notification delivery failed for user %s, text_key %s', user.id, text_key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Webhook timestamp helper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _stamp_webhook_update(subscription: Subscription) -> None:
|
||||
"""Mark subscription as recently updated by webhook to prevent sync overwrite."""
|
||||
subscription.last_webhook_update_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User event handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_user_expired(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
await expire_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_user_disabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DISABLED', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_user_enabled(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_ENABLED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
async def _handle_user_limited(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value:
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_LIMITED', reply_markup=self._get_traffic_keyboard(user))
|
||||
|
||||
async def _handle_user_traffic_reset(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
await update_subscription_usage(db, subscription, 0.0)
|
||||
# Re-enable if was disabled due to traffic limit
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Webhook: traffic reset for subscription %s, user %s', subscription.id, user.id)
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_TRAFFIC_RESET', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_user_modified(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
"""Sync subscription fields from webhook payload without notifying user."""
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
changed = False
|
||||
|
||||
# Sync traffic limit
|
||||
traffic_limit_bytes = data.get('trafficLimitBytes')
|
||||
if traffic_limit_bytes is not None:
|
||||
try:
|
||||
new_limit_gb = int(traffic_limit_bytes) // (1024**3)
|
||||
if subscription.traffic_limit_gb != new_limit_gb:
|
||||
subscription.traffic_limit_gb = new_limit_gb
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync used traffic
|
||||
used_traffic_bytes = data.get('usedTrafficBytes')
|
||||
if used_traffic_bytes is not None:
|
||||
try:
|
||||
new_used_gb = round(int(used_traffic_bytes) / (1024**3), 2)
|
||||
subscription.traffic_used_gb = new_used_gb
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync expire date
|
||||
expire_at = data.get('expireAt')
|
||||
if expire_at:
|
||||
try:
|
||||
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
|
||||
new_end_date = parsed_dt.astimezone(UTC).replace(tzinfo=None)
|
||||
if subscription.end_date != new_end_date:
|
||||
subscription.end_date = new_end_date
|
||||
changed = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Sync status from panel
|
||||
panel_status = data.get('status')
|
||||
if panel_status:
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
end_date = subscription.end_date
|
||||
if panel_status == 'ACTIVE' and end_date and end_date > now:
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
changed = True
|
||||
logger.info(
|
||||
'Webhook: subscription %s reactivated (%s → active) for user %s',
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
user.id,
|
||||
)
|
||||
elif panel_status == 'DISABLED':
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
subscription.status = SubscriptionStatus.DISABLED.value
|
||||
changed = True
|
||||
|
||||
# Sync subscription URL (validate to prevent stored XSS)
|
||||
subscription_url = data.get('subscriptionUrl')
|
||||
if (
|
||||
subscription_url
|
||||
and self._is_valid_url(subscription_url)
|
||||
and subscription.subscription_url != subscription_url
|
||||
):
|
||||
subscription.subscription_url = subscription_url
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite, even if no fields changed
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info('Webhook: subscription %s modified (synced from panel) for user %s', subscription.id, user.id)
|
||||
await db.commit()
|
||||
|
||||
async def _handle_user_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
user_id = user.id
|
||||
sub_id = subscription.id if subscription else None
|
||||
|
||||
if subscription:
|
||||
self._stamp_webhook_update(subscription)
|
||||
|
||||
# Decrement server counters BEFORE clearing connected_squads
|
||||
await decrement_subscription_server_counts(db, subscription)
|
||||
|
||||
# Re-fetch after potential rollback inside decrement_subscription_server_counts
|
||||
try:
|
||||
await db.refresh(subscription)
|
||||
except Exception:
|
||||
# Subscription was cascade-deleted, re-fetch user and skip subscription updates
|
||||
logger.warning(
|
||||
'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup',
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
subscription = None
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
except Exception:
|
||||
logger.error('Webhook: user %s not found after rollback', user_id)
|
||||
return
|
||||
if not user:
|
||||
logger.error('Webhook: user %s not found after rollback', user_id)
|
||||
return
|
||||
|
||||
if subscription:
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
logger.info(
|
||||
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
|
||||
sub_id,
|
||||
user_id,
|
||||
)
|
||||
|
||||
# Clear subscription data — panel user no longer exists
|
||||
subscription.subscription_url = None
|
||||
subscription.subscription_crypto_link = None
|
||||
subscription.remnawave_short_uuid = None
|
||||
subscription.connected_squads = None
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
# Remove SubscriptionServer link rows
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
|
||||
|
||||
# Clear remnawave linkage
|
||||
if user.remnawave_uuid:
|
||||
user.remnawave_uuid = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_user_revoked(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
if subscription:
|
||||
new_url = data.get('subscriptionUrl')
|
||||
new_crypto_link = data.get('subscriptionCryptoLink')
|
||||
changed = False
|
||||
|
||||
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
|
||||
subscription.subscription_url = new_url
|
||||
changed = True
|
||||
if (
|
||||
new_crypto_link
|
||||
and self._is_valid_link(new_crypto_link)
|
||||
and subscription.subscription_crypto_link != new_crypto_link
|
||||
):
|
||||
subscription.subscription_crypto_link = new_crypto_link
|
||||
changed = True
|
||||
|
||||
# Always stamp to protect from sync overwrite
|
||||
self._stamp_webhook_update(subscription)
|
||||
if changed:
|
||||
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info(
|
||||
'Webhook: subscription %s credentials revoked/updated for user %s', subscription.id, user.id
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_REVOKED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
async def _handle_user_created(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s created externally in panel (uuid=%s)', user.id, data.get('uuid'))
|
||||
|
||||
async def _handle_expires_in_72h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_72H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expires_in_48h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_48H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expires_in_24h(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_24H', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_expired_24h_ago(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED_24H_AGO', reply_markup=self._get_renew_keyboard(user))
|
||||
|
||||
async def _handle_first_connected(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s first VPN connection', user.id)
|
||||
await self._notify_user(user, 'WEBHOOK_SUB_FIRST_CONNECTED', reply_markup=self._get_subscription_keyboard(user))
|
||||
|
||||
async def _handle_bandwidth_threshold(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
# Extract threshold percentage from meta or data
|
||||
percent = data.get('thresholdPercent') or data.get('threshold', '')
|
||||
if not percent:
|
||||
# Try to extract from meta
|
||||
meta = data.get('meta', {})
|
||||
if isinstance(meta, dict):
|
||||
percent = meta.get('thresholdPercent', '80')
|
||||
|
||||
# Sanitize to numeric value only (prevent format string injection)
|
||||
percent_str = re.sub(r'[^\d.]', '', str(percent)) or '80'
|
||||
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD',
|
||||
reply_markup=self._get_traffic_keyboard(user),
|
||||
format_kwargs={'percent': percent_str},
|
||||
)
|
||||
|
||||
async def _handle_user_not_connected(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
logger.info('Webhook: user %s has not connected to VPN', user.id)
|
||||
await self._notify_user(user, 'WEBHOOK_USER_NOT_CONNECTED', reply_markup=self._get_connect_keyboard(user))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Device event handlers (user_hwid_devices scope)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _extract_device_name(data: dict) -> str:
|
||||
"""Extract device name from webhook payload.
|
||||
|
||||
RemnaWave sends device info in data['hwidUserDevice'] nested object.
|
||||
Builds a composite name: "tag (platform)" or just "platform" or hwid short.
|
||||
"""
|
||||
device_obj = data.get('hwidUserDevice')
|
||||
if not isinstance(device_obj, dict):
|
||||
# Fallback: top-level fields
|
||||
raw = data.get('deviceName') or data.get('tag') or data.get('hwid') or ''
|
||||
return html.escape(str(raw)) if raw else ''
|
||||
|
||||
tag = (device_obj.get('tag') or device_obj.get('deviceName') or device_obj.get('name') or '').strip()
|
||||
platform = (device_obj.get('platform') or '').strip()
|
||||
hwid = (device_obj.get('hwid') or '').strip()
|
||||
|
||||
if tag and platform:
|
||||
return html.escape(f'{tag} ({platform})')
|
||||
if tag:
|
||||
return html.escape(tag)
|
||||
if platform and hwid:
|
||||
# Show platform + short hwid suffix for identification
|
||||
hwid_short = hwid[:8] if len(hwid) > 8 else hwid
|
||||
return html.escape(f'{platform} ({hwid_short})')
|
||||
if platform:
|
||||
return html.escape(platform)
|
||||
if hwid:
|
||||
hwid_short = hwid[:12] if len(hwid) > 12 else hwid
|
||||
return html.escape(hwid_short)
|
||||
return ''
|
||||
|
||||
async def _handle_device_added(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
device_name = self._extract_device_name(data)
|
||||
logger.info('Webhook: device added for user %s: %s', user.id, device_name or '(empty)')
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_DEVICE_ADDED',
|
||||
reply_markup=self._get_subscription_keyboard(user),
|
||||
format_kwargs={'device': device_name or '—'},
|
||||
)
|
||||
|
||||
async def _handle_device_deleted(
|
||||
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
|
||||
) -> None:
|
||||
device_name = self._extract_device_name(data)
|
||||
logger.info('Webhook: device deleted for user %s: %s', user.id, device_name or '(empty)')
|
||||
await self._notify_user(
|
||||
user,
|
||||
'WEBHOOK_DEVICE_DELETED',
|
||||
reply_markup=self._get_subscription_keyboard(user),
|
||||
format_kwargs={'device': device_name or '—'},
|
||||
)
|
||||
@@ -5,7 +5,6 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
@@ -24,7 +23,6 @@ from app.utils.timezone import format_local_datetime
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Константы
|
||||
VERSION_ENV_VAR: Final[str] = 'VERSION'
|
||||
DEFAULT_VERSION: Final[str] = 'dev'
|
||||
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
|
||||
|
||||
@@ -70,10 +68,20 @@ class StartupNotificationService:
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
def _get_version(self) -> str:
|
||||
"""Получает версию из переменной окружения VERSION."""
|
||||
version = os.getenv(VERSION_ENV_VAR, '').strip()
|
||||
if version:
|
||||
return version
|
||||
"""Получает версию из pyproject.toml."""
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
|
||||
if pyproject_path.exists():
|
||||
for line in pyproject_path.read_text().splitlines():
|
||||
if line.strip().startswith('version'):
|
||||
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if ver:
|
||||
return ver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DEFAULT_VERSION
|
||||
|
||||
async def _get_users_count(self) -> int:
|
||||
|
||||
@@ -1814,340 +1814,4 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
return True
|
||||
|
||||
|
||||
async def auto_activate_subscription_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
topup_amount: int | None = None,
|
||||
) -> tuple[bool, bool]:
|
||||
"""
|
||||
Умная автоактивация после пополнения баланса.
|
||||
|
||||
Работает БЕЗ сохранённой корзины:
|
||||
- Если подписка активна — ничего не делает
|
||||
- Если подписка истекла — продлевает с теми же параметрами
|
||||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||||
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
|
||||
Args:
|
||||
topup_amount: Сумма пополнения в копейках (для отображения в уведомлении)
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool]: (success, notification_sent)
|
||||
- success: True если подписка активирована
|
||||
- notification_sent: True если уведомление отправлено пользователю
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
if not user or not getattr(user, 'id', None):
|
||||
return (False, False)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Если автоактивация отключена - уведомление отправится из _send_payment_success_notification
|
||||
if not settings.is_auto_activate_after_topup_enabled():
|
||||
logger.info(
|
||||
'⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||||
if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow():
|
||||
logger.info(
|
||||
'🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_limit_gb = 0
|
||||
connected_squads = []
|
||||
|
||||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||||
if not connected_squads:
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
|
||||
connected_squads = [s.squad_uuid for s in available_servers if s.is_available and s.price_kopeks == 0]
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = user.balance_kopeks
|
||||
available_periods = sorted(settings.get_available_subscription_periods(), reverse=True)
|
||||
|
||||
if not available_periods:
|
||||
logger.warning('🔁 Автоактивация: нет доступных периодов подписки')
|
||||
return (False, False)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
for period in available_periods:
|
||||
try:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
except Exception as calc_error:
|
||||
logger.warning(
|
||||
'🔁 Автоактивация: ошибка расчёта цены для периода %s: %s',
|
||||
period,
|
||||
calc_error,
|
||||
)
|
||||
continue
|
||||
|
||||
if not best_period:
|
||||
logger.info(
|
||||
'🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода',
|
||||
_format_user_id(user),
|
||||
balance,
|
||||
)
|
||||
# Уведомление отправится из _send_payment_success_notification
|
||||
logger.info(
|
||||
'⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(db, user, subscription, best_period)
|
||||
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=f'Автоматическое продление на {best_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.',
|
||||
_format_user_id(user),
|
||||
best_period,
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '',
|
||||
amount_kopeks=best_price,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, 'language', 'ru'))
|
||||
new_end_date = result.subscription.end_date
|
||||
end_date_str = new_end_date.strftime('%d.%m.%Y') if new_end_date else '—'
|
||||
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
|
||||
'✅ Подписка автоматически продлена на {period}.',
|
||||
).format(period=period_label)
|
||||
|
||||
details = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
|
||||
'⏰ Новая дата окончания: {date}.',
|
||||
).format(date=end_date_str)
|
||||
|
||||
hint = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
|
||||
'Перейдите в раздел «Моя подписка», чтобы получить ссылку.',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'{message}\n{details}\n\n{hint}',
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id or user.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
else:
|
||||
# Создание новой подписки
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True,
|
||||
)
|
||||
|
||||
await subtract_user_balance(db, user, best_price, f'Активация подписки на {best_period} дней')
|
||||
|
||||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=best_price,
|
||||
description=f'Активация подписки на {best_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.',
|
||||
best_period,
|
||||
_format_user_id(user),
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '',
|
||||
tariff_name='',
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, 'language', 'ru'))
|
||||
|
||||
message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_SUCCESS',
|
||||
'✅ Подписка на {period} автоматически оформлена после пополнения баланса.',
|
||||
).format(period=period_label)
|
||||
|
||||
hint = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
|
||||
'Перейдите в раздел «Моя подписка», чтобы получить ссылку.',
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'{message}\n\n{hint}',
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id or user.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
# Уведомление админам (независимо от telegram_id)
|
||||
if bot:
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
new_subscription,
|
||||
None, # transaction
|
||||
best_period,
|
||||
False, # was_trial_conversion
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить админов: %s',
|
||||
admin_error,
|
||||
)
|
||||
|
||||
return (True, True) # success=True, notification_sent=True (об активации)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'❌ Автоактивация: ошибка для пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return (False, False)
|
||||
|
||||
|
||||
__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup']
|
||||
__all__ = ['auto_purchase_saved_cart_after_topup']
|
||||
|
||||
@@ -336,11 +336,6 @@ class SubscriptionRenewalService:
|
||||
if devices_limit is None:
|
||||
devices_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
devices_limit = max(1, devices_limit - 1)
|
||||
|
||||
total_cost, details = await calculate_subscription_total_cost(
|
||||
db,
|
||||
period_days,
|
||||
@@ -454,7 +449,30 @@ class SubscriptionRenewalService:
|
||||
subscription_before = subscription
|
||||
old_end_date = subscription_before.end_date
|
||||
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
try:
|
||||
subscription_after = await extend_subscription(db, subscription_before, period_days)
|
||||
except Exception:
|
||||
# Compensate: refund the charged balance since extension failed
|
||||
if charge_from_balance > 0:
|
||||
try:
|
||||
from app.database.crud.user import add_user_balance
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
charge_from_balance,
|
||||
'Возврат: ошибка продления подписки',
|
||||
create_transaction=True,
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
except Exception as refund_error:
|
||||
logger.critical(
|
||||
'CRITICAL: Failed to refund %s kopeks to user %s after extension failure: %s',
|
||||
charge_from_balance,
|
||||
user.id,
|
||||
refund_error,
|
||||
)
|
||||
raise
|
||||
|
||||
server_ids = pricing.server_ids or []
|
||||
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
|
||||
|
||||
@@ -205,17 +205,24 @@ class SubscriptionService:
|
||||
|
||||
# Ищем существующего пользователя в панели
|
||||
existing_users = []
|
||||
if user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
elif user.remnawave_uuid:
|
||||
# Для email-пользователей ищем по uuid если есть
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
existing_user = await api.get_user(user.remnawave_uuid)
|
||||
existing_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
if existing_user:
|
||||
existing_users = [existing_user]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not existing_users and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
|
||||
# Fallback: поиск по email (для OAuth юзеров без telegram_id)
|
||||
if not existing_users and user.email:
|
||||
try:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if existing_users:
|
||||
logger.info(f'🔄 Найден существующий пользователь в панели для {self._format_user_log(user)}')
|
||||
remnawave_user = existing_users[0]
|
||||
@@ -783,11 +790,6 @@ class SubscriptionService:
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
|
||||
@@ -124,6 +124,7 @@ class BotConfigurationService:
|
||||
'VERSION': '🔄 Проверка версий',
|
||||
'WEB_API': '⚡ Web API',
|
||||
'WEBHOOK': '🌐 Webhook',
|
||||
'WEBHOOK_NOTIFICATIONS': '📢 Уведомления от вебхуков',
|
||||
'LOG': '📝 Логирование',
|
||||
'DEBUG': '🧪 Режим разработки',
|
||||
'MODERATION': '🛡️ Модерация и фильтры',
|
||||
@@ -183,6 +184,7 @@ class BotConfigurationService:
|
||||
'VERSION': 'Отслеживание обновлений репозитория.',
|
||||
'WEB_API': 'Web API, токены и права доступа.',
|
||||
'WEBHOOK': 'Пути и секреты вебхуков.',
|
||||
'WEBHOOK_NOTIFICATIONS': 'Управление уведомлениями, которые получают пользователи при событиях RemnaWave (отключение/активация подписки, устройства, трафик и т.д.).',
|
||||
'LOG': 'Уровни логирования и ротация.',
|
||||
'DEBUG': 'Отладочные функции и безопасный режим.',
|
||||
'MODERATION': 'Настройки фильтров отображаемых имен и защиты от фишинга.',
|
||||
@@ -260,7 +262,6 @@ class BotConfigurationService:
|
||||
'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT',
|
||||
'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT',
|
||||
'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT',
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': 'PAYMENT',
|
||||
'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
|
||||
@@ -293,6 +294,7 @@ class BotConfigurationService:
|
||||
'LOGO_FILE': 'INTERFACE_BRANDING',
|
||||
'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION',
|
||||
'MAIN_MENU_MODE': 'INTERFACE',
|
||||
'CABINET_BUTTON_STYLE': 'INTERFACE',
|
||||
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
|
||||
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
|
||||
'APP_CONFIG_PATH': 'ADDITIONAL',
|
||||
@@ -357,6 +359,7 @@ class BotConfigurationService:
|
||||
'MAINTENANCE_': 'MAINTENANCE',
|
||||
'VERSION_CHECK': 'VERSION',
|
||||
'BACKUP_': 'BACKUP',
|
||||
'WEBHOOK_NOTIFY_': 'WEBHOOK_NOTIFICATIONS',
|
||||
'WEBHOOK_': 'WEBHOOK',
|
||||
'LOG_': 'LOG',
|
||||
'WEB_API_': 'WEB_API',
|
||||
@@ -404,7 +407,13 @@ class BotConfigurationService:
|
||||
],
|
||||
'MAIN_MENU_MODE': [
|
||||
ChoiceOption('default', '📋 Полное меню'),
|
||||
ChoiceOption('text', '📝 Текстовое меню'),
|
||||
ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'),
|
||||
],
|
||||
'CABINET_BUTTON_STYLE': [
|
||||
ChoiceOption('', '🎨 По секциям (авто)'),
|
||||
ChoiceOption('primary', '🔵 Синий'),
|
||||
ChoiceOption('success', '🟢 Зелёный'),
|
||||
ChoiceOption('danger', '🔴 Красный'),
|
||||
],
|
||||
'SALES_MODE': [
|
||||
ChoiceOption('classic', '📋 Классический (периоды из .env)'),
|
||||
@@ -585,19 +594,6 @@ class BotConfigurationService:
|
||||
'example': 'true',
|
||||
'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'),
|
||||
},
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': {
|
||||
'description': (
|
||||
'Включает режим яркого промпта активации подписки после пополнения баланса. '
|
||||
'Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками '
|
||||
'и кнопками для активации/продления подписки или изменения количества устройств.'
|
||||
),
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
'warning': (
|
||||
'При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. '
|
||||
'Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств).'
|
||||
),
|
||||
},
|
||||
'SUPPORT_TICKET_SLA_MINUTES': {
|
||||
'description': 'Лимит времени для ответа модераторов на тикет в минутах.',
|
||||
'format': 'Целое число от 1 до 1440.',
|
||||
@@ -823,6 +819,69 @@ class BotConfigurationService:
|
||||
'example': '60',
|
||||
'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_USER_ENABLED': {
|
||||
'description': (
|
||||
'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. '
|
||||
'При выключении ни одно уведомление не отправляется, независимо от остальных настроек.'
|
||||
),
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_STATUS': {
|
||||
'description': 'Уведомления об отключении и активации подписки администратором.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_EXPIRED': {
|
||||
'description': 'Уведомления об истечении подписки.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_EXPIRING': {
|
||||
'description': 'Предупреждения о скором истечении подписки (72ч, 48ч, 24ч до окончания).',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_LIMITED': {
|
||||
'description': 'Уведомление при достижении лимита трафика.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_TRAFFIC_RESET': {
|
||||
'description': 'Уведомление о сбросе счётчика трафика.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_DELETED': {
|
||||
'description': 'Уведомление при удалении пользователя из панели.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_SUB_REVOKED': {
|
||||
'description': 'Уведомление при обновлении ключей подписки (revoke).',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_FIRST_CONNECTED': {
|
||||
'description': 'Уведомление при первом подключении к VPN.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_NOT_CONNECTED': {
|
||||
'description': 'Напоминание, что пользователь ещё не подключился к VPN.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD': {
|
||||
'description': 'Предупреждение при приближении к лимиту трафика (порог в %).',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
'WEBHOOK_NOTIFY_DEVICES': {
|
||||
'description': 'Уведомления о подключении и отключении устройств.',
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -307,23 +306,8 @@ class TributeService:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
session, user, bot=self.bot, topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if has_saved_cart and self.bot and not activation_notification_sent and user_id:
|
||||
# Отправляем уведомление только если есть сохранённая корзина и telegram_id
|
||||
if has_saved_cart and self.bot and user_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
@@ -1105,6 +1105,9 @@ class UserService:
|
||||
try:
|
||||
if user.subscription:
|
||||
logger.info(f'🔄 Удаляем подписку {user.subscription.id}')
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
|
||||
)
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
@@ -1145,25 +1148,41 @@ class UserService:
|
||||
'new_month': 0,
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> int:
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> tuple[int, int]:
|
||||
"""Clean up inactive users, skipping those with active subscriptions.
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_count, skipped_active_sub_count).
|
||||
"""
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
skipped_active_sub = 0
|
||||
|
||||
for user in inactive_users:
|
||||
# Skip users with active paid subscriptions
|
||||
if user.subscription and user.subscription.is_active:
|
||||
skipped_active_sub += 1
|
||||
continue
|
||||
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f'Удалено {deleted_count} неактивных пользователей')
|
||||
return deleted_count
|
||||
if skipped_active_sub > 0:
|
||||
logger.info(
|
||||
'Пропущено %d неактивных пользователей с активной подпиской',
|
||||
skipped_active_sub,
|
||||
)
|
||||
logger.info('Удалено %d неактивных пользователей', deleted_count)
|
||||
return deleted_count, skipped_active_sub
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка очистки неактивных пользователей: {e}')
|
||||
return 0
|
||||
logger.error('Ошибка очистки неактивных пользователей: %s', e)
|
||||
return 0, 0
|
||||
|
||||
async def get_user_activity_summary(self, db: AsyncSession, user_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
@@ -82,16 +82,18 @@ class VersionService:
|
||||
return 'UNKNOW'
|
||||
|
||||
def _get_current_version(self) -> str:
|
||||
import os
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
current = os.getenv('VERSION', '').strip()
|
||||
|
||||
if current:
|
||||
if '-' in current and current.startswith('v'):
|
||||
base_version = current.split('-')[0]
|
||||
if base_version.count('.') == 2:
|
||||
return base_version
|
||||
return current
|
||||
pyproject_path = Path(__file__).resolve().parents[2] / 'pyproject.toml'
|
||||
if pyproject_path.exists():
|
||||
for line in pyproject_path.read_text().splitlines():
|
||||
if line.strip().startswith('version'):
|
||||
ver = line.split('=', 1)[1].strip().strip('"').strip("'")
|
||||
if ver:
|
||||
return ver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 'UNKNOW'
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -47,6 +49,24 @@ class WataService:
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
_MAX_RETRIES = 2
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after(response: aiohttp.ClientResponse, response_text: str) -> float:
|
||||
"""Extract retry delay from Retry-After header or response body."""
|
||||
retry_after = response.headers.get('Retry-After')
|
||||
if retry_after:
|
||||
try:
|
||||
return float(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
match = re.search(r'[Rr]etry after (\d+)', response_text)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
|
||||
return 45.0
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
@@ -61,35 +81,57 @@ class WataService:
|
||||
url = self._build_url(path)
|
||||
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
|
||||
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
last_error: WataAPIError | None = None
|
||||
for attempt in range(1 + self._MAX_RETRIES):
|
||||
try:
|
||||
async with (
|
||||
aiohttp.ClientSession(timeout=timeout) as session,
|
||||
session.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
params=params,
|
||||
headers=self._build_headers(),
|
||||
) as response,
|
||||
):
|
||||
response_text = await response.text()
|
||||
|
||||
if not response_text:
|
||||
return {}
|
||||
if response.status == 429:
|
||||
retry_delay = self._parse_retry_after(response, response_text)
|
||||
if attempt < self._MAX_RETRIES:
|
||||
logger.warning(
|
||||
'WATA API 429 on %s %s, retry %d/%d after %.0fs',
|
||||
method,
|
||||
path,
|
||||
attempt + 1,
|
||||
self._MAX_RETRIES,
|
||||
retry_delay,
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
logger.warning('WATA API 429 on %s %s, retries exhausted', method, path)
|
||||
last_error = WataAPIError(f'WATA API rate limited on {method} {path}')
|
||||
break
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
if response.status >= 400:
|
||||
logger.error('WATA API error %s: %s', response.status, response_text)
|
||||
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
if not response_text:
|
||||
return {}
|
||||
|
||||
try:
|
||||
data = await response.json()
|
||||
except aiohttp.ContentTypeError as error:
|
||||
logger.error('WATA API returned non-JSON response: %s', error)
|
||||
raise WataAPIError('WATA API returned invalid JSON') from error
|
||||
|
||||
return data
|
||||
except aiohttp.ClientError as error:
|
||||
logger.error('Error communicating with WATA API: %s', error)
|
||||
raise WataAPIError('Failed to communicate with WATA API') from error
|
||||
|
||||
raise last_error or WataAPIError('WATA API request failed')
|
||||
|
||||
@staticmethod
|
||||
def _amount_from_kopeks(amount_kopeks: int) -> float:
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Lightweight in-process cache for per-section cabinet button styles.
|
||||
|
||||
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
|
||||
by keeping the cache and its helpers in a dedicated module.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---- Defaults per section ------------------------------------------------
|
||||
|
||||
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
|
||||
'home': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'subscription': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'balance': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'referral': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
|
||||
}
|
||||
|
||||
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
|
||||
|
||||
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
|
||||
|
||||
# Map callback_data values to their logical section name.
|
||||
CALLBACK_TO_SECTION: dict[str, str] = {
|
||||
'menu_profile_unavailable': 'home',
|
||||
'back_to_menu': 'home',
|
||||
'menu_subscription': 'subscription',
|
||||
'subscription': 'subscription',
|
||||
'subscription_extend': 'subscription',
|
||||
'subscription_upgrade': 'subscription',
|
||||
'subscription_connect': 'subscription',
|
||||
'subscription_resume_checkout': 'subscription',
|
||||
'return_to_saved_cart': 'subscription',
|
||||
'menu_buy': 'subscription',
|
||||
'buy_traffic': 'subscription',
|
||||
'menu_balance': 'balance',
|
||||
'balance_topup': 'balance',
|
||||
'menu_referrals': 'referral',
|
||||
'menu_referral': 'referral',
|
||||
'menu_support': 'support',
|
||||
'menu_info': 'info',
|
||||
'admin_panel': 'admin',
|
||||
}
|
||||
|
||||
# DB key used for storage.
|
||||
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
|
||||
|
||||
# Valid Telegram Bot API style values.
|
||||
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
|
||||
|
||||
# All style values accepted by the admin API ('default' = no color, Telegram default).
|
||||
ALLOWED_STYLE_VALUES = VALID_STYLES | {'default'}
|
||||
|
||||
# ---- Module-level cache ---------------------------------------------------
|
||||
|
||||
_cached_styles: dict[str, dict] | None = None
|
||||
|
||||
|
||||
def _deep_copy_styles(source: dict[str, dict]) -> dict[str, dict]:
|
||||
"""Return a deep copy of styles dict (copies nested ``labels`` dicts)."""
|
||||
return {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in source.items()}
|
||||
|
||||
|
||||
def get_cached_button_styles() -> dict[str, dict]:
|
||||
"""Return the current merged config (DB overrides + defaults).
|
||||
|
||||
If the cache has not been loaded yet, returns defaults.
|
||||
"""
|
||||
if _cached_styles is not None:
|
||||
return _deep_copy_styles(_cached_styles)
|
||||
return _deep_copy_styles(DEFAULT_BUTTON_STYLES)
|
||||
|
||||
|
||||
async def load_button_styles_cache() -> dict[str, dict]:
|
||||
"""Load button styles from DB and refresh the module cache.
|
||||
|
||||
Called at bot startup and after admin updates via the cabinet API.
|
||||
"""
|
||||
global _cached_styles
|
||||
|
||||
merged = _deep_copy_styles(DEFAULT_BUTTON_STYLES)
|
||||
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting and setting.value:
|
||||
db_data: dict = json.loads(setting.value)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
merged[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
merged[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except Exception:
|
||||
logger.exception('Failed to load button styles from DB, using defaults')
|
||||
|
||||
_cached_styles = merged
|
||||
logger.info('Button styles cache loaded: %s', list(merged.keys()))
|
||||
return merged
|
||||
@@ -173,75 +173,3 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def modem_available(for_enable: bool = False, for_disable: bool = False):
|
||||
"""
|
||||
Декоратор для проверки доступности модема.
|
||||
|
||||
Проверяет:
|
||||
- Наличие подписки
|
||||
- Подписка не триальная
|
||||
- Функция модема включена в настройках
|
||||
- (опционально) Модем ещё не подключен (for_enable=True)
|
||||
- (опционально) Модем уже подключен (for_disable=True)
|
||||
|
||||
Args:
|
||||
for_enable: Проверять, что модем ещё не подключен
|
||||
for_disable: Проверять, что модем подключен
|
||||
|
||||
Usage:
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback, db_user, db): ...
|
||||
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback, db_user, db): ...
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not db_user:
|
||||
logger.warning('modem_available: нет db_user в kwargs')
|
||||
return None
|
||||
|
||||
from app.services.modem_service import ModemError, get_modem_service
|
||||
|
||||
service = get_modem_service()
|
||||
result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable)
|
||||
|
||||
if not result.available:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
error_messages = {
|
||||
ModemError.NO_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.TRIAL_SUBSCRIPTION: texts.t(
|
||||
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
|
||||
),
|
||||
ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'),
|
||||
ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'),
|
||||
ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'),
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
try:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(error_text, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(error_text)
|
||||
except TelegramBadRequest as e:
|
||||
if 'query is too old' not in str(e).lower():
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
+18
-10
@@ -87,7 +87,8 @@ def format_time_ago(dt: datetime | str, language: str = 'ru') -> str:
|
||||
|
||||
|
||||
def format_days_declension(days: int, language: str = 'ru') -> str:
|
||||
if language != 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code not in {'ru', 'fa'}:
|
||||
return f'{days} day{"s" if days != 1 else ""}'
|
||||
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
@@ -180,42 +181,49 @@ def format_subscription_status(is_active: bool, is_trial: bool, end_date: dateti
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not is_active:
|
||||
return '❌ Неактивна' if language == 'ru' else '❌ Inactive'
|
||||
return '❌ Неактивна' if use_russian_fallback else '❌ Inactive'
|
||||
|
||||
if is_trial:
|
||||
status = '🎁 Тестовая' if language == 'ru' else '🎁 Trial'
|
||||
status = '🎁 Тестовая' if use_russian_fallback else '🎁 Trial'
|
||||
else:
|
||||
status = '✅ Активна' if language == 'ru' else '✅ Active'
|
||||
status = '✅ Активна' if use_russian_fallback else '✅ Active'
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f' ({days_left} дн.)' if language == 'ru' else f' ({days_left} days)'
|
||||
status += f' ({days_left} дн.)' if use_russian_fallback else f' ({days_left} days)'
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f' ({hours_left} ч.)' if language == 'ru' else f' ({hours_left} hrs)'
|
||||
status += f' ({hours_left} ч.)' if use_russian_fallback else f' ({hours_left} hrs)'
|
||||
else:
|
||||
status = '⏰ Истекла' if language == 'ru' else '⏰ Expired'
|
||||
status = '⏰ Истекла' if use_russian_fallback else '⏰ Expired'
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = 'ru') -> str:
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / ∞'
|
||||
return f'{used_gb:.1f} GB / ∞'
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)'
|
||||
return f'{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)'
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = 'ru') -> str:
|
||||
if language == 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code in {'ru', 'fa'}:
|
||||
return '✅ Да' if value else '❌ Нет'
|
||||
return '✅ Yes' if value else '❌ No'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user