Compare commits
104 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 |
+51
-2
@@ -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
|
||||
@@ -659,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
|
||||
|
||||
@@ -673,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=
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.9.1"
|
||||
".": "3.12.0"
|
||||
}
|
||||
|
||||
+122
@@ -1,5 +1,127 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.9.1" # x-release-please-version
|
||||
ARG VERSION="v3.12.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -610,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 "*"
|
||||
@@ -818,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 "*";
|
||||
@@ -1055,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 СБП и банковские карты.
|
||||
|
||||
+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
|
||||
@@ -89,6 +91,8 @@ 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)
|
||||
@@ -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}')
|
||||
@@ -27,6 +27,7 @@ from app.database.crud.user import (
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
@@ -1806,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
|
||||
|
||||
@@ -1876,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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
@@ -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
|
||||
+46
-76
@@ -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)
|
||||
@@ -395,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'
|
||||
@@ -501,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 = ''
|
||||
@@ -735,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,7 +1069,7 @@ class Settings(BaseSettings):
|
||||
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]:
|
||||
@@ -1089,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:
|
||||
@@ -1343,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]:
|
||||
@@ -1516,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'
|
||||
@@ -1529,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 - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
|
||||
@@ -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(
|
||||
@@ -284,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',
|
||||
@@ -347,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}')
|
||||
@@ -430,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
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
@@ -605,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:
|
||||
@@ -613,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,
|
||||
)
|
||||
|
||||
@@ -632,7 +655,7 @@ async def decrement_subscription_server_counts(
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
|
||||
subscription.id,
|
||||
sub_id,
|
||||
error,
|
||||
)
|
||||
|
||||
@@ -642,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,
|
||||
)
|
||||
|
||||
@@ -1951,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)
|
||||
|
||||
@@ -503,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:
|
||||
@@ -554,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,
|
||||
@@ -570,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:
|
||||
|
||||
+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
+4
-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
|
||||
@@ -1284,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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
@@ -3638,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()
|
||||
|
||||
@@ -5578,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,
|
||||
|
||||
+78
-16
@@ -159,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:
|
||||
@@ -217,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',
|
||||
@@ -931,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}')
|
||||
|
||||
@@ -972,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:
|
||||
@@ -1003,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
|
||||
@@ -1228,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:
|
||||
@@ -1507,6 +1559,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
)
|
||||
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:
|
||||
@@ -1735,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)
|
||||
|
||||
@@ -1880,8 +1948,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -1974,8 +2040,6 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
|
||||
@@ -2028,8 +2092,6 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4113,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
|
||||
|
||||
@@ -4152,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)
|
||||
|
||||
@@ -4169,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
|
||||
|
||||
@@ -4289,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
|
||||
@@ -4307,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,
|
||||
@@ -4316,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,
|
||||
)
|
||||
|
||||
# Проверяем баланс пользователя
|
||||
|
||||
@@ -1552,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)
|
||||
|
||||
+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()
|
||||
|
||||
+123
-32
@@ -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
|
||||
|
||||
# Получаем данные для плейсхолдеров
|
||||
@@ -364,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
|
||||
@@ -398,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')])
|
||||
|
||||
@@ -425,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:
|
||||
@@ -1002,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', '⏸️ Приостановить подписку')
|
||||
@@ -1802,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(
|
||||
@@ -1886,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 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
|
||||
@@ -2622,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(
|
||||
|
||||
@@ -1698,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"
|
||||
}
|
||||
|
||||
@@ -1436,7 +1436,7 @@
|
||||
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>اتصال از طریق Happ</b>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 دکمه زیر بزنید تا راهنمای تنظیم VPN روی دستگاه را ببینید",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>لینک وارد کردن در اپلیکیشن VPN:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_INFO": "ℹ️ اطلاعات اشتراک",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>اطلاعات اشتراک</b>\n\n📊 <b>وضعیت:</b> {status}\n🎭 <b>نوع:</b> {type}\n📅 <b>اعتبار تا:</b> {end_date}\n⏰ <b>روزهای باقیمانده:</b> {days_left}\n\n📈 <b>ترافیک:</b> {traffic_used} / {traffic_limit}\n🌍 <b>سرورها:</b> {countries_count} کشور\n📱 <b>دستگاهها:</b> {devices_used} / {devices_limit}\n\n💳 <b>پرداخت خودکار:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nلینک در حال ایجاد است. چند ثانیه بعد به «اشتراک من» بروید.",
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ لینک اشتراک از دکمههای زیر یا بخش «اشتراک من» در دسترس است.",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 لینک را در اپلیکیشن VPN وارد کنید",
|
||||
@@ -1717,5 +1717,23 @@
|
||||
"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>اشتراک تا ۳ روز دیگر منقضی میشود</b>\n\nفراموش نکنید اشتراک خود را تمدید کنید.",
|
||||
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>اشتراک تا ۲ روز دیگر منقضی میشود</b>\n\nاشتراک خود را زودتر تمدید کنید تا دسترسی VPN حفظ شود.",
|
||||
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>اشتراک فردا منقضی میشود!</b>\n\nکمتر از ۲۴ ساعت باقی مانده. همین الان تمدید کنید.",
|
||||
"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": "✖️ بستن"
|
||||
}
|
||||
@@ -1719,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": "✖️ Закрыть"
|
||||
}
|
||||
|
||||
@@ -1586,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": "✖️ Закрити"
|
||||
}
|
||||
|
||||
@@ -1919,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":"✖️ 关闭"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+102
-145
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -221,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:
|
||||
@@ -262,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)
|
||||
@@ -288,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}')
|
||||
@@ -299,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
|
||||
|
||||
@@ -478,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
|
||||
|
||||
@@ -636,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(
|
||||
@@ -670,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()
|
||||
@@ -1007,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
|
||||
@@ -1237,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',
|
||||
@@ -1286,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,
|
||||
)
|
||||
@@ -1424,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',
|
||||
@@ -1488,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',
|
||||
@@ -1580,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',
|
||||
@@ -1604,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',
|
||||
@@ -1641,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',
|
||||
@@ -1874,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')
|
||||
|
||||
@@ -237,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
|
||||
|
||||
@@ -156,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)
|
||||
@@ -251,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)
|
||||
|
||||
@@ -279,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),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
|
||||
@@ -325,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(
|
||||
|
||||
@@ -272,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),
|
||||
)
|
||||
|
||||
# Связываем платеж с транзакцией
|
||||
|
||||
@@ -75,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:
|
||||
@@ -253,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(
|
||||
|
||||
@@ -385,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)
|
||||
|
||||
@@ -366,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
|
||||
|
||||
|
||||
@@ -472,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)
|
||||
|
||||
@@ -397,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')
|
||||
|
||||
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
|
||||
@@ -589,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):
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -2499,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}')
|
||||
@@ -2547,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)
|
||||
@@ -2555,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 '—'},
|
||||
)
|
||||
@@ -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', [])
|
||||
|
||||
@@ -790,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': 'Настройки фильтров отображаемых имен и защиты от фишинга.',
|
||||
@@ -292,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',
|
||||
@@ -356,6 +359,7 @@ class BotConfigurationService:
|
||||
'MAINTENANCE_': 'MAINTENANCE',
|
||||
'VERSION_CHECK': 'VERSION',
|
||||
'BACKUP_': 'BACKUP',
|
||||
'WEBHOOK_NOTIFY_': 'WEBHOOK_NOTIFICATIONS',
|
||||
'WEBHOOK_': 'WEBHOOK',
|
||||
'LOG_': 'LOG',
|
||||
'WEB_API_': 'WEB_API',
|
||||
@@ -403,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)'),
|
||||
@@ -809,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+140
-17
@@ -2,34 +2,157 @@ from aiogram import types
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
|
||||
|
||||
|
||||
DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable'
|
||||
# Mapping from callback_data to cabinet frontend paths.
|
||||
# Used for automatic deep-linking when explicit ``cabinet_path`` is not provided.
|
||||
# If callback_data is NOT in this mapping, the button falls back to a regular callback.
|
||||
CALLBACK_TO_CABINET_PATH: dict[str, str] = {
|
||||
'menu_balance': '/balance',
|
||||
'balance_topup': '/balance/top-up',
|
||||
'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_referrals': '/referral',
|
||||
'menu_referral': '/referral',
|
||||
'menu_support': '/support',
|
||||
'menu_info': '/info',
|
||||
'menu_profile': '/profile',
|
||||
'back_to_menu': '/',
|
||||
}
|
||||
|
||||
# Default button styles per callback_data for cabinet mode.
|
||||
# Values: 'primary' (blue), 'success' (green), 'danger' (red), None (default).
|
||||
CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
|
||||
'menu_balance': 'primary',
|
||||
'balance_topup': 'primary',
|
||||
'menu_subscription': 'success',
|
||||
'subscription': 'success',
|
||||
'subscription_extend': 'success',
|
||||
'subscription_upgrade': 'success',
|
||||
'subscription_connect': 'success',
|
||||
'subscription_resume_checkout': 'success',
|
||||
'return_to_saved_cart': 'success',
|
||||
'menu_buy': 'success',
|
||||
'buy_traffic': 'success',
|
||||
'menu_referrals': 'success',
|
||||
'menu_referral': 'success',
|
||||
'menu_support': 'primary',
|
||||
'menu_info': 'primary',
|
||||
'menu_profile': 'primary',
|
||||
'back_to_menu': 'primary',
|
||||
}
|
||||
|
||||
# Mapping from broadcast button keys to cabinet paths.
|
||||
BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = {
|
||||
'balance': '/balance/top-up',
|
||||
'referrals': '/referral',
|
||||
'promocode': '/subscription',
|
||||
'connect': '/subscription',
|
||||
'subscription': '/subscription',
|
||||
'support': '/support',
|
||||
'home': '/',
|
||||
}
|
||||
|
||||
# Valid style values accepted by the Telegram Bot API.
|
||||
_VALID_STYLES = frozenset({'primary', 'success', 'danger'})
|
||||
|
||||
|
||||
def _resolve_style(style: str | None) -> str | None:
|
||||
"""Return a validated style or ``None``."""
|
||||
if style and style in _VALID_STYLES:
|
||||
return style
|
||||
return None
|
||||
|
||||
|
||||
def build_cabinet_url(path: str = '') -> str:
|
||||
"""Join ``MINIAPP_CUSTOM_URL`` with an optional *path* segment.
|
||||
|
||||
Handles trailing-slash normalization so that both
|
||||
``https://example.com`` and ``https://example.com/`` produce
|
||||
correct URLs like ``https://example.com/balance``.
|
||||
|
||||
Returns an empty string when the base URL is not configured
|
||||
or when *path* is empty (no known section).
|
||||
"""
|
||||
base = (settings.MINIAPP_CUSTOM_URL or '').strip().rstrip('/')
|
||||
if not base:
|
||||
return ''
|
||||
if not path:
|
||||
return ''
|
||||
if path == '/':
|
||||
return base
|
||||
if not path.startswith('/'):
|
||||
path = f'/{path}'
|
||||
return f'{base}{path}'
|
||||
|
||||
|
||||
def build_miniapp_or_callback_button(
|
||||
text: str,
|
||||
*,
|
||||
callback_data: str,
|
||||
unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK,
|
||||
cabinet_path: str | None = None,
|
||||
style: str | None = None,
|
||||
icon_custom_emoji_id: str | None = None,
|
||||
) -> InlineKeyboardButton:
|
||||
"""Create a button that opens the miniapp in text menu mode.
|
||||
"""Create a button that opens the cabinet miniapp or falls back to a callback.
|
||||
|
||||
When the simplified text menu mode is enabled we should avoid exposing
|
||||
deep bot flows and redirect the user to the configured miniapp instead.
|
||||
If the miniapp URL is missing we fall back to a safe callback that shows
|
||||
an alert about the unavailable profile rather than opening disabled
|
||||
sections of the bot.
|
||||
In cabinet menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
|
||||
opens the relevant section of the cabinet. The target section is determined
|
||||
by ``cabinet_path`` (explicit) or inferred from ``callback_data`` via
|
||||
``CALLBACK_TO_CABINET_PATH``.
|
||||
|
||||
Button styling (Bot API 9.4):
|
||||
- ``style`` overrides the button color: ``'primary'`` (blue),
|
||||
``'success'`` (green), ``'danger'`` (red). When omitted the style is
|
||||
resolved from ``CABINET_BUTTON_STYLE`` config or per-section defaults.
|
||||
- ``icon_custom_emoji_id`` shows a custom emoji before the button text
|
||||
(requires bot owner to have Telegram Premium).
|
||||
|
||||
When ``callback_data`` is not found in the mapping and no explicit
|
||||
``cabinet_path`` is given, the button falls back to a regular Telegram
|
||||
callback — this keeps actions like ``claim_discount_*`` working correctly.
|
||||
|
||||
Only ``MINIAPP_CUSTOM_URL`` is considered here — the purchase-only URL
|
||||
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
|
||||
display subscription details and would load indefinitely.
|
||||
"""
|
||||
|
||||
if settings.is_text_main_menu_mode():
|
||||
miniapp_url = settings.get_main_menu_miniapp_url()
|
||||
if miniapp_url:
|
||||
return InlineKeyboardButton(
|
||||
text=text,
|
||||
web_app=types.WebAppInfo(url=miniapp_url),
|
||||
)
|
||||
safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK
|
||||
return InlineKeyboardButton(text=text, callback_data=safe_callback)
|
||||
if settings.is_cabinet_mode():
|
||||
path = cabinet_path or CALLBACK_TO_CABINET_PATH.get(callback_data)
|
||||
if path:
|
||||
url = build_cabinet_url(path)
|
||||
if url:
|
||||
# Resolve per-section config from cache
|
||||
section = CALLBACK_TO_SECTION.get(callback_data)
|
||||
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
|
||||
|
||||
# Style chain: explicit param > per-section DB > global config > hardcoded default
|
||||
# 'default' in per-section config means "no color" — do not fall through.
|
||||
if style:
|
||||
resolved_style = _resolve_style(style)
|
||||
elif section_cfg.get('style'):
|
||||
resolved_style = _resolve_style(section_cfg['style'])
|
||||
else:
|
||||
resolved_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip()) or _resolve_style(
|
||||
CALLBACK_TO_CABINET_STYLE.get(callback_data)
|
||||
)
|
||||
|
||||
# Emoji chain: explicit param > per-section DB
|
||||
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_style,
|
||||
icon_custom_emoji_id=resolved_emoji or None,
|
||||
)
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
|
||||
@@ -129,14 +129,6 @@ async def compute_simple_subscription_price(
|
||||
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
|
||||
|
||||
# Расчёт цены модема (если включён)
|
||||
modem_enabled = params.get('modem_enabled', False)
|
||||
modem_price_original = 0
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
months = calculate_months_from_days(period_days)
|
||||
modem_price_original = modem_price_per_month * months
|
||||
|
||||
promo_group: PromoGroup | None = params.get('promo_group')
|
||||
|
||||
if promo_group is None:
|
||||
@@ -256,11 +248,7 @@ async def compute_simple_subscription_price(
|
||||
)
|
||||
|
||||
total_before_discount = (
|
||||
base_price_original
|
||||
+ traffic_price_original
|
||||
+ devices_price_original
|
||||
+ servers_price_original
|
||||
+ modem_price_original
|
||||
base_price_original + traffic_price_original + devices_price_original + servers_price_original
|
||||
)
|
||||
|
||||
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
|
||||
@@ -274,8 +262,6 @@ async def compute_simple_subscription_price(
|
||||
'traffic_discount': traffic_discount,
|
||||
'devices_price': devices_price_original,
|
||||
'devices_discount': devices_discount,
|
||||
'modem_price': modem_price_original,
|
||||
'modem_enabled': modem_enabled,
|
||||
'servers_price': servers_price_original,
|
||||
'servers_discount': servers_discount_total,
|
||||
'servers_final': sum(item['final_price'] for item in server_breakdown),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Только буквы, цифры, дефис, подчёркивание
|
||||
PROMO_CODE_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
|
||||
|
||||
# Лимиты
|
||||
MAX_FAILED_ATTEMPTS = 5
|
||||
FAILED_WINDOW_SECONDS = 300 # 5 минут
|
||||
MAX_ACTIVATIONS_PER_DAY = 5
|
||||
ACTIVATION_WINDOW_SECONDS = 86400 # 24 часа
|
||||
|
||||
|
||||
class PromoRateLimiter:
|
||||
"""
|
||||
In-memory rate limiter для промокодов:
|
||||
1. Лимит на неудачные попытки (перебор)
|
||||
2. Лимит на количество активаций за день (стакинг)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# user_id → list[timestamp] неудачных попыток
|
||||
self._failed_attempts: dict[int, list[float]] = {}
|
||||
# user_id → list[timestamp] успешных активаций
|
||||
self._activations: dict[int, list[float]] = {}
|
||||
|
||||
def record_failed_attempt(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
attempts.append(now)
|
||||
self._failed_attempts[user_id] = attempts
|
||||
|
||||
if len(attempts) >= MAX_FAILED_ATTEMPTS:
|
||||
logger.warning(
|
||||
'Promo brute-force: user %s — %d failed attempts in %ds',
|
||||
user_id,
|
||||
len(attempts),
|
||||
FAILED_WINDOW_SECONDS,
|
||||
)
|
||||
|
||||
def is_blocked(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
|
||||
self._failed_attempts[user_id] = attempts
|
||||
return len(attempts) >= MAX_FAILED_ATTEMPTS
|
||||
|
||||
def get_block_cooldown(self, user_id: int) -> int:
|
||||
attempts = self._failed_attempts.get(user_id, [])
|
||||
if not attempts:
|
||||
return 0
|
||||
oldest = attempts[0]
|
||||
remaining = int(FAILED_WINDOW_SECONDS - (time.time() - oldest)) + 1
|
||||
return max(remaining, 0)
|
||||
|
||||
def record_activation(self, user_id: int) -> None:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
activations.append(now)
|
||||
self._activations[user_id] = activations
|
||||
|
||||
def can_activate(self, user_id: int) -> bool:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
self._activations[user_id] = activations
|
||||
return len(activations) < MAX_ACTIVATIONS_PER_DAY
|
||||
|
||||
def get_activations_left(self, user_id: int) -> int:
|
||||
now = time.time()
|
||||
activations = self._activations.get(user_id, [])
|
||||
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
return max(0, MAX_ACTIVATIONS_PER_DAY - len(activations))
|
||||
|
||||
def cleanup(self) -> None:
|
||||
now = time.time()
|
||||
if len(self._failed_attempts) > 500:
|
||||
self._failed_attempts = {
|
||||
uid: [ts for ts in tss if now - ts < FAILED_WINDOW_SECONDS]
|
||||
for uid, tss in self._failed_attempts.items()
|
||||
if any(now - ts < FAILED_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
if len(self._activations) > 500:
|
||||
self._activations = {
|
||||
uid: [ts for ts in tss if now - ts < ACTIVATION_WINDOW_SECONDS]
|
||||
for uid, tss in self._activations.items()
|
||||
if any(now - ts < ACTIVATION_WINDOW_SECONDS for ts in tss)
|
||||
}
|
||||
|
||||
|
||||
def validate_promo_format(code: str) -> bool:
|
||||
"""Проверяет формат промокода: 3-50 символов, только буквы/цифры/дефис/подчёркивание."""
|
||||
if not code or len(code) < 3 or len(code) > 50:
|
||||
return False
|
||||
return bool(PROMO_CODE_PATTERN.match(code))
|
||||
|
||||
|
||||
# Глобальный синглтон
|
||||
promo_limiter = PromoRateLimiter()
|
||||
+42
-13
@@ -1,3 +1,4 @@
|
||||
import html as html_module
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
@@ -23,6 +24,16 @@ ALLOWED_HTML_TAGS = {
|
||||
|
||||
SELF_CLOSING_TAGS = {'br', 'hr', 'img'}
|
||||
|
||||
# Разрешённые атрибуты для HTML-тегов
|
||||
ALLOWED_TAG_ATTRIBUTES = {
|
||||
'a': {'href'},
|
||||
'tg-emoji': {'emoji-id'},
|
||||
'span': {'class'},
|
||||
}
|
||||
|
||||
# Разрешённые URI-схемы в href (allowlist вместо blocklist)
|
||||
SAFE_URI_SCHEMES = re.compile(r'^(https?://|tg://|mailto:|tel:)', re.IGNORECASE)
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
@@ -140,25 +151,43 @@ def sanitize_html(text: str) -> str:
|
||||
# Обработка всех разрешенных тегов
|
||||
for tag in allowed_tags:
|
||||
# Паттерн: захватываем <tag>, </tag>, или <tag атрибуты>
|
||||
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего >
|
||||
# (?s) - позволяет . захватывать новую строку
|
||||
# [^>]*? - ленивый захват до >
|
||||
pattern = rf'(<)(/?{tag}\b)([^>]*?)(>)'
|
||||
|
||||
def replace_tag(match):
|
||||
match.group(1) # <
|
||||
tag_lower = tag.lower()
|
||||
|
||||
def replace_tag(match, _tag=tag_lower):
|
||||
full_tag_content = match.group(2) # /?tagname
|
||||
attrs_part = match.group(3) # атрибуты (без >)
|
||||
match.group(4) # >
|
||||
attrs_part = match.group(3).removeprefix(' ') # атрибуты (без >)
|
||||
|
||||
# Убираем начальный пробел, если есть
|
||||
attrs_part = attrs_part.removeprefix(' ')
|
||||
if not attrs_part:
|
||||
return f'<{full_tag_content}>'
|
||||
|
||||
# Формируем результат
|
||||
if attrs_part:
|
||||
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
|
||||
# Не разворачиваем < и > внутри атрибутов, чтобы избежать XSS
|
||||
processed_attrs = attrs_part.replace('"', '"').replace(''', "'")
|
||||
# Полное декодирование HTML-сущностей для корректной проверки атрибутов
|
||||
processed_attrs = html_module.unescape(attrs_part)
|
||||
|
||||
# Проверяем whitelist атрибутов для данного тега
|
||||
allowed_attrs = ALLOWED_TAG_ATTRIBUTES.get(_tag)
|
||||
if allowed_attrs is None:
|
||||
# Тег без whitelist — удаляем ВСЕ атрибуты
|
||||
return f'<{full_tag_content}>'
|
||||
|
||||
filtered_parts = []
|
||||
for attr_match in re.finditer(r'([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')', processed_attrs):
|
||||
attr_name = attr_match.group(1).lower()
|
||||
attr_value = attr_match.group(2) if attr_match.group(2) is not None else attr_match.group(3)
|
||||
if attr_name not in allowed_attrs:
|
||||
continue
|
||||
# href: allowlist безопасных URI-схем
|
||||
if attr_name == 'href':
|
||||
# Нормализуем: убираем control chars и пробелы из начала значения
|
||||
normalized = re.sub(r'[\x00-\x1f\x7f\s]+', '', attr_value)
|
||||
if not SAFE_URI_SCHEMES.match(normalized):
|
||||
continue
|
||||
filtered_parts.append(f'{attr_name}="{attr_value}"')
|
||||
processed_attrs = ' '.join(filtered_parts)
|
||||
|
||||
if processed_attrs:
|
||||
return f'<{full_tag_content} {processed_attrs}>'
|
||||
return f'<{full_tag_content}>'
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ async def start_round_now(
|
||||
tags=['contests'],
|
||||
)
|
||||
async def list_rounds(
|
||||
status_filter: str = Query('active', regex='^(active|finished|any)$'),
|
||||
status_filter: str = Query('active', pattern='^(active|finished|any)$'),
|
||||
template_id: int | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -29,10 +29,9 @@ from app.database.crud.promo_group import get_auto_assign_promo_groups
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.rules import get_rules_by_language
|
||||
from app.database.crud.server_squad import (
|
||||
add_user_to_servers,
|
||||
get_available_server_squads,
|
||||
get_server_squad_by_uuid,
|
||||
remove_user_from_servers,
|
||||
update_server_user_counts,
|
||||
)
|
||||
from app.database.crud.subscription import (
|
||||
add_subscription_servers,
|
||||
@@ -5926,7 +5925,6 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
|
||||
removed_server_ids = [
|
||||
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
|
||||
@@ -5934,7 +5932,16 @@ async def update_subscription_servers_endpoint(
|
||||
|
||||
if removed_server_ids:
|
||||
await remove_subscription_servers(db, subscription.id, removed_server_ids)
|
||||
await remove_user_from_servers(db, removed_server_ids)
|
||||
|
||||
if added_server_ids or removed_server_ids:
|
||||
try:
|
||||
await update_server_user_counts(
|
||||
db,
|
||||
add_ids=added_server_ids or None,
|
||||
remove_ids=removed_server_ids or None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счётчика серверов: %s', e)
|
||||
|
||||
ordered_selection = []
|
||||
seen_selection = set()
|
||||
|
||||
@@ -30,7 +30,6 @@ from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionModemRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
@@ -54,7 +53,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
@@ -323,41 +321,3 @@ async def delete_subscription(
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse)
|
||||
async def set_subscription_modem(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionModemRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
"""Включить или выключить модем для подписки."""
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions')
|
||||
|
||||
if not settings.is_modem_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled')
|
||||
|
||||
current_modem = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if payload.enabled == current_modem:
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
if payload.enabled:
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
else:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -70,7 +70,6 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
|
||||
@@ -16,7 +16,6 @@ class SubscriptionResponse(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
@@ -51,7 +50,3 @@ class SubscriptionDevicesRequest(BaseModel):
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
|
||||
|
||||
class SubscriptionModemRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -24,7 +24,6 @@ class SubscriptionSummary(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: int | None = None
|
||||
subscription_url: str | None = None
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
FastAPI router for receiving incoming webhooks from RemnaWave backend.
|
||||
|
||||
Handles HMAC-SHA256 signature verification, payload parsing, and
|
||||
event dispatch to RemnaWaveWebhookService.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import APIRouter, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS
|
||||
_MAX_BODY_SIZE = 64 * 1024
|
||||
|
||||
|
||||
def _verify_signature(raw_body: bytes, received_signature: str, secret: str) -> bool:
|
||||
"""Verify HMAC-SHA256 signature from RemnaWave backend."""
|
||||
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, received_signature)
|
||||
|
||||
|
||||
def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
|
||||
router = APIRouter()
|
||||
webhook_service = RemnaWaveWebhookService(bot)
|
||||
webhook_path = settings.REMNAWAVE_WEBHOOK_PATH
|
||||
|
||||
@router.get(webhook_path)
|
||||
async def remnawave_webhook_health() -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'remnawave_webhook',
|
||||
'enabled': settings.is_remnawave_webhook_enabled(),
|
||||
}
|
||||
)
|
||||
|
||||
@router.post(webhook_path)
|
||||
async def remnawave_webhook(request: Request) -> JSONResponse:
|
||||
raw_body = await request.body()
|
||||
if not raw_body:
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'empty_body'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(raw_body) > _MAX_BODY_SIZE:
|
||||
logger.warning('RemnaWave webhook: payload too large (%d bytes)', len(raw_body))
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'payload_too_large'},
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
)
|
||||
|
||||
# Verify HMAC-SHA256 signature (always required)
|
||||
secret = settings.REMNAWAVE_WEBHOOK_SECRET
|
||||
if not secret:
|
||||
logger.error('RemnaWave webhook: secret not configured, rejecting request')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'webhook_not_configured'},
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Remnawave-Signature') or ''
|
||||
if not signature:
|
||||
logger.warning('RemnaWave webhook: missing signature header')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'missing_signature'},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not _verify_signature(raw_body, signature, secret):
|
||||
logger.warning('RemnaWave webhook: invalid signature')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'invalid_signature'},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Parse JSON payload
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'invalid_json'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Extract and validate event info
|
||||
scope = payload.get('scope', '')
|
||||
event = payload.get('event', '')
|
||||
data = payload.get('data')
|
||||
|
||||
if not scope or not event:
|
||||
logger.warning('RemnaWave webhook: missing scope or event')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'missing_scope_or_event'},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
# RemnaWave sends event as full qualified name (e.g. "user.modified"),
|
||||
# so we use event directly instead of concatenating scope + event.
|
||||
event_name = event
|
||||
logger.info('RemnaWave webhook received: scope=%s, event=%s', scope, event_name)
|
||||
|
||||
# Process event — return 200 to prevent retries for application-level errors.
|
||||
# Only return non-200 for infrastructure failures (DB unavailable).
|
||||
# Admin events (node/service/crm) don't need a DB session.
|
||||
if webhook_service.is_admin_event(event_name):
|
||||
try:
|
||||
processed = await webhook_service.process_event(None, event_name, data)
|
||||
return JSONResponse({'status': 'ok', 'processed': processed})
|
||||
except Exception:
|
||||
logger.exception('RemnaWave webhook processing error for event %s', event_name)
|
||||
return JSONResponse({'status': 'ok', 'processed': False})
|
||||
|
||||
# User events require a DB session
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
processed = await webhook_service.process_event(db, event_name, data)
|
||||
await db.commit()
|
||||
return JSONResponse({'status': 'ok', 'processed': processed})
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception('RemnaWave webhook processing error for event %s', event_name)
|
||||
return JSONResponse({'status': 'ok', 'processed': False})
|
||||
except Exception:
|
||||
logger.error('RemnaWave webhook: failed to get database session')
|
||||
return JSONResponse(
|
||||
{'status': 'error', 'reason': 'database_unavailable'},
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -111,6 +111,16 @@ def create_unified_app(
|
||||
payments_router = payments.create_payment_router(bot, payment_service)
|
||||
if payments_router:
|
||||
app.include_router(payments_router)
|
||||
|
||||
# Mount RemnaWave incoming webhook router
|
||||
remnawave_webhook_enabled = settings.is_remnawave_webhook_enabled()
|
||||
if remnawave_webhook_enabled:
|
||||
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
|
||||
|
||||
remnawave_router = create_remnawave_webhook_router(bot)
|
||||
app.include_router(remnawave_router)
|
||||
logger.info('RemnaWave webhook router mounted at %s', settings.REMNAWAVE_WEBHOOK_PATH)
|
||||
|
||||
payment_providers_state = {
|
||||
'tribute': settings.TRIBUTE_ENABLED,
|
||||
'mulenpay': settings.is_mulenpay_enabled(),
|
||||
@@ -181,6 +191,11 @@ def create_unified_app(
|
||||
'path': str(miniapp_path),
|
||||
}
|
||||
|
||||
remnawave_webhook_state = {
|
||||
'enabled': remnawave_webhook_enabled,
|
||||
'path': settings.REMNAWAVE_WEBHOOK_PATH if remnawave_webhook_enabled else None,
|
||||
}
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
'status': 'ok',
|
||||
@@ -188,6 +203,7 @@ def create_unified_app(
|
||||
'web_api_enabled': settings.is_web_api_enabled(),
|
||||
'payment_webhooks': payment_state,
|
||||
'telegram_webhook': telegram_state,
|
||||
'remnawave_webhook': remnawave_webhook_state,
|
||||
'miniapp_static': miniapp_state,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -329,7 +329,7 @@
|
||||
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
|
||||
- `app/keyboards/inline.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
|
||||
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_cabinet_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
|
||||
- `app/keyboards/reply.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
|
||||
@@ -512,7 +512,7 @@
|
||||
Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
|
||||
- `app/utils/miniapp_buttons.py` — Python-модуль
|
||||
Классы: нет
|
||||
Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
|
||||
Функции: `build_cabinet_url`, `build_miniapp_or_callback_button` — Create a button that opens the cabinet miniapp section or falls back to a callback.
|
||||
- `app/utils/pagination.py` — Python-модуль
|
||||
Классы: `PaginationResult` (1 методов)
|
||||
Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
|
||||
|
||||
+15
-15
@@ -1,26 +1,26 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.9.1"
|
||||
version = "3.12.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
requires-python = '==3.13.*'
|
||||
dependencies = [
|
||||
'aiogram>=3.22.0',
|
||||
'sqlalchemy>=2.0.43',
|
||||
'alembic>=1.16.5',
|
||||
'asyncpg>=0.30.0',
|
||||
'aiosqlite>=0.21.0',
|
||||
'fastapi[standard]>=0.115.6',
|
||||
'redis>=5.0.1',
|
||||
'pyyaml>=6.0.2',
|
||||
'yookassa>=3.9.0',
|
||||
"aiogram>=3.25.0",
|
||||
'sqlalchemy>=2.0.46',
|
||||
'alembic>=1.18.4',
|
||||
'asyncpg>=0.31.0',
|
||||
'aiosqlite>=0.22.1',
|
||||
'fastapi[standard]>=0.129.0',
|
||||
'redis>=7.1.1',
|
||||
'pyyaml>=6.0.3',
|
||||
'yookassa>=3.10.0',
|
||||
'python-dateutil>=2.9.0.post0',
|
||||
'cryptography>=41.0.0',
|
||||
'qrcode[pil]>=7.4.2',
|
||||
'packaging>=23.2',
|
||||
'bcrypt>=4.2.0',
|
||||
'pyjwt>=2.8.0',
|
||||
'cryptography>=44.0.1',
|
||||
'qrcode[pil]>=8.0',
|
||||
'packaging>=26.0',
|
||||
'bcrypt>=5.0.0',
|
||||
'pyjwt>=2.11.0',
|
||||
'pyzipper>=0.3.6',
|
||||
]
|
||||
|
||||
|
||||
+22
-22
@@ -1,24 +1,24 @@
|
||||
# Основные зависимости
|
||||
aiogram==3.22.0
|
||||
aiohttp==3.12.15
|
||||
asyncpg==0.30.0
|
||||
SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
aiogram==3.25.0
|
||||
aiohttp==3.13.3
|
||||
asyncpg==0.31.0
|
||||
SQLAlchemy==2.0.46
|
||||
alembic==1.18.4
|
||||
aiosqlite==0.22.1
|
||||
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.32.1
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.13.0
|
||||
python-dotenv==1.2.1
|
||||
redis==7.1.1
|
||||
PyYAML==6.0.3
|
||||
fastapi==0.129.0
|
||||
uvicorn==0.40.0
|
||||
websockets>=12.0
|
||||
python-multipart==0.0.9
|
||||
python-multipart==0.0.22
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.9.0
|
||||
yookassa==3.10.0
|
||||
|
||||
# NaloGO для чеков в налоговую
|
||||
# nalogo - используем локальную исправленную версию в app/lib/nalogo/
|
||||
@@ -28,21 +28,21 @@ httpx # зависимость для nalogo
|
||||
structlog==23.2.0
|
||||
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.11.0
|
||||
APScheduler==3.11.2
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
qrcode[pil]==7.4.2
|
||||
cryptography>=44.0.1
|
||||
qrcode[pil]==8.2
|
||||
|
||||
# Личный кабинет (Cabinet)
|
||||
bcrypt==4.2.0
|
||||
PyJWT==2.8.0
|
||||
email-validator==2.1.0
|
||||
bcrypt==5.0.0
|
||||
PyJWT==2.11.0
|
||||
email-validator==2.3.0
|
||||
|
||||
# Для работы с версиями
|
||||
packaging==23.2
|
||||
packaging==26.0
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
|
||||
+12
-3
@@ -111,10 +111,19 @@ async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> we
|
||||
|
||||
|
||||
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_get_db():
|
||||
yield DummyDB()
|
||||
"""Mock AsyncSessionLocal used by the webhook handler."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db)
|
||||
mock_session = AsyncMock()
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.rollback = AsyncMock()
|
||||
mock_session.execute = AsyncMock()
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
monkeypatch.setattr('app.external.yookassa_webhook.AsyncSessionLocal', lambda: ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
"""
|
||||
Тесты для ModemService - управление модемом в подписке.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.services.modem_service import (
|
||||
ModemError,
|
||||
ModemService,
|
||||
get_modem_service,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings():
|
||||
"""Создаёт мок настроек приложения."""
|
||||
settings = MagicMock()
|
||||
settings.is_modem_enabled.return_value = True
|
||||
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
|
||||
settings.get_modem_period_discount.return_value = 0
|
||||
return settings
|
||||
|
||||
|
||||
def create_sample_user():
|
||||
"""Создаёт пример пользователя."""
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=123456789,
|
||||
balance_kopeks=50000, # 500 рублей
|
||||
language='ru',
|
||||
subscription=None,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_sample_subscription():
|
||||
"""Создаёт пример подписки."""
|
||||
subscription = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=1,
|
||||
is_trial=False,
|
||||
modem_enabled=False,
|
||||
device_limit=2,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_trial_subscription():
|
||||
"""Создаёт триальную подписку."""
|
||||
subscription = SimpleNamespace(
|
||||
id=2,
|
||||
user_id=1,
|
||||
is_trial=True,
|
||||
modem_enabled=False,
|
||||
device_limit=1,
|
||||
end_date=datetime.utcnow() + timedelta(days=7),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_modem_service(monkeypatch):
|
||||
"""Создаёт ModemService с замоканными настройками."""
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
return ModemService(), mock_settings
|
||||
|
||||
|
||||
class TestModemServiceAvailability:
|
||||
"""Тесты проверки доступности модема."""
|
||||
|
||||
def test_check_availability_no_subscription(self, monkeypatch):
|
||||
"""Модем недоступен без подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.subscription = None
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NO_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_trial_subscription(self, monkeypatch):
|
||||
"""Модем недоступен для триальной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
trial_subscription = create_trial_subscription()
|
||||
sample_user.subscription = trial_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.TRIAL_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
|
||||
"""Модем недоступен, если отключён в настройках."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
mock_settings.is_modem_enabled.return_value = False
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.MODEM_DISABLED
|
||||
|
||||
def test_check_availability_success(self, monkeypatch):
|
||||
"""Модем доступен для платной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert result.available
|
||||
assert result.error is None
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
|
||||
"""Нельзя подключить уже подключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_enable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.ALREADY_ENABLED
|
||||
assert result.modem_enabled
|
||||
|
||||
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
|
||||
"""Нельзя отключить неподключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_disable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NOT_ENABLED
|
||||
assert not result.modem_enabled
|
||||
|
||||
|
||||
class TestModemServicePricing:
|
||||
"""Тесты расчёта цены модема."""
|
||||
|
||||
def test_calculate_price_one_month(self, monkeypatch):
|
||||
"""Расчёт цены на 1 месяц."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 10000
|
||||
assert result.final_price == 10000
|
||||
assert result.charged_months == 1
|
||||
assert result.discount_percent == 0
|
||||
assert not result.has_discount
|
||||
|
||||
def test_calculate_price_three_months(self, monkeypatch):
|
||||
"""Расчёт цены на 3 месяца."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000 # 3 * 10000
|
||||
assert result.charged_months == 3
|
||||
|
||||
def test_calculate_price_with_discount(self, monkeypatch):
|
||||
"""Расчёт цены со скидкой."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000
|
||||
assert result.discount_percent == 10
|
||||
assert result.discount_amount == 3000
|
||||
assert result.final_price == 27000
|
||||
assert result.has_discount
|
||||
|
||||
|
||||
class TestModemServiceBalance:
|
||||
"""Тесты проверки баланса."""
|
||||
|
||||
def test_check_balance_sufficient(self, monkeypatch):
|
||||
"""Баланса достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
def test_check_balance_insufficient(self, monkeypatch):
|
||||
"""Баланса недостаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 5000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert not has_funds
|
||||
assert missing == 5000
|
||||
|
||||
def test_check_balance_zero_price(self, monkeypatch):
|
||||
"""Нулевая цена - всегда достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 0
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 0)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
|
||||
class TestModemServicePeriodWarning:
|
||||
"""Тесты предупреждений о сроке действия."""
|
||||
|
||||
def test_warning_critical(self, monkeypatch):
|
||||
"""Критическое предупреждение при <= 7 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(7) == 'critical'
|
||||
assert modem_service.get_period_warning_level(5) == 'critical'
|
||||
assert modem_service.get_period_warning_level(1) == 'critical'
|
||||
|
||||
def test_warning_info(self, monkeypatch):
|
||||
"""Информационное предупреждение при <= 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(30) == 'info'
|
||||
assert modem_service.get_period_warning_level(15) == 'info'
|
||||
assert modem_service.get_period_warning_level(8) == 'info'
|
||||
|
||||
def test_warning_none(self, monkeypatch):
|
||||
"""Нет предупреждения при > 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(31) is None
|
||||
assert modem_service.get_period_warning_level(60) is None
|
||||
assert modem_service.get_period_warning_level(90) is None
|
||||
|
||||
|
||||
class TestModemServiceEnable:
|
||||
"""Тесты подключения модема."""
|
||||
|
||||
async def test_enable_modem_success(self, monkeypatch):
|
||||
"""Успешное подключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=True)
|
||||
mock_create_transaction = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction)
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert result.charged_amount == 10000
|
||||
assert sample_subscription.modem_enabled is True
|
||||
assert sample_subscription.device_limit == 3 # было 2, стало 3
|
||||
|
||||
async def test_enable_modem_insufficient_funds(self, monkeypatch):
|
||||
"""Недостаточно средств для подключения."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 1000 # недостаточно
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.INSUFFICIENT_FUNDS
|
||||
|
||||
async def test_enable_modem_charge_error(self, monkeypatch):
|
||||
"""Ошибка списания средств."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=False) # ошибка списания
|
||||
|
||||
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
|
||||
|
||||
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.CHARGE_ERROR
|
||||
|
||||
|
||||
class TestModemServiceDisable:
|
||||
"""Тесты отключения модема."""
|
||||
|
||||
async def test_disable_modem_success(self, monkeypatch):
|
||||
"""Успешное отключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_subscription.device_limit = 3
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert sample_subscription.modem_enabled is False
|
||||
assert sample_subscription.device_limit == 2 # было 3, стало 2
|
||||
|
||||
|
||||
class TestModemServiceSingleton:
|
||||
"""Тесты singleton паттерна."""
|
||||
|
||||
def test_get_modem_service_returns_same_instance(self, monkeypatch):
|
||||
"""get_modem_service возвращает один и тот же экземпляр."""
|
||||
# Сбрасываем глобальный экземпляр
|
||||
import app.services.modem_service as modem_module
|
||||
|
||||
modem_module._modem_service = None
|
||||
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
|
||||
service1 = get_modem_service()
|
||||
service2 = get_modem_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
|
||||
class TestModemEnabledGetter:
|
||||
"""Тесты безопасного получения статуса модема."""
|
||||
|
||||
def test_get_modem_enabled_true(self, monkeypatch):
|
||||
"""Модем включён."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is True
|
||||
|
||||
def test_get_modem_enabled_false(self, monkeypatch):
|
||||
"""Модем выключен."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is False
|
||||
|
||||
def test_get_modem_enabled_none_subscription(self, monkeypatch):
|
||||
"""Подписка None."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_modem_enabled(None) is False
|
||||
|
||||
def test_get_modem_enabled_no_attribute(self, monkeypatch):
|
||||
"""У подписки нет атрибута modem_enabled."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
subscription = SimpleNamespace(id=1) # без modem_enabled
|
||||
|
||||
assert modem_service.get_modem_enabled(subscription) is False
|
||||
@@ -13,7 +13,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiogram"
|
||||
version = "3.24.0"
|
||||
version = "3.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -23,9 +23,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/31/360c4ce76e60e9e7bcdda1af1ab4331d78837fbb22847a62121ad32b7672/aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c", size = 1721010, upload-time = "2026-02-10T21:50:25.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/be/1090252415e192687985517162dbdcee2ec4150cda1fa52bf57ae1f1c2a8/aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce", size = 713972, upload-time = "2026-02-10T21:50:23.253Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -94,16 +94,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.18.1"
|
||||
version = "1.18.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -413,17 +413,18 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.128.0"
|
||||
version = "0.129.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -977,11 +978,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1105,16 +1106,16 @@ pil = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.1.0"
|
||||
version = "7.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.8.0"
|
||||
version = "3.10.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
@@ -1145,22 +1146,22 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiogram", specifier = ">=3.22.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||
{ name = "alembic", specifier = ">=1.16.5" },
|
||||
{ name = "asyncpg", specifier = ">=0.30.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "packaging", specifier = ">=23.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "aiogram", specifier = ">=3.25.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.22.1" },
|
||||
{ name = "alembic", specifier = ">=1.18.4" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "bcrypt", specifier = ">=5.0.0" },
|
||||
{ name = "cryptography", specifier = ">=44.0.1" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
|
||||
{ name = "packaging", specifier = ">=26.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.11.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "pyzipper", specifier = ">=0.3.6" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" },
|
||||
{ name = "redis", specifier = ">=5.0.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.43" },
|
||||
{ name = "yookassa", specifier = ">=3.9.0" },
|
||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||
{ name = "redis", specifier = ">=7.1.1" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||
{ name = "yookassa", specifier = ">=3.10.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1545,7 +1546,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "yookassa"
|
||||
version = "3.9.0"
|
||||
version = "3.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
@@ -1554,4 +1555,4 @@ dependencies = [
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/05/cbacdfd7d5478956e184cb9e1e99321e1ae14f325cb8eadaf6e7a64206ab/yookassa-3.9.0.tar.gz", hash = "sha256:e8a78fcd96543a5700a4ae9e1411b54f729998f4c051c06c182b9946926e67f5", size = 139247, upload-time = "2025-12-17T09:11:55.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/d7/d5ee3948cbf0379a6af4e0a6da2c39e2e455a84b10651c8ea75c75fea50f/yookassa-3.10.0.tar.gz", hash = "sha256:5d138fa568129989688f632122719741a8ad983bb7c1f1f1b6294e7a83d67915", size = 140883, upload-time = "2026-01-28T12:23:50.413Z" }
|
||||
|
||||
Reference in New Issue
Block a user