Compare commits
421 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7587aec913 | |||
| 7186c93078 | |||
| 727e20584f | |||
| 9c13775eec | |||
| e767ef7d92 | |||
| ce22df4d60 | |||
| 7c4567ae70 | |||
| af1125f6ed | |||
| ee65e65108 | |||
| 5c1f7d54ba | |||
| 21e5af9e9e | |||
| f7d674adf4 | |||
| 5a7de9731a | |||
| f987dc2716 | |||
| bbdc1274e4 | |||
| feb95fe105 | |||
| 91b0d3782a | |||
| a794f5da70 | |||
| e571edb354 | |||
| 84dece39a7 | |||
| f49bf21caa | |||
| 64da0ef94f | |||
| 3177b5ab80 | |||
| ec1b459689 | |||
| 08f7f87035 | |||
| 59189cae2a | |||
| 573dcd034a | |||
| 0f29a8a33b | |||
| 98ec7e4165 | |||
| 46c7d53f6e | |||
| 61758b8c2a | |||
| 42ac9555ac | |||
| f7416f2255 | |||
| 509c92217f | |||
| c0d5ae1e9e | |||
| cf9fbc5ccc | |||
| ad19be83d2 | |||
| 3dd5e41bda | |||
| 639a45611e | |||
| 773299c317 | |||
| ff39b10761 | |||
| 5fd5472fd3 | |||
| adf94802b0 | |||
| 15378a412f | |||
| 32da16f652 | |||
| 075d92e4bd | |||
| a7cbcd284c | |||
| 5682d508a3 | |||
| c738b5703f | |||
| 6875fce2cb | |||
| 57b7ad6732 | |||
| 8377db323b | |||
| 78d768ea4d | |||
| 9dee654eee | |||
| a02f49916a | |||
| 8d25e9b760 | |||
| 62ebbb2b5f | |||
| e62caa725f | |||
| db5f81e6be | |||
| 15cba3da22 | |||
| ca93182f68 | |||
| b1a2b6774d | |||
| ebc98099d3 | |||
| e2137f94f8 | |||
| 3fdc081548 | |||
| 57cc2d2e3a | |||
| b883d2a95c | |||
| cfcb35c7ee | |||
| 7f4899b885 | |||
| 719d656a64 | |||
| 04476ed463 | |||
| 7ef7b1d107 | |||
| 0cb9ab5704 | |||
| 4d109b948a | |||
| efd8b191ff | |||
| 8273a31512 | |||
| 7baebd85c7 | |||
| e43f6afd8a | |||
| ec3430db27 | |||
| a2c0551349 | |||
| a66719b865 | |||
| b201543014 | |||
| e269f31056 | |||
| 0c1db66be6 | |||
| 5e46507f04 | |||
| f3616abec2 | |||
| 86ec46dbe7 | |||
| 863b5df0c2 | |||
| 929b3b8e95 | |||
| f79393c46e | |||
| 559dd1df71 | |||
| cb959bdee7 | |||
| ac1b0ec8b3 | |||
| fe779a29b4 | |||
| b4a1d10037 | |||
| 03856d30ec | |||
| e2e3e444ec | |||
| cd98d150d6 | |||
| 92814ae535 | |||
| 3268c2a509 | |||
| a09b6c1431 | |||
| 31c560093d | |||
| 728bd0450d | |||
| 3df3fdbcfe | |||
| 296756dac4 | |||
| 7cb766f3d0 | |||
| 5e94b287b9 | |||
| e9eb8f17d3 | |||
| dfbaf9de21 | |||
| 4dcca0da4a | |||
| ea85d4f62e | |||
| bef1a3ecd9 | |||
| 22be75508e | |||
| dba8241c5c | |||
| b3d0348821 | |||
| 3006a438e2 | |||
| d2f74481cd | |||
| d29ad223a7 | |||
| c62ccd0790 | |||
| 8ff240cbe9 | |||
| 4a5769d37e | |||
| 7d27ee7937 | |||
| a927a6fa70 | |||
| be4db68d00 | |||
| 6e3807cf78 | |||
| ff9adfc30f | |||
| a6037b60eb | |||
| a0b95cc24c | |||
| a2536b576e | |||
| 0e544040fc | |||
| 940957fa98 | |||
| 2e6b808f6d | |||
| 9ff4734659 | |||
| 7a08f483b9 | |||
| c2b10e969e | |||
| 7bf9b10963 | |||
| 6402cbef06 | |||
| b8855bf73b | |||
| 5453efadb5 | |||
| 8246405c31 | |||
| 777f16f3c1 | |||
| 11860ff21a | |||
| 0a6df56dd2 | |||
| 9dd643ecab | |||
| d8d01384b1 | |||
| d0b3bbd488 | |||
| e8c458f2c4 | |||
| e324e50713 | |||
| d506ba7317 | |||
| 2c960813de | |||
| 1d98bc75b0 | |||
| 19dddb3cc0 | |||
| 863dd2423d | |||
| 4cc5e92b83 | |||
| fe626f3442 | |||
| 50ad9a7a92 | |||
| a73ec96f5c | |||
| 1d072d4544 | |||
| 4997acd8bc | |||
| 94e202c596 | |||
| 9044060816 | |||
| 3be1a34725 | |||
| 41405b83ef | |||
| 2133355fbb | |||
| dd7e9ad43f | |||
| 460b8208d3 | |||
| 5c040a3b03 | |||
| 7dd7643a76 | |||
| 12a6ea1097 | |||
| fb71414096 | |||
| a814a95c34 | |||
| 3732359043 | |||
| 18d637c2fb | |||
| 48a7d2a112 | |||
| 4639020a8e | |||
| 27da3def3c | |||
| 77027adad3 | |||
| 76dd83ac58 | |||
| eb40143b76 | |||
| 1cda346174 | |||
| f534abdb6f | |||
| 5204a38528 | |||
| fcb9c1e3b7 | |||
| 5af41f8d4e | |||
| 592c9de77a | |||
| f67f4edc21 | |||
| a251835a79 | |||
| ba8a57e381 | |||
| 75f581924e | |||
| 10f2a8327c | |||
| 127a16b4d6 | |||
| f9ded2b857 | |||
| 622412992f | |||
| 5156c3481c | |||
| 1d3d4ac36e | |||
| 3449dc9d2c | |||
| acd01910ac | |||
| e16bcafce0 | |||
| b8e664aad8 | |||
| b365394ff4 | |||
| e53549d7cc | |||
| 743e99af85 | |||
| 9616fe8d44 | |||
| 4ae6361599 | |||
| 89c12e6ba9 | |||
| 5d6bc82d8a | |||
| a4f3fe5a49 | |||
| 3a58a3490a | |||
| e2408702ea | |||
| 3f1f27e5aa | |||
| bbd0ec2edd | |||
| eb22b19d98 | |||
| 45b9d44fd0 | |||
| a92003f0f5 | |||
| 4ac9387ef2 | |||
| a24b5e8145 | |||
| 7244e3978b | |||
| 41cabf5e0e | |||
| 99c7dc4b29 | |||
| 338bc03cd6 | |||
| 985260144e | |||
| b487925fbf | |||
| 07a89443b3 | |||
| 54ebb2d96d | |||
| 34a7cd1f6c | |||
| 455b8fc709 | |||
| 591b422302 | |||
| fc73c34a03 | |||
| cc1c6e3088 | |||
| 966f7124c4 | |||
| 1cebec5af4 | |||
| d31b5097ff | |||
| 21a8fe4025 | |||
| 6a4adcfe0c | |||
| c9acd46cd3 | |||
| 6018113a3d | |||
| fe0825957a | |||
| d0cef51e35 | |||
| 2aa0d11f4c | |||
| b8a1aa3f3c | |||
| 80e95bc77b | |||
| 87af0312cb | |||
| e988c9aa14 | |||
| cdee98f690 | |||
| 268c619ad6 | |||
| 352434ae35 | |||
| b1e39bae32 | |||
| 44a2c929c4 | |||
| 42a9865fac | |||
| c730863162 | |||
| b58db19127 | |||
| 2820c2d942 | |||
| 0be172c675 | |||
| b26fd77190 | |||
| 9317a1703a | |||
| f166279507 | |||
| 60216519af | |||
| ed83d31b22 | |||
| cf6e2955c7 | |||
| 4e3a183e2b | |||
| 0879855d3f | |||
| 2358552961 | |||
| 35b461b60c | |||
| 1b5cfc3920 | |||
| 4a179ccc1e | |||
| 4921578af3 | |||
| 55fb1f2044 | |||
| ad38aae7b0 | |||
| 7e0840ef09 | |||
| 03fcb5f2f7 | |||
| 0c4ea86e03 | |||
| 209bb9763e | |||
| 187de6807e | |||
| b8c9302018 | |||
| a86fccffc0 | |||
| a448adec93 | |||
| d45ff62a57 | |||
| 3c50fc6a71 | |||
| 292a120f74 | |||
| a7868787a4 | |||
| 5275f4fbeb | |||
| ba25ec7ad0 | |||
| 301b4124ef | |||
| a936a97e5e | |||
| c390d18399 | |||
| 43df9bff48 | |||
| c15a9ea7cd | |||
| 271742b306 | |||
| ebdadd81c0 | |||
| 1364855303 | |||
| b5d862b231 | |||
| 048a8adaa1 | |||
| 4b7b999d43 | |||
| d02fa83af5 | |||
| 89045095b1 | |||
| 0de721e67d | |||
| c7c171a1d3 | |||
| a2500148fd | |||
| 99b8a3726b | |||
| 8b426e0f6c | |||
| caca561997 | |||
| 385bd78021 | |||
| 8ca127e93f | |||
| ac9b8aaf68 | |||
| 4939f56f1b | |||
| 659e950722 | |||
| 5186ae4334 | |||
| 64cd38e2c3 | |||
| cc29c887c5 | |||
| 7fd7dd7cc8 | |||
| 9ac3088b72 | |||
| 9745885f1b | |||
| 818189e8e0 | |||
| 2ccaaebfc9 | |||
| 82e75f2170 | |||
| 1b265fd95b | |||
| 7a8905e0bc | |||
| 6d8ba6d30c | |||
| 74544c9a9e | |||
| 3314bb7694 | |||
| c920bac33e | |||
| e6881ef617 | |||
| 94a7bb43f6 | |||
| 97841f0cbe | |||
| de392bd4de | |||
| f2a3e5f2fc | |||
| 20a2566fe6 | |||
| 96bd411839 | |||
| 773196fd84 | |||
| 26dc8b4963 | |||
| 06a9202c7c | |||
| 634786917d | |||
| 4ea839c30a | |||
| 10f6ea382b | |||
| 929414e31f | |||
| cd87a3e773 | |||
| 7a3168dcfd | |||
| 888f8fb01a | |||
| 6f06ec5aa0 | |||
| e10cb3f2bb | |||
| 2edd3cb5ab | |||
| d6d3dc83f5 | |||
| ae392691b1 | |||
| 35a202d280 | |||
| e9a2d8a232 | |||
| b76b99ca2e | |||
| 8960ea9c10 | |||
| 9f94acb580 | |||
| c9811fd518 | |||
| e25fb1b178 | |||
| 779a03d008 | |||
| f2a972aeea | |||
| 2a9163a28b | |||
| 20a628089d | |||
| cf01d917d1 | |||
| 1544da7e12 | |||
| 94a533508e | |||
| 98cdfcff81 | |||
| 23ddaa81be | |||
| 667e988c47 | |||
| ea6b20fefa | |||
| e734763eaf | |||
| cb98efc47e | |||
| dbd3d87779 | |||
| 533ae6dd80 | |||
| ca62321b42 | |||
| cd3d2c3731 | |||
| 8b8d29a75d | |||
| 3f97757ec0 | |||
| d5f3299bd2 | |||
| 0b99965731 | |||
| 8f679832fb | |||
| 46564a8e91 | |||
| 09b0dbb60c | |||
| 28d53e7e3f | |||
| 43de39f4ec | |||
| 49df0100c9 | |||
| c020f33c86 | |||
| 813daef2b1 | |||
| 13d4e934ec | |||
| 7721464611 | |||
| 9e42c3e7d1 | |||
| badbc150ab | |||
| 5d57c88e4b | |||
| ec18f82d26 | |||
| 846e5e6bf0 | |||
| 16b6df4d05 | |||
| 9e45bd6d75 | |||
| d01436bf14 | |||
| f50e170f8d | |||
| 2dd76d8bd2 | |||
| b9956391f0 | |||
| fdfd0e2355 | |||
| 548dd2fdc6 | |||
| cdcdd7abdc | |||
| d5c8795e75 | |||
| ceb0ac7538 | |||
| 305026c37d | |||
| f95367d12e | |||
| ac962c43e8 | |||
| 26efc70042 | |||
| 7f431e1485 | |||
| 1292046646 | |||
| d54b455007 | |||
| ca55d8942e | |||
| f00fdcb7c8 | |||
| 5443fdf064 | |||
| c33e49661c | |||
| 92bd0c362e | |||
| fe8cafdd99 | |||
| f0f8c4e4cf | |||
| f8e2e23082 | |||
| b915c65c41 | |||
| 4e7154e817 | |||
| 7992cf9e26 | |||
| ea958ea6c0 | |||
| 9b32de5fb2 | |||
| b71d0d058f | |||
| d36e353d9b | |||
| 02106bd64b | |||
| e68d6ae19a |
+84
-15
@@ -5,6 +5,7 @@
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# Уведомления администраторов
|
||||
@@ -12,6 +13,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
@@ -28,15 +34,37 @@ POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
LOCALES_PATH=./locales
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
REMNAWAVE_PASSWORD=
|
||||
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# Режим удаления пользователей из панели RemnaWave
|
||||
# delete - полностью удалить пользователя из панели
|
||||
# disable - только деактивировать пользователя
|
||||
REMNAWAVE_USER_DELETE_MODE=delete
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
@@ -126,6 +154,9 @@ YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Включить оплату через СБП (Систему быстрых платежей)
|
||||
YOOKASSA_SBP_ENABLED=false
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
@@ -167,6 +198,13 @@ YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Лимиты сумм пополнения через YooKassa (в копейках)
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS=1000000
|
||||
|
||||
# Быстрый выбор суммы пополнения через YooKassa
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
@@ -183,23 +221,35 @@ CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8083
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
SKIP_REFERRAL_CODE=false
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
@@ -218,9 +268,38 @@ MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
|
||||
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# Отправка бэкапов в телеграм
|
||||
BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
@@ -229,13 +308,3 @@ LOG_FILE=logs/bot.log
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
python-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "10:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "09:00"
|
||||
timezone: "Europe/Moscow"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "Fr1ngg"
|
||||
assignees:
|
||||
- "Fr1ngg"
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -24,18 +24,15 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: |
|
||||
network=host
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -50,31 +47,26 @@ jobs:
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.2.6"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ PR из того же репозитория - будем пушить"
|
||||
else
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR из внешнего форка - только build без push"
|
||||
fi
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR - only build without push"
|
||||
else
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Push/Tag - будем пушить"
|
||||
echo "✅ Push/Tag - will push"
|
||||
fi
|
||||
|
||||
- name: Extract metadata
|
||||
@@ -93,11 +85,11 @@ jobs:
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -105,18 +97,12 @@ jobs:
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: |
|
||||
type=gha
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: |
|
||||
type=gha,mode=max
|
||||
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
build-contexts: |
|
||||
alpine=docker-image://alpine:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Generate security report
|
||||
uses: docker/scout-action@v1
|
||||
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
command: quickview,compare
|
||||
image: ${{ steps.meta.outputs.tags }}
|
||||
@@ -125,30 +111,31 @@ jobs:
|
||||
only-severities: critical,high
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build Summary
|
||||
if: steps.version.outputs.should_push == 'true'
|
||||
run: |
|
||||
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Image** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Branch** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Status** | ✅ Published |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Build Summary (No Push)
|
||||
if: steps.version.outputs.should_push == 'false'
|
||||
run: |
|
||||
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Version** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Commit** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Build Date** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Status** | ✅ Built successfully (without publishing) |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
|
||||
echo "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# Разрешаем папку app/ и все её содержимое рекурсивно
|
||||
!app/
|
||||
!app/**
|
||||
!locales/
|
||||
!locales/**
|
||||
|
||||
# Дополнительно разрешаем README и лицензию (опционально)
|
||||
!README.md
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
# 🤝 Contributing to Remnawave Bedolaga Bot
|
||||
|
||||
Спасибо за интерес к развитию проекта! Этот документ содержит правила и рекомендации для контрибьюторов.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Кодекс поведения](#-кодекс-поведения)
|
||||
- [Как помочь проекту](#-как-помочь-проекту)
|
||||
- [Настройка среды разработки](#-настройка-среды-разработки)
|
||||
- [Стандарты кода](#-стандарты-кода)
|
||||
- [Работа с Git](#-работа-с-git)
|
||||
- [Тестирование](#-тестирование)
|
||||
- [Процесс ревью](#-процесс-ревью)
|
||||
- [Документация](#-документация)
|
||||
|
||||
## 📜 Кодекс поведения
|
||||
|
||||
### Основные принципы
|
||||
|
||||
- **Уважение** - относитесь к участникам проекта с уважением
|
||||
- **Конструктивность** - предлагайте решения, а не только критику
|
||||
- **Открытость** - будьте открыты к обратной связи и новым идеям
|
||||
- **Профессионализм** - поддерживайте высокий уровень обсуждений
|
||||
|
||||
### Недопустимое поведение
|
||||
|
||||
- Оскорбления и личные атаки
|
||||
- Спам и офф-топик сообщения
|
||||
- Публикация приватной информации
|
||||
- Любые формы дискриминации
|
||||
|
||||
## 🚀 Как помочь проекту
|
||||
|
||||
### 🐛 Сообщения о багах
|
||||
|
||||
Перед созданием issue проверьте:
|
||||
- [ ] Аналогичная проблема не была описана ранее
|
||||
- [ ] Вы используете актуальную версию бота
|
||||
- [ ] Проблема воспроизводится стабильно
|
||||
|
||||
**Шаблон для багрепорта:**
|
||||
|
||||
```markdown
|
||||
## 🐛 Описание бага
|
||||
Краткое описание проблемы
|
||||
|
||||
## 🔄 Шаги воспроизведения
|
||||
1. Перейти к '...'
|
||||
2. Нажать на '...'
|
||||
3. Увидеть ошибку
|
||||
|
||||
## ✅ Ожидаемое поведение
|
||||
Что должно было произойти
|
||||
|
||||
## ❌ Фактическое поведение
|
||||
Что произошло на самом деле
|
||||
|
||||
## 🌍 Окружение
|
||||
- Версия бота: [например, 1.0.0]
|
||||
- Python версия: [например, 3.11.7]
|
||||
- ОС: [например, Ubuntu 22.04]
|
||||
- Версия Docker: [если используется]
|
||||
|
||||
## 📋 Логи
|
||||
```
|
||||
Вставьте соответствующие логи
|
||||
```
|
||||
|
||||
## 📷 Скриншоты
|
||||
Если применимо, добавьте скриншоты
|
||||
```
|
||||
|
||||
### 💡 Предложения функций
|
||||
|
||||
Используйте лейбл `enhancement` и опишите:
|
||||
- **Проблему**, которую решает функция
|
||||
- **Предлагаемое решение**
|
||||
- **Альтернативы**, которые вы рассматривали
|
||||
- **Дополнительную информацию** и контекст
|
||||
|
||||
### 📝 Улучшение документации
|
||||
|
||||
- Исправление опечаток
|
||||
- Дополнение примеров
|
||||
- Перевод на другие языки
|
||||
- Улучшение структуры
|
||||
|
||||
## 🛠 Настройка среды разработки
|
||||
|
||||
### Требования
|
||||
|
||||
- Python 3.11+
|
||||
- Docker и Docker Compose
|
||||
- Git
|
||||
- PostgreSQL 15+ (опционально для локальной разработки)
|
||||
- Redis (опционально для локальной разработки)
|
||||
|
||||
### Установка
|
||||
|
||||
1. **Форкните и клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
```
|
||||
|
||||
2. **Создайте виртуальное окружение:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# или
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. **Установите зависимости:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt # если есть dev зависимости
|
||||
```
|
||||
|
||||
4. **Настройте окружение:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
```
|
||||
|
||||
5. **Запустите через Docker (рекомендуется):**
|
||||
```bash
|
||||
docker compose up -d postgres redis
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
bedolaga_bot/
|
||||
├── app/ # Основной код приложения
|
||||
│ ├── handlers/ # Обработчики сообщений
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ ├── database/ # Модели и CRUD операции
|
||||
│ ├── utils/ # Утилиты
|
||||
│ ├── middlewares/ # Middleware
|
||||
│ └── external/ # Внешние API
|
||||
├── migrations/ # Миграции БД (если нужны)
|
||||
├── tests/ # Тесты (создать при необходимости)
|
||||
├── docs/ # Документация
|
||||
└── requirements.txt # Зависимости
|
||||
```
|
||||
|
||||
## 🎨 Стандарты кода
|
||||
|
||||
### Python стиль
|
||||
|
||||
Мы следуем **PEP 8** с некоторыми исключениями:
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
async def get_user_subscription(user_id: int) -> Optional[Subscription]:
|
||||
"""Получает активную подписку пользователя."""
|
||||
async with get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Subscription)
|
||||
.where(Subscription.user_id == user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
# ❌ Плохо
|
||||
async def getUserSub(uid):
|
||||
session = get_session()
|
||||
sub = session.query(Subscription).filter(Subscription.user_id==uid,Subscription.is_active==True).first()
|
||||
return sub
|
||||
```
|
||||
|
||||
### Правила именования
|
||||
|
||||
- **Функции и переменные**: `snake_case`
|
||||
- **Классы**: `PascalCase`
|
||||
- **Константы**: `UPPER_CASE`
|
||||
- **Приватные методы**: `_leading_underscore`
|
||||
|
||||
### Типизация
|
||||
|
||||
Обязательно используйте type hints:
|
||||
|
||||
```python
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
async def create_subscription(
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: Optional[int] = None
|
||||
) -> Subscription:
|
||||
"""Создает новую подписку."""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Документация кода
|
||||
|
||||
```python
|
||||
async def calculate_subscription_price(
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
devices_count: int,
|
||||
servers_count: int
|
||||
) -> int:
|
||||
"""
|
||||
Рассчитывает стоимость подписки.
|
||||
|
||||
Args:
|
||||
period_days: Период подписки в днях
|
||||
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
|
||||
devices_count: Количество устройств
|
||||
servers_count: Количество серверов
|
||||
|
||||
Returns:
|
||||
Стоимость в копейках
|
||||
|
||||
Raises:
|
||||
ValueError: Если переданы некорректные параметры
|
||||
"""
|
||||
# implementation
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
```python
|
||||
# ✅ Хорошо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("❌ Ошибка при создании подписки. Попробуйте позже.")
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Validation error: {e}")
|
||||
await message.answer("❌ Некорректные данные для создания подписки.")
|
||||
|
||||
# ❌ Плохо
|
||||
try:
|
||||
subscription = await subscription_service.create_subscription(user_id, data)
|
||||
await message.answer("✅ Подписка создана успешно!")
|
||||
except:
|
||||
await message.answer("Ошибка")
|
||||
```
|
||||
|
||||
### Логирование
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Уровни логирования
|
||||
logger.debug("Детальная информация для отладки")
|
||||
logger.info("Общая информация о работе")
|
||||
logger.warning("Предупреждение о потенциальной проблеме")
|
||||
logger.error("Ошибка, которая не прерывает работу")
|
||||
logger.critical("Критическая ошибка")
|
||||
```
|
||||
|
||||
## 🔄 Работа с Git
|
||||
|
||||
### Ветки
|
||||
|
||||
- `main` - стабильная версия
|
||||
- `dev` - разработка новых функций
|
||||
|
||||
### Коммиты
|
||||
|
||||
Используйте [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```bash
|
||||
# Типы коммитов
|
||||
feat: добавить поддержку CryptoBot платежей
|
||||
fix: исправить ошибку с расчетом цены подписки
|
||||
docs: обновить документацию по настройке
|
||||
style: исправить форматирование кода
|
||||
refactor: рефакторинг сервиса платежей
|
||||
test: добавить тесты для subscription_service
|
||||
chore: обновить зависимости
|
||||
|
||||
# Примеры
|
||||
git commit -m "feat(payments): добавить поддержку YooKassa webhook"
|
||||
git commit -m "fix(subscription): исправить расчет стоимости устройств"
|
||||
git commit -m "docs(readme): обновить инструкцию по установке"
|
||||
```
|
||||
|
||||
### Pull Request процесс
|
||||
|
||||
1. **Создайте ветку** от `dev`:
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/new-payment-method
|
||||
```
|
||||
|
||||
2. **Разработайте функцию** с соблюдением стандартов
|
||||
|
||||
3. **Протестируйте** изменения локально
|
||||
|
||||
4. **Создайте Pull Request** с описанием:
|
||||
|
||||
```markdown
|
||||
## 📝 Описание
|
||||
Краткое описание изменений
|
||||
|
||||
## 🎯 Мотивация
|
||||
Почему эти изменения нужны?
|
||||
|
||||
## 🔧 Тип изменений
|
||||
- [ ] Bug fix (исправление)
|
||||
- [ ] New feature (новая функция)
|
||||
- [ ] Breaking change (ломающие изменения)
|
||||
- [ ] Documentation update (обновление документации)
|
||||
|
||||
## ✅ Чеклист
|
||||
- [ ] Код соответствует стандартам проекта
|
||||
- [ ] Добавлены/обновлены тесты
|
||||
- [ ] Документация обновлена
|
||||
- [ ] Проверена работа в Docker
|
||||
- [ ] Проверена совместимость с существующим API
|
||||
|
||||
## 🧪 Тестирование
|
||||
Как тестировались изменения:
|
||||
- [ ] Локальное тестирование
|
||||
- [ ] Тестирование с реальным Remnawave API
|
||||
- [ ] Тестирование платежных систем
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### Локальное тестирование
|
||||
|
||||
```bash
|
||||
# Запуск с тестовой базой
|
||||
export DATABASE_URL="sqlite:///test.db"
|
||||
export BOT_TOKEN="test_token"
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Тестирование компонентов
|
||||
|
||||
```python
|
||||
# tests/test_subscription_service.py
|
||||
import pytest
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calculate_price():
|
||||
price = await SubscriptionService.calculate_subscription_price(
|
||||
period_days=30,
|
||||
traffic_gb=100,
|
||||
devices_count=3,
|
||||
servers_count=1
|
||||
)
|
||||
assert price > 0
|
||||
assert isinstance(price, int)
|
||||
```
|
||||
|
||||
### Integration тесты
|
||||
|
||||
Тестируйте интеграцию с:
|
||||
- Remnawave API (с тестовыми данными)
|
||||
- Базой данных
|
||||
- Платежными системами (sandbox режим)
|
||||
|
||||
## 👀 Процесс ревью
|
||||
|
||||
### Требования для ревьюера
|
||||
|
||||
- Проверить соответствие стандартам кода
|
||||
- Убедиться в работоспособности
|
||||
- Проверить безопасность (особенно для платежных функций)
|
||||
- Оценить производительность
|
||||
- Проверить совместимость с существующим API
|
||||
|
||||
### Требования для автора
|
||||
|
||||
- Ответить на все комментарии
|
||||
- Исправить замечания
|
||||
- Обновить документацию при необходимости
|
||||
- Убедиться в прохождении всех проверок
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
### Обновление документации
|
||||
|
||||
При добавлении новых функций обновляйте:
|
||||
|
||||
- `README.md` - если изменился API или конфигурация
|
||||
- Комментарии в коде
|
||||
- Примеры использования
|
||||
- Changelog (если есть)
|
||||
|
||||
### Стиль документации
|
||||
|
||||
- Используйте ясный и понятный язык
|
||||
- Добавляйте примеры кода
|
||||
- Указывайте возможные ошибки и их решения
|
||||
- Используйте эмодзи для улучшения читаемости
|
||||
|
||||
## 🏷 Лейблы Issues и PR
|
||||
|
||||
### Приоритет
|
||||
- `priority:high` - высокий приоритет
|
||||
- `priority:medium` - средний приоритет
|
||||
- `priority:low` - низкий приоритет
|
||||
|
||||
### Тип
|
||||
- `bug` - ошибка
|
||||
- `enhancement` - улучшение
|
||||
- `feature` - новая функция
|
||||
- `documentation` - документация
|
||||
- `question` - вопрос
|
||||
|
||||
### Область
|
||||
- `payments` - платежные системы
|
||||
- `api` - Remnawave API
|
||||
- `database` - база данных
|
||||
- `ui/ux` - интерфейс пользователя
|
||||
- `admin` - админ панель
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
### Сообщения о уязвимостях
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
- Свяжитесь с [@fringg](https://t.me/fringg) напрямую
|
||||
- Не создавайте публичные issues для уязвимостей
|
||||
- Дайте время на исправление перед публичным раскрытием
|
||||
|
||||
### Рекомендации по безопасности
|
||||
|
||||
- Никогда не коммитьте API ключи и пароли
|
||||
- Используйте переменные окружения для чувствительных данных
|
||||
- Валидируйте все пользовательские данные
|
||||
- Используйте HTTPS для всех внешних запросов
|
||||
|
||||
## 📞 Получение помощи
|
||||
|
||||
### Каналы связи
|
||||
|
||||
- **💬 Telegram Group:** [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - общие вопросы
|
||||
- **🐛 GitHub Issues:** Технические вопросы и баги
|
||||
- **📧 Прямой контакт:** [@fringg](https://t.me/fringg) - только критические вопросы
|
||||
|
||||
### Часто задаваемые вопросы
|
||||
|
||||
**Q: Как настроить локальную разработку без Docker?**
|
||||
A: Установите PostgreSQL и Redis локально, обновите DATABASE_URL и REDIS_URL в .env
|
||||
|
||||
**Q: Можно ли использовать SQLite для разработки?**
|
||||
A: Да, установите DATABASE_MODE=sqlite в .env
|
||||
|
||||
**Q: Как тестировать платежные системы?**
|
||||
A: Используйте тестовые/sandbox режимы платежных систем
|
||||
|
||||
**Q: Что делать, если тесты не проходят?**
|
||||
A: Проверьте конфигурацию .env и убедитесь, что все сервисы запущены
|
||||
|
||||
## 📋 Чеклист для контрибьюторов
|
||||
|
||||
Перед отправкой PR убедитесь:
|
||||
|
||||
- [ ] ✅ Код соответствует стандартам проекта
|
||||
- [ ] 📝 Добавлены комментарии и docstrings
|
||||
- [ ] 🧪 Функциональность протестирована
|
||||
- [ ] 📚 Документация обновлена
|
||||
- [ ] 🔒 Нет чувствительных данных в коде
|
||||
- [ ] 🐳 Изменения работают в Docker
|
||||
- [ ] 📋 PR создан с подробным описанием
|
||||
- [ ] 🏷 Проставлены соответствующие лейблы
|
||||
|
||||
## 🎉 Благодарности
|
||||
|
||||
Спасибо всем, кто вносит вклад в развитие проекта! Ваша помощь делает Bedolaga Bot лучше для всего сообщества.
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by community**
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-slim AS builder
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
@@ -12,9 +12,9 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.2.6"
|
||||
ARG VERSION="v2.3.6"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
EXPOSE 8081 8082
|
||||
EXPOSE 8081 8082 8083
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
|
||||
@@ -53,6 +53,19 @@
|
||||
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
|
||||
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
|
||||
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
|
||||
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
|
||||
- ✍️ **Проверка на подписку** - проверяет подписку на канал
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
Метод | Заголовок | Описание
|
||||
-- | -- | --
|
||||
API Key | X-Api-Key: your_api_key | Стандартный API ключ
|
||||
Bearer Token | Authorization: Bearer token | Классический Bearer token
|
||||
Basic Auth | X-Api-Key: Basic base64(user:pass) | Basic Authentication
|
||||
eGames Cookies | Cookies в формате key:value | Для панелей eGames
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -70,7 +83,9 @@ cp .env.example .env
|
||||
nano .env # Заполни токены и настройки
|
||||
|
||||
# 3. Создай необходимые директории
|
||||
mkdir -p logs data
|
||||
mkdir -p ./logs ./data ./data/backups ./data/referral_qr
|
||||
chmod -R 755 ./logs ./data
|
||||
sudo chown -R 1000:1000 ./logs ./data
|
||||
|
||||
# 4. Запусти всё разом
|
||||
docker compose up -d
|
||||
@@ -214,30 +229,60 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== TELEGRAM BOT =====
|
||||
BOT_TOKEN=
|
||||
ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== DATABASE =====
|
||||
# Для Docker используйте PostgreSQL:
|
||||
DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Для локального запуска без Docker используйте SQLite: sqlite+aiosqlite:///./bot.db
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
DATABASE_MODE=auto
|
||||
|
||||
# Пароли для Docker (PostgreSQL/Redis)
|
||||
# Основной URL (можно оставить пустым для автоматического выбора)
|
||||
DATABASE_URL=
|
||||
|
||||
# PostgreSQL настройки (для Docker и кастомных установок)
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB=remnawave_bot
|
||||
POSTGRES_USER=remnawave_user
|
||||
POSTGRES_PASSWORD=secure_password_123
|
||||
|
||||
# SQLite настройки (для локального запуска)
|
||||
SQLITE_PATH=./data/bot.db
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# ===== REMNAWAVE API =====
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
REMNAWAVE_PASSWORD=
|
||||
|
||||
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=your_secret_key_here
|
||||
REMNAWAVE_SECRET_KEY=
|
||||
|
||||
# Шаблон описания пользователя в панели Remnawave
|
||||
# Доступные плейсхолдеры:
|
||||
# {full_name} — Имя, Фамилия из Telegram
|
||||
# {username} — @логин из Telegram (c @)
|
||||
# {username_clean} — логин из Telegram (без @)
|
||||
# {telegram_id} — ID Telegram
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE="Bot user: {full_name} {username}"
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
@@ -250,7 +295,7 @@ TRIAL_SQUAD_UUID=
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств доступных к покупке (0 = Нет лимита)
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
@@ -309,18 +354,6 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
|
||||
|
||||
# ===== ПЛАТЕЖНЫЕ СИСТЕМЫ =====
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8083
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# Telegram Stars (работает автоматически)
|
||||
TELEGRAM_STARS_ENABLED=true
|
||||
TELEGRAM_STARS_RATE_RUB=1.3
|
||||
@@ -339,10 +372,44 @@ YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Включить оплату через СБП (Систему быстрых платежей)
|
||||
YOOKASSA_SBP_ENABLED=false
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
# 1 - НДС не облагается
|
||||
# 2 - НДС 0%
|
||||
# 3 - НДС 10%
|
||||
# 4 - НДС 20%
|
||||
# 5 - НДС 10/110
|
||||
# 6 - НДС 20/120
|
||||
|
||||
YOOKASSA_PAYMENT_MODE=full_payment
|
||||
# Способы расчета:
|
||||
# full_payment - полная оплата
|
||||
# partial_payment - частичная оплата
|
||||
# advance - аванс
|
||||
# full_prepayment - полная предоплата
|
||||
# partial_prepayment - частичная предоплата
|
||||
# credit - передача в кредит
|
||||
# credit_payment - оплата кредита
|
||||
|
||||
YOOKASSA_PAYMENT_SUBJECT=service
|
||||
# Предметы расчета:
|
||||
# commodity - товар
|
||||
# excise - подакцизный товар
|
||||
# job - работа
|
||||
# service - услуга
|
||||
# gambling_bet - ставка в азартной игре
|
||||
# gambling_prize - выигрыш в азартной игре
|
||||
# lottery - лотерейный билет
|
||||
# lottery_prize - выигрыш в лотерее
|
||||
# intellectual_activity - результат интеллектуальной деятельности
|
||||
# payment - платеж
|
||||
# agent_commission - агентское вознаграждение
|
||||
# composite - составной предмет расчета
|
||||
# another - другое
|
||||
|
||||
# Webhook настройки
|
||||
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
|
||||
@@ -350,24 +417,50 @@ YOOKASSA_WEBHOOK_PORT=8082
|
||||
YOOKASSA_WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
PAYMENT_SERVICE_NAME=Интернет-сервис
|
||||
PAYMENT_BALANCE_DESCRIPTION=Пополнение баланса
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION=Оплата подписки
|
||||
PAYMENT_BALANCE_TEMPLATE={service_name} - {description}
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
|
||||
# CRYPTOBOT
|
||||
CRYPTOBOT_ENABLED=true
|
||||
CRYPTOBOT_API_TOKEN=123456789:AAzQcZWQqQAbsfgPnOLr4FHC8Doa4L7KryC
|
||||
CRYPTOBOT_WEBHOOK_SECRET=your_webhook_secret_here
|
||||
CRYPTOBOT_BASE_URL=https://pay.crypt.bot
|
||||
CRYPTOBOT_TESTNET=false
|
||||
CRYPTOBOT_WEBHOOK_PATH=/cryptobot-webhook
|
||||
CRYPTOBOT_WEBHOOK_PORT=8081
|
||||
CRYPTOBOT_DEFAULT_ASSET=USDT
|
||||
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
|
||||
# ===== ИНТЕРФЕЙС И UX =====
|
||||
|
||||
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
|
||||
HIDE_SUBSCRIPTION_LINK=false
|
||||
|
||||
# Режим работы кнопки "Подключиться"
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
|
||||
# Пропустить принятие правил использования бота
|
||||
SKIP_RULES_ACCEPT=false
|
||||
# Пропустить запрос реферального кода
|
||||
SKIP_REFERRAL_CODE=false
|
||||
|
||||
# ===== МОНИТОРИНГ И УВЕДОМЛЕНИЯ =====
|
||||
MONITORING_INTERVAL=60
|
||||
INACTIVE_USER_DELETE_MONTHS=3
|
||||
@@ -386,9 +479,38 @@ MAINTENANCE_AUTO_ENABLE=true
|
||||
MAINTENANCE_MESSAGE=Ведутся технические работы. Сервис временно недоступен. Попробуйте позже.
|
||||
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
# ===== СИСТЕМА БЕКАПОВ =====
|
||||
BACKUP_AUTO_ENABLED=true
|
||||
BACKUP_INTERVAL_HOURS=24
|
||||
BACKUP_TIME=03:00
|
||||
# Первый запуск автобекапа выполняется в ближайшее время BACKUP_TIME после старта бота,
|
||||
# далее копии создаются каждые BACKUP_INTERVAL_HOURS.
|
||||
BACKUP_MAX_KEEP=7
|
||||
BACKUP_COMPRESSION=true
|
||||
BACKUP_INCLUDE_LOGS=false
|
||||
BACKUP_LOCATION=/app/data/backups
|
||||
|
||||
# Отправка бэкапов в телеграм
|
||||
BACKUP_SEND_ENABLED=true
|
||||
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
|
||||
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
@@ -398,15 +520,7 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
VERSION_CHECK_ENABLED=true
|
||||
VERSION_CHECK_REPO=fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
VERSION_CHECK_INTERVAL_HOURS=1
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -424,33 +538,37 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
🛒 **Умная покупка подписок**
|
||||
- 📅 Гибкие периоды (14-360 дней)
|
||||
- 📊 Выбор трафика или фиксированный лимит
|
||||
- 🌍 Автоматический выбор серверов(для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств
|
||||
- 🌍 Автоматический выбор серверов (для одного сквада) или ручной выбор из множества
|
||||
- 📱 Настройка количества устройств и серверов прямо при оформлении
|
||||
- 🧾 Понятный калькулятор стоимости с учетом всех опций
|
||||
|
||||
🧪 **Тестовая подписка**
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении
|
||||
- Плавный переход на платную версию
|
||||
- Уведомления об истечении и сценарий мягкой конверсии в платную подписку
|
||||
- Приветственное сообщение с предложением активации триала (с возможностью отключения)
|
||||
- Поддержка рекламных кампаний с автоматической выдачей бонусных подписок
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa
|
||||
- 🎁 Реферальные бонусы
|
||||
- Детальная история транзакций
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa (включая СБП и онлайн-чек)
|
||||
- 💰 CryptoBot (мультивалюта и срок жизни инвойсов)
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- Детальная история транзакций и чеков
|
||||
|
||||
📱 **Управление подписками**
|
||||
- 📈 Статистика использования в реальном времени (Используемый трафик, подключенные устройства)
|
||||
- 🔄 Автопродление с баланса
|
||||
- 🔄 Управление трафиком
|
||||
- 🌍 Переключение серверов на лету
|
||||
- 📈 Статистика использования в реальном времени (трафик, устройства, серверы)
|
||||
- 🔄 Автопродление с баланса с настройкой дней и мгновенными уведомлениями
|
||||
- 🔄 Управление трафиком и лимитами без участия оператора
|
||||
- 🌍 Переключение серверов Remnawave на лету
|
||||
- 📱 Управление устройствами + сброс HWID
|
||||
- 🧩 Смена языка и персональных настроек
|
||||
|
||||
🎁 **Бонусная система**
|
||||
- 🎫 Промокоды на деньги/дни/триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями
|
||||
- 🔔 Своевременные уведомления
|
||||
- 📊 Детальная статистика рефералов
|
||||
- 🎫 Промокоды на деньги/дни/длинный триал
|
||||
- 👥 Защищенная реферальная программа с комиссиями и антифродом
|
||||
- 📊 Детальная статистика рефералов и кампаний
|
||||
- 📨 Персональные рассылки и уведомления о новых бонусах
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -458,105 +576,97 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
📊 **Мощная аналитика**
|
||||
- 👥 Детальная статистика пользователей
|
||||
- 💰 Анализ подписок и платежей
|
||||
- 🖥️ Мониторинг серверов Remnawave
|
||||
- 📈 Финансовые отчеты и тренды
|
||||
- 👥 Детальная статистика пользователей и подписок
|
||||
- 💰 Анализ платежей по источникам (Stars, YooKassa, Tribute, CryptoBot)
|
||||
- 🖥️ Мониторинг серверов Remnawave и статуса сквадов
|
||||
- 📈 Финансовые отчеты, конверсии и эффективность рекламных кампаний
|
||||
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами
|
||||
- 📱 **НОВОЕ**: Изменение лимита устройств (1-X)
|
||||
- 📊 **НОВОЕ**: Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 **НОВОЕ**: Мультивыбор серверов
|
||||
- 🔄 **НОВОЕ**: Сброс HWID устройств
|
||||
- 👥 **НОВОЕ**: Детальная реферальная статистика в профиле пользователя
|
||||
- 🚫 Блокировка/разблокировка/удаление
|
||||
- 💰 Управление балансами и ручные начисления
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ) и подключенных сквадов
|
||||
- 🌍 Мультивыбор серверов и мгновенная синхронизация с панелью
|
||||
- 🔄 Сброс HWID устройств и обновление конфигураций
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 Изменение типа подписки триал/платная и ручное продление
|
||||
- 🚫 Блокировка/разблокировка/удаление аккаунтов
|
||||
|
||||
🎫 **Промо-система**
|
||||
- 🎁 Создание промокодов (деньги/дни/длинный триал)
|
||||
- 📊 Детальная статистика использования
|
||||
- ⚙️ Полное редактирование промокодов
|
||||
🎯 **Маркетинг и продажи**
|
||||
- 🎫 Гибкая промо-система (деньги/дни/длинный триал)
|
||||
- 📣 Рекламные кампании с автоматическими бонусами за регистрацию
|
||||
- 📊 Детальная статистика использования промокодов и кампаний
|
||||
- 📨 Рассылки по сегментам с фильтрами по активности, подпискам и языкам
|
||||
|
||||
🖥️ **Умный мониторинг**
|
||||
- 💚 Состояние Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных
|
||||
- 🌐 Управление сквадами с актуальным статусом
|
||||
- 🚧 **Автоматический режим тех. работ**
|
||||
- 📋 Логи и диагностика
|
||||
- 💚 Контроль доступности Remnawave панели в реальном времени
|
||||
- 🔄 Автоматическая синхронизация данных и реконнект при ошибках
|
||||
- 🌐 Управление сквадами с актуальным статусом и массовыми действиями
|
||||
- 🚧 Автоматический режим тех. работ с уведомлениями пользователей
|
||||
- 📋 Логи и диагностика, включая журнал сбоев и действий бота
|
||||
- 📦 Проверка обновлений проекта и уведомления о новых релизах
|
||||
|
||||
📨 **Коммуникации**
|
||||
- 📢 Рассылки по сегментам
|
||||
- 🔔 Автоуведомления о продлении
|
||||
- 💬 Система поддержки с HTML разметкой
|
||||
- 📝 Настройка правил сервиса
|
||||
📨 **Коммуникации и поддержка**
|
||||
- 📢 Рассылки по сегментам и мгновенные оповещения
|
||||
- 🔔 Автоуведомления о продлении, задолженностях и автоплатежах
|
||||
- 💬 Система поддержки с HTML разметкой и история обращений
|
||||
- 📝 Настройка правил сервиса, FAQ и приветственных экранов
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала
|
||||
- 💎 Покупка подписки
|
||||
- 🔄 Конверсия из триала в платную
|
||||
- ⏰ Продление подписки
|
||||
- 💰 Пополнение баланса
|
||||
- 🎯 Активация триала, продления и конверсии в платную подписку
|
||||
- 💎 Покупка подписки и пополнение баланса
|
||||
- 🔄 Изменения параметров подписки пользователем
|
||||
- 🚧 Потеря соединения с Remnawave и включение техработ
|
||||
- 🗄️ Авто- и ручные бекапы/восстановления с отправкой файлов
|
||||
- ♻️ Выхлоп новых версий бота и результаты автоплатежей
|
||||
|
||||
🗄️ **Бекапы и восстановление**
|
||||
- Ручной запуск бекапа с выбором содержимого (включая логи)
|
||||
- Автоматические бекапы по расписанию и управление retention-политикой
|
||||
- Восстановление из бекапа без остановки сервиса
|
||||
- Отправка копии бекапа в выделенный чат/топик и ведение метаданных
|
||||
|
||||
💳 **Автоплатежи и биллинг**
|
||||
- Настройки автоплатежа на уровне пользователя (вкл/выкл, дни до списания)
|
||||
- Принудительная проверка готовности автосписаний
|
||||
- Уведомления об успешных и неудачных автоплатежах с подсказками действий
|
||||
- Настройка минимального баланса и логирование всех списаний
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
### 🤖 Автоматизация и экосистема
|
||||
|
||||
## 🆕 Недавние обновления (v2.2.1)
|
||||
- 🔄 **Мониторинг Remnawave** — регулярная проверка API, автоматическое включение/выключение техработ и сообщения в топики.
|
||||
- 🛡️ **Антифрод** — валидация обязательной подписки на канал, проверка уникальности устройств и логирование действий.
|
||||
- 🧠 **Асинхронная архитектура** — aiogram 3, PostgreSQL/SQLite, Redis и очередь задач для стабильной работы под нагрузкой.
|
||||
- 🌐 **Мультиязычность** — локализации в `locales/`, быстрый выбор языка пользователем, готовность к расширению.
|
||||
- 📦 **Интеграция с Remnawave API** — автоматическое создание пользователей, управление сквадами и синхронизация подписок.
|
||||
- 🧾 **История операций** — хранение транзакций, уведомлений, рассылок, кампаний и бекапов для аудита.
|
||||
|
||||
### 🛡️ **Защищенная реферальная система**
|
||||
- **Защита от абуза**: Бонусы выдаются только при реальном пополнении от 100₽
|
||||
- **Новая логика наград**: При первом пополнении ≥100₽ - новый пользователь и пригласивший получают по 100₽
|
||||
- **Комиссионная система**: Пригласивший получает 25% с последующих пополнений
|
||||
- **Автоматическая миграция**: Обновление статуса существующих пользователей с историей пополнений
|
||||
### 🧭 Навигация по проекту
|
||||
|
||||
### 🔔 **Умные уведомления**
|
||||
- **Реферальные уведомления**: Автоматические сообщения при переходе по ссылке, первом пополнении и получении комиссий
|
||||
- **Контроль повторов**: Защита от дублирования уведомлений и повторного получения бонусов
|
||||
- **Настраиваемые попытки**: Конфигурируемое количество попыток отправки уведомлений
|
||||
- `app/bot.py` — точка входа бота и регистрация middlewares/handlers.
|
||||
- `app/handlers/` — сценарии общения (пользовательские и административные).
|
||||
- `app/services/` — бизнес-логика: подписки, платежи, мониторинг, бекапы, кампании, версия.
|
||||
- `app/database/` — модели SQLAlchemy, CRUD и миграции Alembic.
|
||||
- `app/localization/` и `locales/` — тексты интерфейса и переводов.
|
||||
- `migrations/` — текущие миграции БД для PostgreSQL.
|
||||
|
||||
### 📊 **Расширенная статистика рефералов**
|
||||
- **Детальная аналитика**: Количество приглашенных, активных рефералов, конверсия и доходы
|
||||
- **Список рефералов**: Просмотр всех приглашенных пользователей с пагинацией
|
||||
- **Топ-рефералы**: Самые прибыльные рефералы с индикацией активности
|
||||
- **Доходы по периодам**: Разбивка заработка по типам начислений
|
||||
## Локализации
|
||||
|
||||
### 🔧 **Исправления админ-панели**
|
||||
- **Статистика**: Исправлена неработающая кнопка "Обновить" в статистике
|
||||
- **Промокоды**: Исправлена кнопка "Статистика" в разделе управления промокодами
|
||||
- **Навигация**: Синхронизированы callback_data между клавиатурами и обработчиками
|
||||
- **Реферальная статистика**: Добавлена детальная реферальная информация в профилях пользователей
|
||||
|
||||
### 📱 **Улучшения пользовательского интерфейса**
|
||||
- **User-friendly**: Улучшен интерфейс страницы подписки для лучшего UX
|
||||
- **Производительность**: Оптимизированы SQL-запросы для работы с реферальной статистикой
|
||||
- **Обработка ошибок**: Улучшена обработка ошибок при обновлении данных
|
||||
- Каталог `locales/` содержит файлы локализаций в формате `.yml` или `.json` (например, `ru.yml`, `en.yml`). Эти файлы можно редактировать напрямую на рабочем сервере.
|
||||
- Структура YAML/JSON повторяет иерархию ключей, объявленных в `app/localization/texts.py`. Например, блок `MENU:` и пара `BALANCE: "Баланс"` соответствуют ключу `MENU_BALANCE`. При добавлении новых ключей сохраняйте верхний регистр и единый стиль именования.
|
||||
- Порядок внесения правок:
|
||||
1. Измените нужные значения в файле `locales/<язык>.yml` (или `.json`).
|
||||
2. Перезапустите бота/контейнер (`docker compose restart bot` в Docker) — при старте приложение перечитает локали.
|
||||
- Путь до пользовательских локалей задаётся переменной `LOCALES_PATH` (`./locales` по умолчанию; в Docker каталог монтируется в `/app/locales`, см. `docker-compose.yml`).
|
||||
- При первом запуске, если каталог пуст, бот копирует шаблоны `ru.yml` и `en.yml` — благодаря этому у владельца сразу есть заготовки для редактирования.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Недавние обновления
|
||||
|
||||
### 🛠️ **Управление серверами**
|
||||
- **Мультивыбор серверов**: Админы могут назначать пользователям несколько серверов через удобный переключатель
|
||||
- **Умная логика отображения**: Показывает активные серверы + уже назначенные пользователю неактивные серверы
|
||||
- **Автоматический пропуск**: При наличии только одного сервера шаг выбора автоматически пропускается
|
||||
- **Синхронизация в реальном времени**: Все изменения серверов автоматически синхронизируются с Remnawave панелью
|
||||
|
||||
### 📱 **Управление устройствами**
|
||||
- **Изменение лимита устройств**: Админы могут менять количество разрешенных устройств (1-X)
|
||||
- **Быстрые кнопки**: Предустановленные значения 1, 2, 3, 5, 10 устройств
|
||||
- **Сброс HWID**: Функция сброса привязанных устройств пользователя
|
||||
- **Гибкая настройка**: Отдельные лимиты для триал и платных подписок
|
||||
|
||||
### 📊 **Управление трафиком**
|
||||
- **Настройка лимитов трафика**: Возможность установки лимита от 0 ГБ до 10000 ГБ
|
||||
- **Безлимитный режим**: Опция установки безлимитного трафика (0 ГБ)
|
||||
- **Быстрые настройки**: Предустановленные значения 50, 100, 500, 1000 ГБ
|
||||
- **Два режима продажи**: Фиксированный трафик или выбираемые пакеты
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
@@ -572,7 +682,7 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-remnawave_bot}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-remnawave_user}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secure_password_123}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -588,7 +698,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
@@ -611,22 +721,46 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
DOCKER_ENV: "true"
|
||||
DATABASE_MODE: "auto"
|
||||
POSTGRES_HOST: "postgres"
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
|
||||
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
|
||||
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
@@ -634,6 +768,7 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
### 🚀 Команды управления
|
||||
@@ -762,11 +897,13 @@ bedolaga_bot/
|
||||
│ │ ├── 💬 support.py # Техподдержка
|
||||
│ │ └── 👑 admin/ # Админ панель
|
||||
│ │ ├── 📊 statistics.py # Статистика
|
||||
│ │ ├── 🗄️ backup.py # Бекапы
|
||||
│ │ ├── 👥 users.py # Управление юзерами
|
||||
│ │ ├── 🎫 promocodes.py # Управление промокодами
|
||||
│ │ ├── 🚧 maintenance.py # Тех работы
|
||||
│ │ ├── 📨 messages.py # Рассылки
|
||||
│ │ ├── 📨 user_messages.py # Рандомные сообщения в меню
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── ⚙️ main.py # Админское меню
|
||||
│ │ ├── 📖 rules.py # Правила
|
||||
│ │ ├── 🙋 referrals.py # Правила
|
||||
@@ -786,18 +923,20 @@ bedolaga_bot/
|
||||
│ │ ├── 📜 rules.py # Правила сервиса
|
||||
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
|
||||
│ │ ├── 💳 yookassa.py # YooKassa операции
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
|
||||
│ │ ├── 💳 cryptobot.py # CryptoBot операции
|
||||
│ │ ├── 🌐 server_squad.py # Серверы и сквады
|
||||
│ │ ├── 🎁 promocode.py # Промокоды
|
||||
│ │ └── 👥 referral.py # Рефералы
|
||||
│ │ └── 👥 referral.py # Партнерка
|
||||
│ │
|
||||
│ ├── 🔧 services/ # Бизнес-логика
|
||||
│ │ ├── 👤 user_service.py # Сервис пользователей
|
||||
│ │ ├── 📋 subscription_service.py # Сервис подписок
|
||||
│ │ ├── 💰 payment_service.py # Платежи
|
||||
│ │ ├── 🗄️ backup_service.py # Бекапы
|
||||
│ │ ├── 🎁 promocode_service.py # Промокоды
|
||||
│ │ ├── 🚧 maintenance_service.py # Промокоды
|
||||
│ │ ├── 👥 referral_service.py # Рефералы
|
||||
│ │ ├── 👥 referral_service.py # Партнерка
|
||||
│ │ ├── 💬 admin_notification_service.py # Уведомления для администраторов в чаты
|
||||
│ │ ├── 🔍 monitoring_service.py # Мониторинг
|
||||
│ │ ├── ♻️ version_service.py # Проверка версий бота
|
||||
@@ -812,6 +951,9 @@ bedolaga_bot/
|
||||
│ │ ├── ✅ subscription_utils.py # Проверка подписок
|
||||
│ │ ├── 📄 pagination.py # Пагинация
|
||||
│ │ ├── 📄 pricing_utils.py # Цены
|
||||
│ │ ├── 📄 message_patch.py
|
||||
│ │ ├── 📄 photo_message.py
|
||||
│ │ ├── ‼️ global_error.py # Обработка ошибок
|
||||
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
|
||||
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
|
||||
│ │ └── ⚡ cache.py # Кеширование
|
||||
@@ -821,6 +963,7 @@ bedolaga_bot/
|
||||
│ │ ├── 📋 logging.py # Логирование
|
||||
│ │ ├── 🚧 maintenance.py # тех работы
|
||||
│ │ ├── 🔐 subscription_checker.py # тех работы
|
||||
│ │ ├── 🔐 channel_checker.py
|
||||
│ │ └── ⏱️ throttling.py # Ограничение запросов
|
||||
│ │
|
||||
│ ├── 🌐 localization/ # Локализация
|
||||
@@ -850,8 +993,6 @@ bedolaga_bot/
|
||||
|
||||
### 🏥 Health Checks
|
||||
- **Основной**: `http://localhost:8081/health`
|
||||
- **YooKassa**: `http://localhost:8082/health`
|
||||
- **Tribute**: `http://localhost:8081/tribute-webhook`
|
||||
|
||||
### 🔧 Полезные команды
|
||||
```bash
|
||||
@@ -933,6 +1074,10 @@ your-domain.com {
|
||||
handle /tribute-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081
|
||||
}
|
||||
|
||||
handle /yookassa-webhook* {
|
||||
reverse_proxy localhost:8082
|
||||
@@ -955,7 +1100,7 @@ your-domain.com {
|
||||
3. **💰 Баланс** → "💰 Баланс" → пополни через Stars/Tribute/YooKassa
|
||||
4. **🛒 Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
|
||||
5. **📱 Управление** → "📋 Мои подписки" → конфигурация → получение ссылки
|
||||
6. **👥 Рефералы** → "👥 Рефералы" → поделись ссылкой
|
||||
6. **👥 Партнерка** → "👥 Партнерка" → поделись ссылкой
|
||||
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
@@ -1070,6 +1215,13 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>@Nav1_0</strong></td>
|
||||
<td>₽1,000</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>@kr33njee</strong></td>
|
||||
|
||||
+384
@@ -0,0 +1,384 @@
|
||||
# 🔐 Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Мы обеспечиваем поддержку безопасности для следующих версий:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x.x | ✅ Yes |
|
||||
| 1.x.x | ❌ No |
|
||||
| < 1.0 | ❌ No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
### 🚨 Критические уязвимости
|
||||
|
||||
Для сообщений о критических уязвимостях безопасности:
|
||||
|
||||
**НЕ создавайте публичные Issues или Pull Requests**
|
||||
|
||||
Вместо этого:
|
||||
1. Свяжитесь с [@fringg](https://t.me/fringg) напрямую в Telegram
|
||||
2. Укажите в сообщении "SECURITY VULNERABILITY" в начале
|
||||
3. Опишите уязвимость детально
|
||||
4. Дайте время на исправление (обычно 90 дней)
|
||||
|
||||
### 📧 Контактная информация
|
||||
|
||||
- **Telegram**: [@fringg](https://t.me/fringg)
|
||||
- **Response Time**: 24-48 часов
|
||||
- **Fix Timeline**: 7-30 дней (в зависимости от критичности)
|
||||
|
||||
### 🔍 Что считается уязвимостью безопасности
|
||||
|
||||
**Критические уязвимости:**
|
||||
- SQL инъекции
|
||||
- Remote Code Execution (RCE)
|
||||
- Обход аутентификации
|
||||
- Несанкционированный доступ к данным пользователей
|
||||
- Утечка API ключей или токенов
|
||||
- Bypass платежных проверок
|
||||
|
||||
**Средние уязвимости:**
|
||||
- Cross-Site Scripting (XSS) в веб-компонентах
|
||||
- Небезопасная десериализация
|
||||
- Недостаточная валидация входных данных
|
||||
- Rate limiting bypass
|
||||
|
||||
**Низкие уязвимости:**
|
||||
- Information disclosure
|
||||
- DoS атаки
|
||||
- Проблемы с логированием чувствительных данных
|
||||
|
||||
### 📝 Информация для включения в отчет
|
||||
|
||||
1. **Описание уязвимости**
|
||||
- Тип уязвимости
|
||||
- Затронутые компоненты
|
||||
- Потенциальное воздействие
|
||||
|
||||
2. **Шаги воспроизведения**
|
||||
- Детальные инструкции
|
||||
- Скриншоты или видео (если применимо)
|
||||
- Тестовые данные
|
||||
|
||||
3. **Proof of Concept**
|
||||
- Работающий эксплойт (если безопасно)
|
||||
- Логи или вывод команд
|
||||
|
||||
4. **Предлагаемые исправления**
|
||||
- Возможные решения
|
||||
- Альтернативные подходы
|
||||
|
||||
## Security Measures
|
||||
|
||||
### 🛡️ Текущие меры защиты
|
||||
|
||||
**Аутентификация и авторизация:**
|
||||
- Проверка админ ID через ADMIN_IDS
|
||||
- Валидация всех пользовательских данных
|
||||
- Защита от несанкционированного доступа к админ функциям
|
||||
|
||||
**Защита API:**
|
||||
- Поддержка различных методов аутентификации Remnawave
|
||||
- Безопасная передача куки для защищенных панелей
|
||||
- Rate limiting для предотвращения злоупотреблений
|
||||
- Timeout для внешних запросов
|
||||
|
||||
**Защита данных:**
|
||||
- Хеширование чувствительной информации
|
||||
- Безопасное хранение API ключей
|
||||
- Логирование без записи чувствительных данных
|
||||
- Регулярные бекапы с возможностью восстановления
|
||||
|
||||
**Платежная безопасность:**
|
||||
- Валидация webhook подписей
|
||||
- Проверка сумм платежей
|
||||
- Защита от duplicate payments
|
||||
- Безопасная обработка платежных данных
|
||||
|
||||
**Инфраструктурная безопасность:**
|
||||
- Docker контейнеризация
|
||||
- Изоляция сетевого трафика
|
||||
- Минимальные права доступа
|
||||
- Регулярные обновления зависимостей
|
||||
|
||||
### 🔒 Конфиденциальные данные
|
||||
|
||||
**Что НЕ логируется:**
|
||||
- API ключи и токены
|
||||
- Пароли и секретные ключи
|
||||
- Персональные данные пользователей
|
||||
- Детали платежных транзакций
|
||||
- Webhook секреты
|
||||
|
||||
**Защита в коде:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
logger.info(f"User {user_id} created subscription")
|
||||
logger.debug(f"Payment webhook received for order {order_id}")
|
||||
|
||||
# ❌ Неправильно
|
||||
logger.info(f"API key: {api_key}")
|
||||
logger.debug(f"Webhook payload: {webhook_data}")
|
||||
```
|
||||
|
||||
### 🌐 Сетевая безопасность
|
||||
|
||||
**HTTPS обязателен:**
|
||||
- Все внешние API вызовы через HTTPS
|
||||
- Webhook endpoints защищены TLS
|
||||
- Проверка SSL сертификатов
|
||||
|
||||
**Firewall конфигурация:**
|
||||
```bash
|
||||
# Открыть только необходимые порты
|
||||
ufw allow 80/tcp # HTTP (redirect to HTTPS)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw deny 8081/tcp # Webhook порты (только через reverse proxy)
|
||||
ufw deny 8082/tcp
|
||||
```
|
||||
|
||||
### 📊 Мониторинг безопасности
|
||||
|
||||
**Автоматические проверки:**
|
||||
- Мониторинг неудачных попыток аутентификации
|
||||
- Отслеживание подозрительной активности
|
||||
- Проверка целостности платежей
|
||||
- Мониторинг состояния внешних сервисов
|
||||
|
||||
**Алерты:**
|
||||
- Множественные неудачные попытки входа
|
||||
- Неожиданные изменения в конфигурации
|
||||
- Критические ошибки в платежных системах
|
||||
- Недоступность Remnawave API
|
||||
|
||||
## Best Practices for Developers
|
||||
|
||||
### 🔐 Безопасное программирование
|
||||
|
||||
**Input Validation:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
def validate_user_id(user_id: str) -> int:
|
||||
if not user_id.isdigit():
|
||||
raise ValueError("Invalid user ID format")
|
||||
uid = int(user_id)
|
||||
if uid <= 0:
|
||||
raise ValueError("User ID must be positive")
|
||||
return uid
|
||||
|
||||
# ❌ Неправильно
|
||||
def get_user(user_id):
|
||||
return session.execute(f"SELECT * FROM users WHERE id = {user_id}")
|
||||
```
|
||||
|
||||
**SQL Injection Prevention:**
|
||||
```python
|
||||
# ✅ Правильно - используйте SQLAlchemy ORM
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
# ❌ Неправильно - raw SQL без параметров
|
||||
async def get_user_subscriptions(user_id: int):
|
||||
result = await session.execute(
|
||||
f"SELECT * FROM subscriptions WHERE user_id = {user_id}"
|
||||
)
|
||||
```
|
||||
|
||||
**API Keys Management:**
|
||||
```python
|
||||
# ✅ Правильно
|
||||
class Config:
|
||||
REMNAWAVE_API_KEY: str = os.getenv("REMNAWAVE_API_KEY", "")
|
||||
|
||||
def validate_required_env(self):
|
||||
if not self.REMNAWAVE_API_KEY:
|
||||
raise ValueError("REMNAWAVE_API_KEY is required")
|
||||
|
||||
# ❌ Неправильно
|
||||
API_KEY = "your_api_key_here" # Никогда не хардкодьте ключи
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
```python
|
||||
# ✅ Правильно - не раскрывайте внутреннюю информацию
|
||||
try:
|
||||
result = await remnawave_api.create_subscription(data)
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"RemnaWave API error: {e}")
|
||||
await message.answer("Временные проблемы с сервисом. Попробуйте позже.")
|
||||
|
||||
# ❌ Неправильно - раскрытие внутренних деталей
|
||||
except Exception as e:
|
||||
await message.answer(f"Error: {str(e)}")
|
||||
```
|
||||
|
||||
### 🔍 Code Review Security Checklist
|
||||
|
||||
- [ ] Нет хардкода API ключей или паролей
|
||||
- [ ] Все пользовательские данные валидируются
|
||||
- [ ] SQL запросы используют параметризированные запросы
|
||||
- [ ] Ошибки логируются без раскрытия чувствительной информации
|
||||
- [ ] Внешние запросы используют таймауты
|
||||
- [ ] Webhook подписи проверяются
|
||||
- [ ] Rate limiting применяется где необходимо
|
||||
- [ ] Авторизация проверяется для защищенных операций
|
||||
|
||||
### 📋 Environment Variables Security
|
||||
|
||||
**Обязательные переменные:**
|
||||
```env
|
||||
# Критически важные - должны быть установлены
|
||||
BOT_TOKEN=your_bot_token
|
||||
REMNAWAVE_API_KEY=your_api_key
|
||||
ADMIN_IDS=123456789,987654321
|
||||
|
||||
# Webhook секреты - генерируйте случайные значения
|
||||
YOOKASSA_WEBHOOK_SECRET=random_secret_here
|
||||
TRIBUTE_WEBHOOK_SECRET=another_random_secret
|
||||
CRYPTOBOT_WEBHOOK_SECRET=yet_another_secret
|
||||
```
|
||||
|
||||
**Генерация безопасных секретов:**
|
||||
```bash
|
||||
# Генерация случайного секрета
|
||||
openssl rand -hex 32
|
||||
|
||||
# Или используйте Python
|
||||
python -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### 🐳 Docker Security
|
||||
|
||||
**Dockerfile best practices:**
|
||||
```dockerfile
|
||||
# Не запускайтесь под root
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
USER appuser
|
||||
|
||||
# Минимальные права на файлы
|
||||
COPY --chown=appuser:appuser . /app
|
||||
|
||||
# Удаляйте ненужные пакеты
|
||||
RUN apt-get remove --purge -y build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
apt-get clean
|
||||
```
|
||||
|
||||
**docker-compose.yml security:**
|
||||
```yaml
|
||||
services:
|
||||
bot:
|
||||
# Ограничьте ресурсы
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
|
||||
# Не пробрасывайте порты наружу без необходимости
|
||||
ports:
|
||||
- "127.0.0.1:8081:8081" # Только localhost
|
||||
|
||||
# Read-only root filesystem
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
```
|
||||
|
||||
## Security Updates
|
||||
|
||||
### 📅 График обновлений
|
||||
|
||||
- **Критические уязвимости**: Исправляются в течение 24-48 часов
|
||||
- **Высокий приоритет**: Исправляются в течение 7 дней
|
||||
- **Средний приоритет**: Исправляются в течение 30 дней
|
||||
- **Низкий приоритет**: Исправляются в следующем планируемом релизе
|
||||
|
||||
### 🔄 Процесс обновления
|
||||
|
||||
1. **Уведомление**: Администраторы получают уведомление о доступных обновлениях
|
||||
2. **Тестирование**: Обновления тестируются в изолированной среде
|
||||
3. **Развертывание**: Постепенное развертывание с возможностью отката
|
||||
4. **Мониторинг**: Наблюдение за стабильностью после обновления
|
||||
|
||||
### 📢 Уведомления о безопасности
|
||||
|
||||
Подпишитесь на уведомления:
|
||||
- **Telegram**: [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)
|
||||
- **GitHub**: Watch repository для получения уведомлений о релизах
|
||||
- **RSS**: Следите за GitHub Releases
|
||||
|
||||
## Compliance and Standards
|
||||
|
||||
### 📜 Соответствие стандартам
|
||||
|
||||
**GDPR Compliance:**
|
||||
- Минимизация сбора данных
|
||||
- Право на удаление данных
|
||||
- Уведомление о нарушениях безопасности
|
||||
- Согласие на обработку данных
|
||||
|
||||
**PCI DSS (для платежей):**
|
||||
- Не храним данные банковских карт
|
||||
- Используем сертифицированные платежные провайдеры
|
||||
- Шифрование чувствительных данных
|
||||
- Регулярные аудиты безопасности
|
||||
|
||||
### 🛡️ Third-party Security
|
||||
|
||||
**Используемые сервисы:**
|
||||
- **Telegram API**: Официальный API с высоким уровнем безопасности
|
||||
- **Remnawave**: VPN панель с собственными мерами безопасности
|
||||
- **YooKassa**: PCI DSS сертифицированный платежный провайдер
|
||||
- **CryptoBot**: Криптовалютные платежи с блокчейн безопасностью
|
||||
|
||||
**Регулярные проверки:**
|
||||
- Мониторинг безопасности dependencies
|
||||
- Обновление библиотек и зависимостей
|
||||
- Проверка CVE для используемых компонентов
|
||||
|
||||
## Incident Response
|
||||
|
||||
### 🚨 В случае инцидента безопасности
|
||||
|
||||
1. **Немедленно**:
|
||||
- Изолируйте затронутую систему
|
||||
- Сохраните логи и доказательства
|
||||
- Уведомите команду разработки
|
||||
|
||||
2. **В течение часа**:
|
||||
- Оцените масштаб инцидента
|
||||
- Определите затронутых пользователей
|
||||
- Начните процедуры восстановления
|
||||
|
||||
3. **В течение дня**:
|
||||
- Устраните уязвимость
|
||||
- Восстановите нормальную работу
|
||||
- Уведомите пользователей (если необходимо)
|
||||
|
||||
4. **После инцидента**:
|
||||
- Проведите post-mortem анализ
|
||||
- Обновите процедуры безопасности
|
||||
- Внедрите дополнительные защитные меры
|
||||
|
||||
### 📊 Отчетность
|
||||
|
||||
**Внутренние отчеты:**
|
||||
- Еженедельные отчеты о безопасности
|
||||
- Ежемесячные аудиты логов
|
||||
- Квартальные обзоры политик безопасности
|
||||
|
||||
**Внешние уведомления:**
|
||||
- Уведомление пользователей о критических обновлениях
|
||||
- Публикация Security Advisories при необходимости
|
||||
- Сотрудничество с исследователями безопасности
|
||||
|
||||
---
|
||||
|
||||
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
|
||||
+42
-14
@@ -5,6 +5,7 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.config import settings
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.throttling import ThrottlingMiddleware
|
||||
@@ -18,17 +19,30 @@ from app.handlers import (
|
||||
referral, support, common
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes, messages as admin_messages,
|
||||
monitoring as admin_monitoring, referrals as admin_referrals,
|
||||
rules as admin_rules, remnawave as admin_remnawave,
|
||||
statistics as admin_statistics, servers as admin_servers,
|
||||
main as admin_main,
|
||||
users as admin_users,
|
||||
subscriptions as admin_subscriptions,
|
||||
promocodes as admin_promocodes,
|
||||
messages as admin_messages,
|
||||
monitoring as admin_monitoring,
|
||||
referrals as admin_referrals,
|
||||
rules as admin_rules,
|
||||
remnawave as admin_remnawave,
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
campaigns as admin_campaigns,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -68,20 +82,33 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
|
||||
else:
|
||||
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
dp.message.middleware(SubscriptionStatusMiddleware())
|
||||
dp.callback_query.middleware(SubscriptionStatusMiddleware())
|
||||
|
||||
start.register_handlers(dp)
|
||||
menu.register_handlers(dp)
|
||||
subscription.register_handlers(dp)
|
||||
@@ -89,7 +116,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
promocode.register_handlers(dp)
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -101,12 +127,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
admin_updates.register_handlers(dp)
|
||||
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
@@ -116,6 +143,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
|
||||
|
||||
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
|
||||
logger.info("Бот успешно настроен")
|
||||
|
||||
return bot, dp
|
||||
|
||||
+151
-5
@@ -1,4 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import html
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -14,6 +17,10 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
@@ -24,6 +31,7 @@ class Settings(BaseSettings):
|
||||
POSTGRES_PASSWORD: str = "secure_password_123"
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
LOCALES_PATH: str = "./locales"
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
@@ -32,6 +40,12 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_API_URL: str
|
||||
REMNAWAVE_API_KEY: str
|
||||
REMNAWAVE_SECRET_KEY: Optional[str] = None
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
@@ -50,7 +64,6 @@ class Settings(BaseSettings):
|
||||
NOTIFICATION_CACHE_HOURS: int = 24
|
||||
|
||||
BASE_SUBSCRIPTION_PRICE: int = 50000
|
||||
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS: str = "14,30,60,90,180,360"
|
||||
AVAILABLE_RENEWAL_PERIODS: str = "30,90,180"
|
||||
PRICE_14_DAYS: int = 50000
|
||||
@@ -113,12 +126,16 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_VAT_CODE: int = 1
|
||||
YOOKASSA_SBP_ENABLED: bool = False
|
||||
YOOKASSA_PAYMENT_MODE: str = "full_payment"
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = "service"
|
||||
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
|
||||
YOOKASSA_WEBHOOK_PORT: int = 8082
|
||||
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
|
||||
YOOKASSA_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
YOOKASSA_MAX_AMOUNT_KOPEKS: int = 1000000
|
||||
YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: bool = False
|
||||
PAYMENT_BALANCE_DESCRIPTION: str = "Пополнение баланса"
|
||||
PAYMENT_SUBSCRIPTION_DESCRIPTION: str = "Оплата подписки"
|
||||
PAYMENT_SERVICE_NAME: str = "Интернет-сервис"
|
||||
@@ -138,7 +155,12 @@ class Settings(BaseSettings):
|
||||
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
|
||||
HIDE_SUBSCRIPTION_LINK: bool = False
|
||||
ENABLE_LOGO_MODE: bool = True
|
||||
LOGO_FILE: str = "vpn_logo.png"
|
||||
SKIP_RULES_ACCEPT: bool = False
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
|
||||
@@ -156,6 +178,17 @@ class Settings(BaseSettings):
|
||||
VERSION_CHECK_ENABLED: bool = True
|
||||
VERSION_CHECK_REPO: str = "fr1ngg/remnawave-bedolaga-telegram-bot"
|
||||
VERSION_CHECK_INTERVAL_HOURS: int = 1
|
||||
|
||||
BACKUP_AUTO_ENABLED: bool = True
|
||||
BACKUP_INTERVAL_HOURS: int = 24
|
||||
BACKUP_TIME: str = "03:00"
|
||||
BACKUP_MAX_KEEP: int = 7
|
||||
BACKUP_COMPRESSION: bool = True
|
||||
BACKUP_INCLUDE_LOGS: bool = False
|
||||
BACKUP_LOCATION: str = "/app/data/backups"
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
|
||||
@field_validator('LOG_FILE', mode='before')
|
||||
@classmethod
|
||||
@@ -222,6 +255,48 @@ class Settings(BaseSettings):
|
||||
|
||||
except (ValueError, AttributeError):
|
||||
return []
|
||||
|
||||
def get_remnawave_auth_params(self) -> Dict[str, Optional[str]]:
|
||||
return {
|
||||
"base_url": self.REMNAWAVE_API_URL,
|
||||
"api_key": self.REMNAWAVE_API_KEY,
|
||||
"secret_key": self.REMNAWAVE_SECRET_KEY,
|
||||
"username": self.REMNAWAVE_USERNAME,
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
def get_remnawave_user_delete_mode(self) -> str:
|
||||
"""Возвращает режим удаления пользователей: 'delete' или 'disable'"""
|
||||
mode = self.REMNAWAVE_USER_DELETE_MODE.lower().strip()
|
||||
return mode if mode in ["delete", "disable"] else "delete"
|
||||
|
||||
def format_remnawave_user_description(
|
||||
self,
|
||||
*,
|
||||
full_name: str,
|
||||
username: Optional[str],
|
||||
telegram_id: int
|
||||
) -> str:
|
||||
template = self.REMNAWAVE_USER_DESCRIPTION_TEMPLATE or "Bot user: {full_name} {username}"
|
||||
template_for_formatting = template.replace("@{username}", "{username}")
|
||||
|
||||
username_clean = (username or "").lstrip("@")
|
||||
values = defaultdict(str, {
|
||||
"full_name": full_name,
|
||||
"username": f"@{username_clean}" if username_clean else "",
|
||||
"username_clean": username_clean,
|
||||
"telegram_id": str(telegram_id)
|
||||
})
|
||||
|
||||
description = template_for_formatting.format_map(values)
|
||||
|
||||
if not username_clean:
|
||||
description = re.sub(r'@(?=\W|$)', '', description)
|
||||
description = re.sub(r'\(\s*\)', '', description)
|
||||
|
||||
description = re.sub(r'\s+', ' ', description).strip()
|
||||
return description
|
||||
|
||||
def get_autopay_warning_days(self) -> List[int]:
|
||||
try:
|
||||
@@ -403,9 +478,22 @@ class Settings(BaseSettings):
|
||||
return None
|
||||
|
||||
def is_admin_notifications_enabled(self) -> bool:
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
return (self.ADMIN_NOTIFICATIONS_ENABLED and
|
||||
self.get_admin_notifications_chat_id() is not None)
|
||||
|
||||
def get_backup_send_chat_id(self) -> Optional[int]:
|
||||
if not self.BACKUP_SEND_CHAT_ID:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(self.BACKUP_SEND_CHAT_ID)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def is_backup_send_enabled(self) -> bool:
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
|
||||
@@ -518,10 +606,68 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
return package["price"]
|
||||
|
||||
def _clean_support_contact(self) -> str:
|
||||
return (self.SUPPORT_USERNAME or "").strip()
|
||||
|
||||
def get_support_contact_url(self) -> Optional[str]:
|
||||
contact = self._clean_support_contact()
|
||||
|
||||
if not contact:
|
||||
return None
|
||||
|
||||
if contact.startswith(("http://", "https://", "tg://")):
|
||||
return contact
|
||||
|
||||
contact_without_prefix = contact.lstrip("@")
|
||||
|
||||
if contact_without_prefix.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
return f"https://{contact_without_prefix}"
|
||||
|
||||
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
return f"https://{contact}"
|
||||
|
||||
if "." in contact_without_prefix:
|
||||
return f"https://{contact_without_prefix}"
|
||||
|
||||
if contact_without_prefix:
|
||||
return f"https://t.me/{contact_without_prefix}"
|
||||
|
||||
return None
|
||||
|
||||
def get_support_contact_display(self) -> str:
|
||||
contact = self._clean_support_contact()
|
||||
|
||||
if not contact:
|
||||
return ""
|
||||
|
||||
if contact.startswith("@"):
|
||||
return contact
|
||||
|
||||
if contact.startswith(("http://", "https://", "tg://")):
|
||||
return contact
|
||||
|
||||
if contact.startswith(("t.me/", "telegram.me/", "telegram.dog/")):
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
|
||||
contact_without_prefix = contact.lstrip("@")
|
||||
|
||||
if "." in contact_without_prefix:
|
||||
url = self.get_support_contact_url()
|
||||
return url if url else contact
|
||||
|
||||
if re.fullmatch(r"[A-Za-z0-9_]{3,}", contact_without_prefix):
|
||||
return f"@{contact_without_prefix}"
|
||||
|
||||
return contact
|
||||
|
||||
def get_support_contact_display_html(self) -> str:
|
||||
return html.escape(self.get_support_contact_display())
|
||||
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
start_parameter: str,
|
||||
bonus_type: str,
|
||||
created_by: Optional[int] = None,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
subscription_traffic_gb: Optional[int] = None,
|
||||
subscription_device_limit: Optional[int] = None,
|
||||
subscription_squads: Optional[List[str]] = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
start_parameter=start_parameter,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
subscription_traffic_gb=subscription_traffic_gb,
|
||||
subscription_device_limit=subscription_device_limit,
|
||||
subscription_squads=subscription_squads or [],
|
||||
created_by=created_by,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
async def get_campaign_by_id(
|
||||
db: AsyncSession, campaign_id: int
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaign_by_start_parameter(
|
||||
db: AsyncSession,
|
||||
start_parameter: str,
|
||||
*,
|
||||
only_active: bool = False,
|
||||
) -> Optional[AdvertisingCampaign]:
|
||||
stmt = select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.start_parameter == start_parameter
|
||||
)
|
||||
if only_active:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_campaigns_list(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
include_inactive: bool = True,
|
||||
) -> List[AdvertisingCampaign]:
|
||||
stmt = (
|
||||
select(AdvertisingCampaign)
|
||||
.options(selectinload(AdvertisingCampaign.registrations))
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_campaigns_count(
|
||||
db: AsyncSession, *, is_active: Optional[bool] = None
|
||||
) -> int:
|
||||
stmt = select(func.count(AdvertisingCampaign.id))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
async def update_campaign(
|
||||
db: AsyncSession,
|
||||
campaign: AdvertisingCampaign,
|
||||
**kwargs,
|
||||
) -> AdvertisingCampaign:
|
||||
allowed_fields = {
|
||||
"name",
|
||||
"start_parameter",
|
||||
"bonus_type",
|
||||
"balance_bonus_kopeks",
|
||||
"subscription_duration_days",
|
||||
"subscription_traffic_gb",
|
||||
"subscription_device_limit",
|
||||
"subscription_squads",
|
||||
"is_active",
|
||||
}
|
||||
|
||||
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
|
||||
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
|
||||
await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(AdvertisingCampaign.id == campaign.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(
|
||||
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
async def record_campaign_registration(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
campaign_id: int,
|
||||
user_id: int,
|
||||
bonus_type: str,
|
||||
balance_bonus_kopeks: int = 0,
|
||||
subscription_duration_days: Optional[int] = None,
|
||||
) -> AdvertisingCampaignRegistration:
|
||||
existing = await db.execute(
|
||||
select(AdvertisingCampaignRegistration).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
registration = existing.scalar_one_or_none()
|
||||
if registration:
|
||||
return registration
|
||||
|
||||
registration = AdvertisingCampaignRegistration(
|
||||
campaign_id=campaign_id,
|
||||
user_id=user_id,
|
||||
bonus_type=bonus_type,
|
||||
balance_bonus_kopeks=balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=subscription_duration_days,
|
||||
)
|
||||
db.add(registration)
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
async def get_campaign_statistics(
|
||||
db: AsyncSession,
|
||||
campaign_id: int,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.count(AdvertisingCampaignRegistration.id),
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
),
|
||||
func.max(AdvertisingCampaignRegistration.created_at),
|
||||
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
|
||||
)
|
||||
count, total_balance, last_registration = result.one()
|
||||
|
||||
subscription_count_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
and_(
|
||||
AdvertisingCampaignRegistration.campaign_id == campaign_id,
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"registrations": count or 0,
|
||||
"balance_issued": total_balance or 0,
|
||||
"subscription_issued": subscription_count_result.scalar() or 0,
|
||||
"last_registration": last_registration,
|
||||
}
|
||||
|
||||
|
||||
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
|
||||
total = await get_campaigns_count(db)
|
||||
active = await get_campaigns_count(db, is_active=True)
|
||||
inactive = await get_campaigns_count(db, is_active=False)
|
||||
|
||||
registrations_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id))
|
||||
)
|
||||
|
||||
balance_result = await db.execute(
|
||||
select(
|
||||
func.coalesce(
|
||||
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscription_result = await db.execute(
|
||||
select(func.count(AdvertisingCampaignRegistration.id)).where(
|
||||
AdvertisingCampaignRegistration.bonus_type == "subscription"
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": inactive,
|
||||
"registrations": registrations_result.scalar() or 0,
|
||||
"balance_total": balance_result.scalar() or 0,
|
||||
"subscription_total": subscription_result.scalar() or 0,
|
||||
}
|
||||
+139
-3
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,10 +53,37 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f"✅ Правила для языка {language} обновлены")
|
||||
logger.info(f"✅ Правила для языка {language} обновлены (ID: {new_rules.id})")
|
||||
return new_rules
|
||||
|
||||
|
||||
async def clear_all_rules(db: AsyncSession, language: str = "ru") -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f"✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}")
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при очистке правил для языка {language}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> str:
|
||||
rules = await get_rules_by_language(db, language)
|
||||
|
||||
@@ -79,4 +106,113 @@ async def get_current_rules_content(db: AsyncSession, language: str = "ru") -> s
|
||||
6. При возникновении вопросов обращайтесь в техническую поддержку.
|
||||
|
||||
Используя сервис, вы соглашаетесь с данными правилами.
|
||||
"""
|
||||
"""
|
||||
|
||||
|
||||
async def get_all_rules_versions(
|
||||
db: AsyncSession,
|
||||
language: str = "ru",
|
||||
limit: int = 10
|
||||
) -> list[ServiceRule]:
|
||||
result = await db.execute(
|
||||
select(ServiceRule)
|
||||
.where(ServiceRule.language == language)
|
||||
.order_by(ServiceRule.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def restore_rules_version(
|
||||
db: AsyncSession,
|
||||
rule_id: int,
|
||||
language: str = "ru"
|
||||
) -> Optional[ServiceRule]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ServiceRule).where(
|
||||
ServiceRule.id == rule_id,
|
||||
ServiceRule.language == language
|
||||
)
|
||||
)
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f"Правило с ID {rule_id} не найдено для языка {language}")
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
update(ServiceRule)
|
||||
.where(
|
||||
ServiceRule.language == language,
|
||||
ServiceRule.is_active == True
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
restored_rule = ServiceRule(
|
||||
title=rule_to_restore.title,
|
||||
content=rule_to_restore.content,
|
||||
language=language,
|
||||
is_active=True,
|
||||
order=0
|
||||
)
|
||||
|
||||
db.add(restored_rule)
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f"✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}")
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при восстановлении правил ID {rule_id}: {e}")
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
try:
|
||||
active_result = await db.execute(
|
||||
select(ServiceRule).where(ServiceRule.is_active == True)
|
||||
)
|
||||
active_rules = active_result.scalars().all()
|
||||
|
||||
all_result = await db.execute(select(ServiceRule))
|
||||
all_rules = all_result.scalars().all()
|
||||
|
||||
languages_stats = {}
|
||||
for rule in active_rules:
|
||||
lang = rule.language
|
||||
if lang not in languages_stats:
|
||||
languages_stats[lang] = {
|
||||
'active_count': 0,
|
||||
'last_updated': None,
|
||||
'content_length': 0
|
||||
}
|
||||
|
||||
languages_stats[lang]['active_count'] += 1
|
||||
languages_stats[lang]['content_length'] = len(rule.content)
|
||||
|
||||
if not languages_stats[lang]['last_updated'] or rule.updated_at > languages_stats[lang]['last_updated']:
|
||||
languages_stats[lang]['last_updated'] = rule.updated_at
|
||||
|
||||
return {
|
||||
'total_active': len(active_rules),
|
||||
'total_all_time': len(all_rules),
|
||||
'languages': languages_stats,
|
||||
'total_languages': len(languages_stats)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики правил: {e}")
|
||||
return {
|
||||
'total_active': 0,
|
||||
'total_all_time': 0,
|
||||
'languages': {},
|
||||
'total_languages': 0,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, func, update, delete
|
||||
from sqlalchemy import select, and_, func, update, delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ServerSquad, SubscriptionServer
|
||||
from app.database.models import ServerSquad, SubscriptionServer, Subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,7 +137,7 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f"❌ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
logger.warning(f"⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
@@ -244,10 +244,23 @@ async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
available_servers = available_result.scalar()
|
||||
|
||||
with_connections_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionServer.server_squad_id)))
|
||||
)
|
||||
servers_with_connections = with_connections_result.scalar()
|
||||
servers_with_connections = 0
|
||||
all_servers_result = await db.execute(select(ServerSquad.squad_uuid))
|
||||
all_server_uuids = [row[0] for row in all_servers_result.fetchall()]
|
||||
|
||||
for squad_uuid in all_server_uuids:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
user_count = count_result.scalar() or 0
|
||||
if user_count > 0:
|
||||
servers_with_connections += 1
|
||||
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(SubscriptionServer.paid_price_kopeks), 0))
|
||||
@@ -324,20 +337,26 @@ async def get_server_ids_by_uuids(
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(
|
||||
ServerSquad.id,
|
||||
ServerSquad.squad_uuid,
|
||||
func.count(SubscriptionServer.id).label('actual_users')
|
||||
)
|
||||
.outerjoin(SubscriptionServer, ServerSquad.id == SubscriptionServer.server_squad_id)
|
||||
.join(Subscription, SubscriptionServer.subscription_id == Subscription.id)
|
||||
.where(Subscription.status == 'active')
|
||||
.group_by(ServerSquad.id, ServerSquad.squad_uuid)
|
||||
)
|
||||
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
|
||||
all_servers = all_servers_result.fetchall()
|
||||
|
||||
logger.info(f"🔍 Найдено серверов для синхронизации: {len(all_servers)}")
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid, actual_users in result.fetchall():
|
||||
for server_id, squad_uuid in all_servers:
|
||||
count_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(s.id)
|
||||
FROM subscriptions s
|
||||
WHERE s.status IN ('active', 'trial')
|
||||
AND s.connected_squads::text LIKE :uuid_pattern
|
||||
"""),
|
||||
{"uuid_pattern": f'%"{squad_uuid}"%'}
|
||||
)
|
||||
actual_users = count_result.scalar() or 0
|
||||
|
||||
logger.info(f"📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей")
|
||||
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
@@ -352,4 +371,4 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка синхронизации счетчиков пользователей: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
return 0
|
||||
|
||||
@@ -442,6 +442,8 @@ async def add_subscription_servers(
|
||||
server_squad_ids: List[int],
|
||||
paid_prices: List[int] = None
|
||||
) -> Subscription:
|
||||
await db.refresh(subscription)
|
||||
|
||||
if paid_prices is None:
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
paid_prices = []
|
||||
@@ -458,7 +460,7 @@ async def add_subscription_servers(
|
||||
|
||||
for i, server_id in enumerate(server_squad_ids):
|
||||
subscription_server = SubscriptionServer(
|
||||
subscription_id=subscription.id,
|
||||
subscription_id=subscription.id,
|
||||
server_squad_id=server_id,
|
||||
paid_price_kopeks=paid_prices[i] if i < len(paid_prices) else 0
|
||||
)
|
||||
@@ -467,7 +469,7 @@ async def add_subscription_servers(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
logger.info(f"🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
|
||||
return subscription
|
||||
|
||||
async def get_server_monthly_price(
|
||||
@@ -533,9 +535,12 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -646,9 +651,12 @@ async def get_subscription_renewal_cost(
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
if total_traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
if total_devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
|
||||
@@ -216,7 +216,8 @@ async def get_users_list(
|
||||
offset: int = 0,
|
||||
limit: int = 50,
|
||||
search: Optional[str] = None,
|
||||
status: Optional[UserStatus] = None
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
) -> List[User]:
|
||||
|
||||
query = select(User).options(selectinload(User.subscription))
|
||||
@@ -237,7 +238,13 @@ async def get_users_list(
|
||||
|
||||
query = query.where(or_(*conditions))
|
||||
|
||||
query = query.order_by(User.created_at.desc()).offset(offset).limit(limit)
|
||||
# Сортировка по балансу в порядке убывания, если order_by_balance=True
|
||||
if order_by_balance:
|
||||
query = query.order_by(User.balance_kopeks.desc())
|
||||
else:
|
||||
query = query.order_by(User.created_at.desc())
|
||||
|
||||
query = query.offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WelcomeText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WELCOME_TEXT_KEY = "welcome_text"
|
||||
|
||||
async def get_active_welcome_text(db: AsyncSession) -> Optional[str]:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.where(WelcomeText.is_enabled == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
return welcome_text.text_content
|
||||
|
||||
return None
|
||||
|
||||
async def get_current_welcome_text_settings(db: AsyncSession) -> dict:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
return {
|
||||
'text': welcome_text.text_content,
|
||||
'is_enabled': welcome_text.is_enabled,
|
||||
'id': welcome_text.id
|
||||
}
|
||||
|
||||
return {
|
||||
'text': await get_current_welcome_text_or_default(),
|
||||
'is_enabled': True,
|
||||
'id': None
|
||||
}
|
||||
|
||||
async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(WelcomeText)
|
||||
.where(WelcomeText.is_active == True)
|
||||
.order_by(WelcomeText.updated_at.desc())
|
||||
)
|
||||
welcome_text = result.scalar_one_or_none()
|
||||
|
||||
if welcome_text:
|
||||
welcome_text.is_enabled = not welcome_text.is_enabled
|
||||
welcome_text.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
status = "включен" if welcome_text.is_enabled else "отключен"
|
||||
logger.info(f"Приветственный текст {status} администратором {admin_id}")
|
||||
return welcome_text.is_enabled
|
||||
else:
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=default_text,
|
||||
is_active=True,
|
||||
is_enabled=True,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
db.add(new_welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f"Создан и включен дефолтный приветственный текст администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при переключении статуса приветственного текста: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
|
||||
try:
|
||||
current_settings = await get_current_welcome_text_settings(db)
|
||||
current_enabled_status = current_settings.get('is_enabled', True)
|
||||
|
||||
await db.execute(
|
||||
update(WelcomeText).values(is_active=False)
|
||||
)
|
||||
|
||||
new_welcome_text = WelcomeText(
|
||||
text_content=text_content,
|
||||
is_active=True,
|
||||
is_enabled=current_enabled_status,
|
||||
created_by=admin_id
|
||||
)
|
||||
|
||||
db.add(new_welcome_text)
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f"Установлен новый приветственный текст администратором {admin_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при установке приветственного текста: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
return (
|
||||
f"Привет, {{user_name}}! 🎁 3 дней VPN бесплатно! "
|
||||
f"Подключайтесь за минуту и забудьте о блокировках. "
|
||||
f"✅ До 1 Гбит/с скорость "
|
||||
f"✅ Умный VPN — можно не отключать для большинства российских сервисов "
|
||||
f"✅ Современные протоколы — максимум защиты и анонимности "
|
||||
f"💉 Всего 99₽/мес за 1 устройство "
|
||||
f"👇 Жмите кнопку и подключайтесь!"
|
||||
)
|
||||
|
||||
def replace_placeholders(text: str, user) -> str:
|
||||
first_name = getattr(user, 'first_name', None)
|
||||
username = getattr(user, 'username', None)
|
||||
|
||||
first_name = first_name.strip() if first_name else None
|
||||
username = username.strip() if username else None
|
||||
|
||||
user_name = first_name or username or "друг"
|
||||
display_first_name = first_name or "друг"
|
||||
display_username = f"@{username}" if username else (first_name or "друг")
|
||||
clean_username = username or first_name or "друг"
|
||||
|
||||
replacements = {
|
||||
'{user_name}': user_name,
|
||||
'{first_name}': display_first_name,
|
||||
'{username}': display_username,
|
||||
'{username_clean}': clean_username,
|
||||
'Egor': user_name
|
||||
}
|
||||
|
||||
result = text
|
||||
for placeholder, value in replacements.items():
|
||||
result = result.replace(placeholder, value)
|
||||
|
||||
return result
|
||||
|
||||
async def get_welcome_text_for_user(db: AsyncSession, user) -> str:
|
||||
welcome_text = await get_active_welcome_text(db)
|
||||
|
||||
if not welcome_text:
|
||||
return None
|
||||
|
||||
if isinstance(user, str):
|
||||
class SimpleUser:
|
||||
def __init__(self, name):
|
||||
self.first_name = name
|
||||
self.username = None
|
||||
user = SimpleUser(user)
|
||||
|
||||
return replace_placeholders(welcome_text, user)
|
||||
|
||||
def get_available_placeholders() -> dict:
|
||||
return {
|
||||
'{user_name}': 'Имя или username пользователя (приоритет: имя → username → "друг")',
|
||||
'{first_name}': 'Только имя пользователя (или "друг" если не указано)',
|
||||
'{username}': 'Username с символом @ (или имя если username не указан)',
|
||||
'{username_clean}': 'Username без символа @ (или имя если username не указан)'
|
||||
}
|
||||
+84
-3
@@ -3,8 +3,17 @@ from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, Float, JSON, BigInteger
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
DateTime,
|
||||
Boolean,
|
||||
Text,
|
||||
ForeignKey,
|
||||
Float,
|
||||
JSON,
|
||||
BigInteger,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Mapped, mapped_column
|
||||
@@ -575,6 +584,10 @@ class BroadcastHistory(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
target_type = Column(String(100), nullable=False)
|
||||
message_text = Column(Text, nullable=False)
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
total_count = Column(Integer, default=0)
|
||||
sent_count = Column(Integer, default=0)
|
||||
failed_count = Column(Integer, default=0)
|
||||
@@ -583,7 +596,6 @@ class BroadcastHistory(Base):
|
||||
admin_name = Column(String(255))
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
admin = relationship("User", back_populates="broadcasts")
|
||||
|
||||
class ServerSquad(Base):
|
||||
@@ -660,3 +672,72 @@ class UserMessage(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
|
||||
|
||||
class WelcomeText(Base):
|
||||
__tablename__ = "welcome_texts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
text_content = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", backref="created_welcome_texts")
|
||||
|
||||
|
||||
class AdvertisingCampaign(Base):
|
||||
__tablename__ = "advertising_campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
start_parameter = Column(String(64), nullable=False, unique=True, index=True)
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
subscription_traffic_gb = Column(Integer, nullable=True)
|
||||
subscription_device_limit = Column(Integer, nullable=True)
|
||||
subscription_squads = Column(JSON, default=list)
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
|
||||
|
||||
@property
|
||||
def is_balance_bonus(self) -> bool:
|
||||
return self.bonus_type == "balance"
|
||||
|
||||
@property
|
||||
def is_subscription_bonus(self) -> bool:
|
||||
return self.bonus_type == "subscription"
|
||||
|
||||
|
||||
class AdvertisingCampaignRegistration(Base):
|
||||
__tablename__ = "advertising_campaign_registrations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
bonus_type = Column(String(20), nullable=False)
|
||||
balance_bonus_kopeks = Column(Integer, default=0)
|
||||
subscription_duration_days = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
campaign = relationship("AdvertisingCampaign", back_populates="registrations")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
@@ -75,7 +75,6 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
return False
|
||||
|
||||
async def create_cryptobot_payments_table():
|
||||
"""Создание таблицы cryptobot_payments"""
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица cryptobot_payments уже существует")
|
||||
@@ -178,7 +177,6 @@ async def create_cryptobot_payments_table():
|
||||
return False
|
||||
|
||||
async def create_user_messages_table():
|
||||
"""Создание таблицы user_messages"""
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
logger.info("Таблица user_messages уже существует")
|
||||
@@ -250,6 +248,162 @@ async def create_user_messages_table():
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
async def add_welcome_text_is_enabled_column():
|
||||
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
if column_exists:
|
||||
logger.info("Колонка is_enabled уже существует в таблице welcome_texts")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT 1 NOT NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE welcome_texts ADD COLUMN is_enabled BOOLEAN DEFAULT TRUE NOT NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления колонки: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Поле is_enabled добавлено в таблицу welcome_texts")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
update_sql = "UPDATE welcome_texts SET is_enabled = 1 WHERE is_enabled IS NULL"
|
||||
else:
|
||||
update_sql = "UPDATE welcome_texts SET is_enabled = TRUE WHERE is_enabled IS NULL"
|
||||
|
||||
result = await conn.execute(text(update_sql))
|
||||
updated_count = result.rowcount
|
||||
logger.info(f"Обновлено {updated_count} существующих записей welcome_texts")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении поля is_enabled: {e}")
|
||||
return False
|
||||
|
||||
async def create_welcome_texts_table():
|
||||
table_exists = await check_table_exists('welcome_texts')
|
||||
if table_exists:
|
||||
logger.info("Таблица welcome_texts уже существует")
|
||||
return await add_welcome_text_is_enabled_column()
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
is_enabled BOOLEAN DEFAULT 1 NOT NULL,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_by INTEGER NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE welcome_texts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
text_content TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_enabled BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_welcome_texts_active ON welcome_texts(is_active);
|
||||
CREATE INDEX idx_welcome_texts_enabled ON welcome_texts(is_enabled);
|
||||
CREATE INDEX idx_welcome_texts_updated ON welcome_texts(updated_at);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("✅ Таблица welcome_texts успешно создана с полем is_enabled")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
async def add_media_fields_to_broadcast_history():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
|
||||
|
||||
media_fields = {
|
||||
'has_media': 'BOOLEAN DEFAULT FALSE',
|
||||
'media_type': 'VARCHAR(20)',
|
||||
'media_file_id': 'VARCHAR(255)',
|
||||
'media_caption': 'TEXT'
|
||||
}
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
for field_name, field_type in media_fields.items():
|
||||
field_exists = await check_column_exists('broadcast_history', field_name)
|
||||
|
||||
if not field_exists:
|
||||
logger.info(f"Добавление поля {field_name} в таблицу broadcast_history")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT 0')
|
||||
elif db_type == 'postgresql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
elif db_type == 'mysql':
|
||||
if 'BOOLEAN' in field_type:
|
||||
field_type = field_type.replace('BOOLEAN DEFAULT FALSE', 'BOOLEAN DEFAULT FALSE')
|
||||
|
||||
alter_sql = f"ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}"
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info(f"✅ Поле {field_name} успешно добавлено")
|
||||
else:
|
||||
logger.info(f"Поле {field_name} уже существует в broadcast_history")
|
||||
|
||||
logger.info("✅ Все поля медиа в broadcast_history готовы")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -344,7 +498,6 @@ async def add_referral_system_columns():
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
"""Создание таблицы subscription_conversions"""
|
||||
table_exists = await check_table_exists('subscription_conversions')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_conversions уже существует")
|
||||
@@ -420,7 +573,6 @@ async def create_subscription_conversions_table():
|
||||
return False
|
||||
|
||||
async def fix_subscription_duplicates_universal():
|
||||
"""Исправление дублирующихся подписок"""
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Обнаружен тип базы данных: {db_type}")
|
||||
@@ -505,12 +657,10 @@ async def run_universal_migration():
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
# Миграция реферальной системы
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
# Создание таблицы CryptoBot payments
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
@@ -518,15 +668,27 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
# Создание таблицы user_messages
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===")
|
||||
user_messages_created = await create_user_messages_table()
|
||||
if user_messages_created:
|
||||
logger.info("✅ Таблица user_messages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_messages")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
|
||||
welcome_texts_created = await create_welcome_texts_table()
|
||||
if welcome_texts_created:
|
||||
logger.info("✅ Таблица welcome_texts готова с полем is_enabled")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
|
||||
media_fields_added = await add_media_fields_to_broadcast_history()
|
||||
if media_fields_added:
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
# Обновление внешних ключей
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -534,7 +696,6 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с обновлением внешних ключей")
|
||||
|
||||
# Создание таблицы конверсий подписок
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
if conversions_created:
|
||||
@@ -542,7 +703,6 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
|
||||
|
||||
# Проверка и исправление дублирующихся подписок
|
||||
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"))
|
||||
@@ -560,7 +720,6 @@ async def run_universal_migration():
|
||||
|
||||
deleted_count = await fix_subscription_duplicates_universal()
|
||||
|
||||
# Финальная проверка
|
||||
async with engine.begin() as conn:
|
||||
final_check = await conn.execute(text("""
|
||||
SELECT user_id, COUNT(*) as count
|
||||
@@ -579,6 +738,8 @@ async def run_universal_migration():
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
@@ -593,20 +754,31 @@ async def check_migration_status():
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"cryptobot_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
}
|
||||
|
||||
# Проверка колонки реферальной системы
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
|
||||
# Проверка таблицы CryptoBot
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
|
||||
# Проверка таблицы конверсий подписок
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
# Проверка дублирующихся подписок
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
await check_column_exists('broadcast_history', 'media_file_id') and
|
||||
await check_column_exists('broadcast_history', 'media_caption')
|
||||
)
|
||||
status["broadcast_history_media_fields"] = media_fields_exist
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -619,10 +791,13 @@ async def check_migration_status():
|
||||
duplicates_count = duplicates_check.fetchone()[0]
|
||||
status["subscription_duplicates"] = (duplicates_count == 0)
|
||||
|
||||
# Вывод результатов
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
@@ -640,11 +815,15 @@ async def check_migration_status():
|
||||
async with engine.begin() as conn:
|
||||
conversions_count = await conn.execute(text("SELECT COUNT(*) FROM subscription_conversions"))
|
||||
users_count = await conn.execute(text("SELECT COUNT(*) FROM users"))
|
||||
welcome_texts_count = await conn.execute(text("SELECT COUNT(*) FROM welcome_texts"))
|
||||
broadcasts_count = await conn.execute(text("SELECT COUNT(*) FROM broadcast_history"))
|
||||
|
||||
conv_count = conversions_count.fetchone()[0]
|
||||
usr_count = users_count.fetchone()[0]
|
||||
welcome_count = welcome_texts_count.fetchone()[0]
|
||||
broadcast_count = broadcasts_count.fetchone()[0]
|
||||
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
|
||||
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов, {broadcast_count} рассылок")
|
||||
except Exception as stats_error:
|
||||
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
|
||||
|
||||
|
||||
Vendored
+99
-18
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
import base64
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Union, Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -83,6 +84,16 @@ class RemnaWaveNode:
|
||||
traffic_limit_bytes: Optional[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubscriptionInfo:
|
||||
is_found: bool
|
||||
user: Optional[Dict[str, Any]]
|
||||
links: List[str]
|
||||
ss_conf_links: Dict[str, str]
|
||||
subscription_url: str
|
||||
happ: Optional[Dict[str, str]]
|
||||
|
||||
|
||||
class RemnaWaveAPIError(Exception):
|
||||
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
||||
self.message = message
|
||||
@@ -93,12 +104,15 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None):
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.secret_key = secret_key
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
self.authenticated = False
|
||||
|
||||
def _detect_connection_type(self) -> str:
|
||||
parsed = urlparse(self.base_url)
|
||||
@@ -119,14 +133,9 @@ class RemnaWaveAPI:
|
||||
return "local"
|
||||
|
||||
return "external"
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"🔗 Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
|
||||
def _prepare_auth_headers(self) -> Dict[str, str]:
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-Forwarded-Proto': 'https',
|
||||
@@ -134,20 +143,41 @@ class RemnaWaveAPI:
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
if self.username and self.password:
|
||||
import base64
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
|
||||
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
|
||||
else:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug("Используем API ключ в X-Api-Key заголовке")
|
||||
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
return headers
|
||||
|
||||
async def __aenter__(self):
|
||||
conn_type = self._detect_connection_type()
|
||||
|
||||
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
|
||||
|
||||
headers = self._prepare_auth_headers()
|
||||
|
||||
cookies = None
|
||||
if self.secret_key:
|
||||
if ':' in self.secret_key:
|
||||
key_name, key_value = self.secret_key.split(':', 1)
|
||||
cookies = {key_name: key_value}
|
||||
logger.debug(f"🍪 Используем куки: {key_name}=***")
|
||||
logger.debug(f"Используем куки: {key_name}=***")
|
||||
else:
|
||||
cookies = {self.secret_key: self.secret_key}
|
||||
logger.debug(f"🍪 Используем куки: {self.secret_key}=***")
|
||||
logger.debug(f"Используем куки: {self.secret_key}=***")
|
||||
|
||||
connector_kwargs = {}
|
||||
|
||||
if conn_type == "local":
|
||||
logger.debug("🏠 Использую локальные заголовки proxy")
|
||||
logger.debug("Используют локальные заголовки proxy")
|
||||
headers.update({
|
||||
'X-Forwarded-Host': 'localhost',
|
||||
'Host': 'localhost'
|
||||
@@ -158,10 +188,10 @@ class RemnaWaveAPI:
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
connector_kwargs['ssl'] = ssl_context
|
||||
logger.debug("🔓 SSL проверка отключена для локального HTTPS")
|
||||
logger.debug("SSL проверка отключена для локального HTTPS")
|
||||
|
||||
elif conn_type == "external":
|
||||
logger.debug("🌐 Использую внешнее подключение с полной SSL проверкой")
|
||||
logger.debug("Используют внешнее подключение с полной SSL проверкой")
|
||||
pass
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
@@ -280,7 +310,10 @@ class RemnaWaveAPI:
|
||||
async def get_user_by_telegram_id(self, telegram_id: int) -> List[RemnaWaveUser]:
|
||||
try:
|
||||
response = await self._make_request('GET', f'/api/users/by-telegram-id/{telegram_id}')
|
||||
return [self._parse_user(user) for user in response['response']]
|
||||
users_data = response.get('response', [])
|
||||
if not users_data:
|
||||
return []
|
||||
return [self._parse_user(user) for user in users_data]
|
||||
except RemnaWaveAPIError as e:
|
||||
if e.status_code == 404:
|
||||
return []
|
||||
@@ -440,9 +473,47 @@ class RemnaWaveAPI:
|
||||
return response['response']['eventSent']
|
||||
|
||||
|
||||
async def get_subscription_info(self, short_uuid: str) -> Dict[str, Any]:
|
||||
async def get_subscription_info(self, short_uuid: str) -> SubscriptionInfo:
|
||||
response = await self._make_request('GET', f'/api/sub/{short_uuid}/info')
|
||||
return response['response']
|
||||
return self._parse_subscription_info(response['response'])
|
||||
|
||||
async def get_subscription_by_short_uuid(self, short_uuid: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_by_client_type(self, short_uuid: str, client_type: str) -> str:
|
||||
valid_types = ["stash", "singbox", "singbox-legacy", "mihomo", "json", "v2ray-json", "clash"]
|
||||
if client_type not in valid_types:
|
||||
raise ValueError(f"Invalid client type. Must be one of: {valid_types}")
|
||||
|
||||
async with self.session.get(f"{self.base_url}/api/sub/{short_uuid}/{client_type}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
async def get_subscription_links(self, short_uuid: str) -> Dict[str, str]:
|
||||
base_url = f"{self.base_url}/api/sub/{short_uuid}"
|
||||
|
||||
links = {
|
||||
"base": base_url,
|
||||
"stash": f"{base_url}/stash",
|
||||
"singbox": f"{base_url}/singbox",
|
||||
"singbox_legacy": f"{base_url}/singbox-legacy",
|
||||
"mihomo": f"{base_url}/mihomo",
|
||||
"json": f"{base_url}/json",
|
||||
"v2ray_json": f"{base_url}/v2ray-json",
|
||||
"clash": f"{base_url}/clash"
|
||||
}
|
||||
|
||||
return links
|
||||
|
||||
async def get_outline_subscription(self, short_uuid: str, encoded_tag: str) -> str:
|
||||
async with self.session.get(f"{self.base_url}/api/sub/outline/{short_uuid}/ss/{encoded_tag}") as response:
|
||||
if response.status >= 400:
|
||||
raise RemnaWaveAPIError(f"Failed to get outline subscription: {response.status}")
|
||||
return await response.text()
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Dict[str, Any]:
|
||||
@@ -572,6 +643,16 @@ class RemnaWaveAPI:
|
||||
traffic_used_bytes=node_data.get('trafficUsedBytes'),
|
||||
traffic_limit_bytes=node_data.get('trafficLimitBytes')
|
||||
)
|
||||
|
||||
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
|
||||
return SubscriptionInfo(
|
||||
is_found=data['isFound'],
|
||||
user=data.get('user'),
|
||||
links=data.get('links', []),
|
||||
ss_conf_links=data.get('ssConfLinks', {}),
|
||||
subscription_url=data.get('subscriptionUrl', ''),
|
||||
happ=data.get('happ')
|
||||
)
|
||||
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
|
||||
Vendored
+4
-2
@@ -33,6 +33,7 @@ class TelegramStarsService:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
invoice_link = await self.bot.create_invoice_link(
|
||||
title=title,
|
||||
@@ -46,7 +47,7 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
|
||||
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
f"для {chat_id}, курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return invoice_link
|
||||
|
||||
@@ -66,6 +67,7 @@ class TelegramStarsService:
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
message = await self.bot.send_invoice(
|
||||
chat_id=chat_id,
|
||||
@@ -80,7 +82,7 @@ class TelegramStarsService:
|
||||
|
||||
logger.info(
|
||||
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
|
||||
f"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
|
||||
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
|
||||
@@ -0,0 +1,647 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.services.backup_service import backup_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BackupStates(StatesGroup):
|
||||
waiting_backup_file = State()
|
||||
waiting_settings_update = State()
|
||||
|
||||
|
||||
def get_backup_main_keyboard(language: str = "ru"):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🚀 Создать бекап", callback_data="backup_create"),
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data="backup_restore")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список бекапов", callback_data="backup_list"),
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="backup_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_list_keyboard(backups: list, page: int = 1, per_page: int = 5):
|
||||
keyboard = []
|
||||
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
page_backups = backups[start_idx:end_idx]
|
||||
|
||||
for backup in page_backups:
|
||||
try:
|
||||
if backup.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m %H:%M")
|
||||
else:
|
||||
date_str = "?"
|
||||
except:
|
||||
date_str = "?"
|
||||
|
||||
size_str = f"{backup.get('file_size_mb', 0):.1f}MB"
|
||||
records_str = backup.get('total_records', '?')
|
||||
|
||||
button_text = f"📦 {date_str} • {size_str} • {records_str} записей"
|
||||
callback_data = f"backup_manage_{backup['filename']}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(text=button_text, callback_data=callback_data)])
|
||||
|
||||
if len(backups) > per_page:
|
||||
total_pages = (len(backups) + per_page - 1) // per_page
|
||||
nav_row = []
|
||||
|
||||
if page > 1:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"backup_list_page_{page-1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="noop"))
|
||||
|
||||
if page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"backup_list_page_{page+1}"))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_backup_manage_keyboard(backup_filename: str):
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📥 Восстановить", callback_data=f"backup_restore_file_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"backup_delete_{backup_filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ К списку", callback_data="backup_list")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_backup_settings_keyboard(settings_obj):
|
||||
auto_status = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
compression_status = "✅ Включено" if settings_obj.compression_enabled else "❌ Отключено"
|
||||
logs_status = "✅ Включены" if settings_obj.include_logs else "❌ Отключены"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 Автобекапы: {auto_status}",
|
||||
callback_data="backup_toggle_auto"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🗜️ Сжатие: {compression_status}",
|
||||
callback_data="backup_toggle_compression"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📋 Логи в бекапе: {logs_status}",
|
||||
callback_data="backup_toggle_logs"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
status_auto = "✅ Включены" if settings_obj.auto_backup_enabled else "❌ Отключены"
|
||||
|
||||
text = f"""🗄️ <b>СИСТЕМА БЕКАПОВ</b>
|
||||
|
||||
📊 <b>Статус:</b>
|
||||
• Автобекапы: {status_auto}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Хранить: {settings_obj.max_backups_keep} файлов
|
||||
• Сжатие: {'Да' if settings_obj.compression_enabled else 'Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>/app/data/backups</code>
|
||||
|
||||
⚡ <b>Доступные операции:</b>
|
||||
• Создание полного бекапа всех данных
|
||||
• Восстановление из файла бекапа
|
||||
• Управление автоматическими бекапами
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def create_backup_handler(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Создание бекапа запущено...")
|
||||
|
||||
progress_msg = await callback.message.edit_text(
|
||||
"🔄 <b>Создание бекапа...</b>\n\n"
|
||||
"⏳ Экспортируем данные из базы...\n"
|
||||
"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Создаем бекап
|
||||
success, message, file_path = await backup_service.create_backup(
|
||||
created_by=db_user.telegram_id,
|
||||
compress=True
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Бекап создан успешно!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка создания бекапа</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 1
|
||||
if callback.data.startswith("backup_list_page_"):
|
||||
try:
|
||||
page = int(callback.data.split("_")[-1])
|
||||
except:
|
||||
page = 1
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
|
||||
if not backups:
|
||||
text = "📦 <b>Список бекапов пуст</b>\n\nБекапы еще не создавались."
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🚀 Создать первый бекап", callback_data="backup_create")],
|
||||
[InlineKeyboardButton(text="◀️ Назад", callback_data="backup_panel")]
|
||||
])
|
||||
else:
|
||||
text = f"📦 <b>Список бекапов</b> (всего: {len(backups)})\n\n"
|
||||
text += "Выберите бекап для управления:"
|
||||
keyboard = get_backup_list_keyboard(backups, page)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def manage_backup_file(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_manage_", "")
|
||||
|
||||
backups = await backup_service.get_backup_list()
|
||||
backup_info = None
|
||||
|
||||
for backup in backups:
|
||||
if backup["filename"] == filename:
|
||||
backup_info = backup
|
||||
break
|
||||
|
||||
if not backup_info:
|
||||
await callback.answer("❌ Файл бекапа не найден", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if backup_info.get("timestamp"):
|
||||
dt = datetime.fromisoformat(backup_info["timestamp"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%d.%m.%Y %H:%M:%S")
|
||||
else:
|
||||
date_str = "Неизвестно"
|
||||
except:
|
||||
date_str = "Ошибка формата даты"
|
||||
|
||||
text = f"""📦 <b>Информация о бекапе</b>
|
||||
|
||||
📄 <b>Файл:</b> <code>{filename}</code>
|
||||
📅 <b>Создан:</b> {date_str}
|
||||
💾 <b>Размер:</b> {backup_info.get('file_size_mb', 0):.2f} MB
|
||||
📊 <b>Таблиц:</b> {backup_info.get('tables_count', '?')}
|
||||
📈 <b>Записей:</b> {backup_info.get('total_records', '?'):,}
|
||||
🗜️ <b>Сжатие:</b> {'Да' if backup_info.get('compressed') else 'Нет'}
|
||||
🗄️ <b>БД:</b> {backup_info.get('database_type', 'unknown')}
|
||||
"""
|
||||
|
||||
if backup_info.get("error"):
|
||||
text += f"\n⚠️ <b>Ошибка:</b> {backup_info['error']}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_", "")
|
||||
|
||||
text = f"🗑️ <b>Удаление бекапа</b>\n\n"
|
||||
text += f"Вы уверены, что хотите удалить бекап?\n\n"
|
||||
text += f"📄 <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>Это действие нельзя отменить!</b>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, удалить", callback_data=f"backup_delete_confirm_{filename}"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
filename = callback.data.replace("backup_delete_confirm_", "")
|
||||
|
||||
success, message = await backup_service.delete_backup(filename)
|
||||
|
||||
if success:
|
||||
await callback.message.edit_text(
|
||||
f"✅ <b>Бекап удален</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 К списку бекапов", callback_data="backup_list")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка удаления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_start(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data.startswith("backup_restore_file_"):
|
||||
# Восстановление из конкретного файла
|
||||
filename = callback.data.replace("backup_restore_file_", "")
|
||||
|
||||
text = f"📥 <b>Восстановление из бекапа</b>\n\n"
|
||||
text += f"📄 <b>Файл:</b> <code>{filename}</code>\n\n"
|
||||
text += "⚠️ <b>ВНИМАНИЕ!</b>\n"
|
||||
text += "• Процесс может занять несколько минут\n"
|
||||
text += "• Рекомендуется создать бекап перед восстановлением\n"
|
||||
text += "• Существующие данные будут дополнены\n\n"
|
||||
text += "Продолжить восстановление?"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, восстановить", callback_data=f"backup_restore_execute_{filename}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_clear_{filename}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=f"backup_manage_{filename}")
|
||||
]
|
||||
])
|
||||
else:
|
||||
text = """📥 <b>Восстановление из бекапа</b>
|
||||
|
||||
📎 Отправьте файл бекапа (.json или .json.gz)
|
||||
|
||||
⚠️ <b>ВАЖНО:</b>
|
||||
• Файл должен быть создан этой системой бекапов
|
||||
• Процесс может занять несколько минут
|
||||
• Рекомендуется создать бекап перед восстановлением
|
||||
|
||||
💡 Или выберите из существующих бекапов ниже."""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Выбрать из списка", callback_data="backup_list")],
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
|
||||
await state.set_state(BackupStates.waiting_backup_file)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def restore_backup_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if callback.data.startswith("backup_restore_execute_"):
|
||||
filename = callback.data.replace("backup_restore_execute_", "")
|
||||
clear_existing = False
|
||||
elif callback.data.startswith("backup_restore_clear_"):
|
||||
filename = callback.data.replace("backup_restore_clear_", "")
|
||||
clear_existing = True
|
||||
else:
|
||||
await callback.answer("❌ Неверный формат команды", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("🔄 Восстановление запущено...")
|
||||
|
||||
# Показываем прогресс
|
||||
action_text = "очисткой и восстановлением" if clear_existing else "восстановлением"
|
||||
progress_msg = await callback.message.edit_text(
|
||||
f"📥 <b>Восстановление из бекапа...</b>\n\n"
|
||||
f"⏳ Работаем с {action_text} данных...\n"
|
||||
f"📄 Файл: <code>{filename}</code>\n\n"
|
||||
f"Это может занять несколько минут.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
backup_path = backup_service.backup_dir / filename
|
||||
|
||||
success, message = await backup_service.restore_backup(
|
||||
str(backup_path),
|
||||
clear_existing=clear_existing
|
||||
)
|
||||
|
||||
if success:
|
||||
await progress_msg.edit_text(
|
||||
f"✅ <b>Восстановление завершено!</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_main_keyboard(db_user.language)
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f"❌ <b>Ошибка восстановления</b>\n\n{message}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_manage_keyboard(filename)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_backup_file_upload(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
if not message.document:
|
||||
await message.answer(
|
||||
"❌ Пожалуйста, отправьте файл бекапа (.json или .json.gz)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
document = message.document
|
||||
|
||||
if not (document.file_name.endswith('.json') or document.file_name.endswith('.json.gz')):
|
||||
await message.answer(
|
||||
"❌ Неподдерживаемый формат файла. Загрузите .json или .json.gz файл",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
if document.file_size > 50 * 1024 * 1024:
|
||||
await message.answer(
|
||||
"❌ Файл слишком большой (максимум 50MB)",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
file = await message.bot.get_file(document.file_id)
|
||||
|
||||
temp_path = backup_service.backup_dir / f"uploaded_{document.file_name}"
|
||||
|
||||
await message.bot.download_file(file.file_path, temp_path)
|
||||
|
||||
text = f"""📥 <b>Файл загружен</b>
|
||||
|
||||
📄 <b>Имя:</b> <code>{document.file_name}</code>
|
||||
💾 <b>Размер:</b> {document.file_size / 1024 / 1024:.2f} MB
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ!</b>
|
||||
Процесс восстановления изменит данные в базе.
|
||||
Рекомендуется создать бекап перед восстановлением.
|
||||
|
||||
Продолжить?"""
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Восстановить", callback_data=f"backup_restore_uploaded_{temp_path.name}"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить и восстановить", callback_data=f"backup_restore_uploaded_clear_{temp_path.name}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="backup_panel")
|
||||
]
|
||||
])
|
||||
|
||||
await message.answer(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки файла бекапа: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка загрузки файла: {str(e)}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="◀️ Отмена", callback_data="backup_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_backup_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
text = f"""⚙️ <b>Настройки системы бекапов</b>
|
||||
|
||||
🔄 <b>Автоматические бекапы:</b>
|
||||
• Статус: {'✅ Включены' if settings_obj.auto_backup_enabled else '❌ Отключены'}
|
||||
• Интервал: {settings_obj.backup_interval_hours} часов
|
||||
• Время запуска: {settings_obj.backup_time}
|
||||
|
||||
📦 <b>Хранение:</b>
|
||||
• Максимум файлов: {settings_obj.max_backups_keep}
|
||||
• Сжатие: {'✅ Включено' if settings_obj.compression_enabled else '❌ Отключено'}
|
||||
• Включать логи: {'✅ Да' if settings_obj.include_logs else '❌ Нет'}
|
||||
|
||||
📁 <b>Расположение:</b> <code>{settings_obj.backup_location}</code>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_backup_settings_keyboard(settings_obj)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_backup_setting(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
|
||||
if callback.data == "backup_toggle_auto":
|
||||
new_value = not settings_obj.auto_backup_enabled
|
||||
await backup_service.update_backup_settings(auto_backup_enabled=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Автобекапы {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_compression":
|
||||
new_value = not settings_obj.compression_enabled
|
||||
await backup_service.update_backup_settings(compression_enabled=new_value)
|
||||
status = "включено" if new_value else "отключено"
|
||||
await callback.answer(f"Сжатие {status}")
|
||||
|
||||
elif callback.data == "backup_toggle_logs":
|
||||
new_value = not settings_obj.include_logs
|
||||
await backup_service.update_backup_settings(include_logs=new_value)
|
||||
status = "включены" if new_value else "отключены"
|
||||
await callback.answer(f"Логи в бекапе {status}")
|
||||
|
||||
await show_backup_settings(callback, db_user, db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_panel,
|
||||
F.data == "backup_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
create_backup_handler,
|
||||
F.data == "backup_create"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_list,
|
||||
F.data.startswith("backup_list")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
manage_backup_file,
|
||||
F.data.startswith("backup_manage_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_confirm,
|
||||
F.data.startswith("backup_delete_") & ~F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_backup_execute,
|
||||
F.data.startswith("backup_delete_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_start,
|
||||
F.data.in_(["backup_restore"]) | F.data.startswith("backup_restore_file_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
restore_backup_execute,
|
||||
F.data.startswith("backup_restore_execute_") | F.data.startswith("backup_restore_clear_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_backup_settings,
|
||||
F.data == "backup_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_backup_setting,
|
||||
F.data.in_(["backup_toggle_auto", "backup_toggle_compression", "backup_toggle_logs"])
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
handle_backup_file_upload,
|
||||
BackupStates.waiting_backup_file
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
+275
-2
@@ -1,12 +1,22 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.keyboards.admin import (
|
||||
get_admin_main_keyboard,
|
||||
get_admin_users_submenu_keyboard,
|
||||
get_admin_promo_submenu_keyboard,
|
||||
get_admin_communications_submenu_keyboard,
|
||||
get_admin_settings_submenu_keyboard,
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +31,25 @@ async def show_admin_panel(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
admin_text = texts.ADMIN_PANEL
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
stats = await remnawave_service.get_system_statistics()
|
||||
system_stats = stats.get("system", {})
|
||||
users_online = system_stats.get("users_online", 0)
|
||||
users_today = system_stats.get("users_last_day", 0)
|
||||
users_week = system_stats.get("users_last_week", 0)
|
||||
admin_text = admin_text.replace(
|
||||
"\n\nВыберите раздел для управления:",
|
||||
(
|
||||
f"\n\n- 🟢 Онлайн сейчас: {users_online}"
|
||||
f"\n- 📅 Онлайн сегодня: {users_today}"
|
||||
f"\n- 🗓️ На этой неделе: {users_week}"
|
||||
"\n\nВыберите раздел для управления:"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось получить статистику Remnawave для админ-панели: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
admin_text,
|
||||
@@ -29,9 +58,253 @@ async def show_admin_panel(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_users_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 **Управление пользователями и подписками**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
reply_markup=get_admin_users_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💰 **Промокоды и статистика**\n\n"
|
||||
"Выберите нужный раздел:",
|
||||
reply_markup=get_admin_promo_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_communications_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 **Коммуникации**\n\n"
|
||||
"Управление рассылками и текстами интерфейса:",
|
||||
reply_markup=get_admin_communications_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_settings_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚙️ **Настройки системы**\n\n"
|
||||
"Управление Remnawave, мониторингом и другими настройками:",
|
||||
reply_markup=get_admin_settings_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_system_submenu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🛠️ **Системные функции**\n\n"
|
||||
"Обновления, резервные копии и системные операции:",
|
||||
reply_markup=get_admin_system_submenu_keyboard(db_user.language),
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if stats['total_active'] == 0:
|
||||
await message.reply(
|
||||
"ℹ️ <b>Правила уже очищены</b>\n\n"
|
||||
"В системе нет активных правил. Используются стандартные правила по умолчанию."
|
||||
)
|
||||
return
|
||||
|
||||
success = await clear_all_rules(db, db_user.language)
|
||||
|
||||
if success:
|
||||
clear_rules_cache()
|
||||
|
||||
await message.reply(
|
||||
f"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
f"📊 <b>Статистика:</b>\n"
|
||||
f"• Очищено правил: {stats['total_active']}\n"
|
||||
f"• Язык: {db_user.language}\n"
|
||||
f"• Выполнил: {db_user.full_name}\n\n"
|
||||
f"Теперь используются стандартные правила по умолчанию."
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены командой администратором {db_user.telegram_id} ({db_user.full_name})")
|
||||
else:
|
||||
await message.reply(
|
||||
"⚠️ <b>Нет правил для очистки</b>\n\n"
|
||||
"Активные правила не найдены."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил командой: {e}")
|
||||
await message.reply(
|
||||
"❌ <b>Ошибка при очистке правил</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}\n"
|
||||
"Попробуйте через админ-панель или повторите позже."
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def rules_stats_command(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
stats = await get_rules_statistics(db)
|
||||
|
||||
if 'error' in stats:
|
||||
await message.reply(f"❌ Ошибка получения статистики: {stats['error']}")
|
||||
return
|
||||
|
||||
text = f"📊 <b>Статистика правил сервиса</b>\n\n"
|
||||
text += f"📋 <b>Общая информация:</b>\n"
|
||||
text += f"• Активных правил: {stats['total_active']}\n"
|
||||
text += f"• Всего в истории: {stats['total_all_time']}\n"
|
||||
text += f"• Поддерживаемых языков: {stats['total_languages']}\n\n"
|
||||
|
||||
if stats['languages']:
|
||||
text += f"🌐 <b>По языкам:</b>\n"
|
||||
for lang, lang_stats in stats['languages'].items():
|
||||
text += f"• <code>{lang}</code>: {lang_stats['active_count']} правил, "
|
||||
text += f"{lang_stats['content_length']} символов\n"
|
||||
if lang_stats['last_updated']:
|
||||
text += f" Обновлено: {lang_stats['last_updated'].strftime('%d.%m.%Y %H:%M')}\n"
|
||||
else:
|
||||
text += "ℹ️ Активных правил нет - используются правила по умолчанию"
|
||||
|
||||
await message.reply(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении статистики правил: {e}")
|
||||
await message.reply(
|
||||
f"❌ <b>Ошибка получения статистики</b>\n\n"
|
||||
f"Произошла ошибка: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_commands_help(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = """
|
||||
🔧 <b>Доступные админские команды:</b>
|
||||
|
||||
<b>📋 Управление правилами:</b>
|
||||
• <code>/clear_rules</code> - очистить все правила
|
||||
• <code>/rules_stats</code> - статистика правил
|
||||
|
||||
<b>ℹ️ Справка:</b>
|
||||
• <code>/admin_help</code> - это сообщение
|
||||
|
||||
<b>📱 Панель управления:</b>
|
||||
Используйте кнопку "Админ панель" в главном меню для полного доступа ко всем функциям.
|
||||
|
||||
<b>⚠️ Важно:</b>
|
||||
Все команды логируются и требуют админских прав.
|
||||
"""
|
||||
|
||||
await message.reply(help_text)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_users_submenu,
|
||||
F.data == "admin_submenu_users"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_promo_submenu,
|
||||
F.data == "admin_submenu_promo"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_communications_submenu,
|
||||
F.data == "admin_submenu_communications"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_settings_submenu,
|
||||
F.data == "admin_submenu_settings"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
Command("clear_rules")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
rules_stats_command,
|
||||
Command("rules_stats")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
admin_commands_help,
|
||||
Command("admin_help")
|
||||
)
|
||||
+462
-28
@@ -13,7 +13,10 @@ from app.database.models import User, UserStatus, Subscription, BroadcastHistory
|
||||
from app.keyboards.admin import (
|
||||
get_admin_messages_keyboard, get_broadcast_target_keyboard,
|
||||
get_custom_criteria_keyboard, get_broadcast_history_keyboard,
|
||||
get_admin_pagination_keyboard
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTONS, BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
BROADCAST_BUTTON_LABELS
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
@@ -22,6 +25,44 @@ from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUTTON_CONFIG = BROADCAST_BUTTONS
|
||||
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
|
||||
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
|
||||
BUTTON_LABELS = BROADCAST_BUTTON_LABELS
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard(list(DEFAULT_SELECTED_BUTTONS), language)
|
||||
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard(selected_buttons: list, language: str = "ru") -> types.InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(selected_buttons, False, language)
|
||||
|
||||
|
||||
def create_broadcast_keyboard(selected_buttons: list) -> Optional[types.InlineKeyboardMarkup]:
|
||||
selected_buttons = selected_buttons or []
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
for row in BUTTON_ROWS:
|
||||
row_buttons: list[types.InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
if button_key not in selected_buttons:
|
||||
continue
|
||||
button_config = BUTTON_CONFIG[button_key]
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=button_config["text"],
|
||||
callback_data=button_config["callback"]
|
||||
)
|
||||
)
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
if not keyboard:
|
||||
return None
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -107,13 +148,16 @@ async def show_messages_history(
|
||||
|
||||
message_preview = broadcast.message_text[:100] + "..." if len(broadcast.message_text) > 100 else broadcast.message_text
|
||||
|
||||
import html
|
||||
message_preview = html.escape(message_preview)
|
||||
|
||||
text += f"""
|
||||
{status_emoji} <b>{broadcast.created_at.strftime('%d.%m.%Y %H:%M')}</b>
|
||||
📊 Отправлено: {broadcast.sent_count}/{broadcast.total_count} ({success_rate}%)
|
||||
🎯 Аудитория: {get_target_name(broadcast.target_type)}
|
||||
👤 Админ: {broadcast.admin_name}
|
||||
📝 Сообщение: <i>{message_preview}</i>
|
||||
━━━━━━━━━━━━━━━━━━━━
|
||||
📝 Сообщение: {message_preview}
|
||||
━━━━━━━━━━━━━━━━━━━━━━━
|
||||
"""
|
||||
|
||||
keyboard = get_broadcast_history_keyboard(page, total_pages, db_user.language).inline_keyboard
|
||||
@@ -121,7 +165,7 @@ async def show_messages_history(
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -260,15 +304,323 @@ async def process_broadcast_message(
|
||||
await message.answer("❌ Сообщение слишком длинное (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
|
||||
await state.update_data(broadcast_message=broadcast_text)
|
||||
|
||||
await message.answer(
|
||||
"🖼️ <b>Добавление медиафайла</b>\n\n"
|
||||
"Вы можете добавить к сообщению фото, видео или документ.\n"
|
||||
"Или пропустить этот шаг.\n\n"
|
||||
"Выберите тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
if callback.data == "skip_media":
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_broadcast_media(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
expected_type = data.get('media_type')
|
||||
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
|
||||
if message.photo and expected_type == "photo":
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_type = "photo"
|
||||
elif message.video and expected_type == "video":
|
||||
media_file_id = message.video.file_id
|
||||
media_type = "video"
|
||||
elif message.document and expected_type == "document":
|
||||
media_file_id = message.document.file_id
|
||||
media_type = "document"
|
||||
else:
|
||||
await message.answer(
|
||||
f"❌ Пожалуйста, отправьте {expected_type} как указано в инструкции."
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
has_media=True,
|
||||
media_file_id=media_file_id,
|
||||
media_type=media_type,
|
||||
media_caption=message.caption
|
||||
)
|
||||
|
||||
await show_media_preview(message, db_user, state)
|
||||
|
||||
async def show_media_preview(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
|
||||
preview_text = f"🖼️ <b>Медиафайл добавлен</b>\n\n" \
|
||||
f"📎 <b>Тип:</b> {media_type}\n" \
|
||||
f"✅ Файл сохранен и готов к отправке\n\n" \
|
||||
f"Что делать дальше?"
|
||||
|
||||
# Для предпросмотра рассылки используем оригинальный метод без патчинга логотипа
|
||||
# чтобы показать именно загруженное фото
|
||||
from app.utils.message_patch import _original_answer
|
||||
|
||||
if media_type == "photo" and media_file_id:
|
||||
# Показываем предпросмотр с загруженным фото
|
||||
await message.bot.send_photo(
|
||||
chat_id=message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для других типов медиа или если нет фото, используем обычное сообщение
|
||||
await _original_answer(message, preview_text,
|
||||
reply_markup=get_media_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML")
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_media_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
action = callback.data
|
||||
|
||||
if action == "confirm_media":
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
elif action == "replace_media":
|
||||
data = await state.get_data()
|
||||
media_type = data.get('media_type', 'photo')
|
||||
await handle_media_selection(callback, db_user, state)
|
||||
elif action == "skip_media":
|
||||
await state.update_data(
|
||||
has_media=False,
|
||||
media_file_id=None,
|
||||
media_type=None,
|
||||
media_caption=None
|
||||
)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_change_media(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🖼️ <b>Изменение медиафайла</b>\n\n"
|
||||
"Выберите новый тип медиа:",
|
||||
reply_markup=get_broadcast_media_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector_callback(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
has_media = data.get('has_media', False)
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||||
📱 <b>Подписка</b> — покажет состояние подписки
|
||||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||||
|
||||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.{media_info}
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_button_selector(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
has_media = data.get('has_media', False)
|
||||
|
||||
text = """
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
Выберите кнопки, которые будут добавлены к сообщению рассылки:
|
||||
|
||||
💰 <b>Пополнить баланс</b> — откроет методы пополнения
|
||||
🤝 <b>Партнерка</b> — откроет реферальную программу
|
||||
🎫 <b>Промокод</b> — откроет форму ввода промокода
|
||||
🔗 <b>Подключиться</b> — поможет подключить приложение
|
||||
📱 <b>Подписка</b> — покажет состояние подписки
|
||||
🛠️ <b>Техподдержка</b> — свяжет с поддержкой
|
||||
|
||||
🏠 <b>Кнопка "На главную"</b> включена по умолчанию, но вы можете отключить её при необходимости.
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
button_type = callback.data.replace('btn_', '')
|
||||
data = await state.get_data()
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
else:
|
||||
selected_buttons = list(selected_buttons)
|
||||
|
||||
if button_type in selected_buttons:
|
||||
selected_buttons.remove(button_type)
|
||||
else:
|
||||
selected_buttons.append(button_type)
|
||||
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
has_media = data.get('has_media', False)
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_button_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
|
||||
user_count = await get_target_users_count(db, target) if not target.startswith('custom_') else await get_custom_users_count(db, target.replace('custom_', ''))
|
||||
target_display = get_target_display_name(target)
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type_names = {
|
||||
"photo": "Фотография",
|
||||
"video": "Видео",
|
||||
"document": "Документ"
|
||||
}
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type_names.get(media_type, media_type)}"
|
||||
|
||||
ordered_keys = [button_key for row in BUTTON_ROWS for button_key in row]
|
||||
selected_names = [BUTTON_LABELS[key] for key in ordered_keys if key in selected_buttons]
|
||||
if selected_names:
|
||||
buttons_info = f"\n📘 <b>Кнопки:</b> {', '.join(selected_names)}"
|
||||
else:
|
||||
buttons_info = "\n📘 <b>Кнопки:</b> отсутствуют"
|
||||
|
||||
preview_text = f"""
|
||||
📨 <b>Предварительный просмотр рассылки</b>
|
||||
|
||||
@@ -276,7 +628,9 @@ async def process_broadcast_message(
|
||||
👥 <b>Получателей:</b> {user_count}
|
||||
|
||||
📝 <b>Сообщение:</b>
|
||||
{broadcast_text}
|
||||
{message_text}{media_info}
|
||||
|
||||
{buttons_info}
|
||||
|
||||
Подтвердить отправку?
|
||||
"""
|
||||
@@ -284,18 +638,48 @@ async def process_broadcast_message(
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Отправить", callback_data="admin_confirm_broadcast"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
types.InlineKeyboardButton(text="📘 Изменить кнопки", callback_data="edit_buttons")
|
||||
]
|
||||
]
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
])
|
||||
|
||||
# Если есть медиа, показываем его с загруженным фото, иначе обычное текстовое сообщение
|
||||
if has_media and media_type == "photo":
|
||||
media_file_id = data.get('media_file_id')
|
||||
if media_file_id:
|
||||
# Удаляем текущее сообщение и отправляем новое с фото
|
||||
await callback.message.delete()
|
||||
await callback.bot.send_photo(
|
||||
chat_id=callback.message.chat.id,
|
||||
photo=media_file_id,
|
||||
caption=preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Если нет file_id, используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
# Для текстовых сообщений или других типов медиа используем обычное редактирование
|
||||
await callback.message.edit_text(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_broadcast(
|
||||
@@ -307,6 +691,13 @@ async def confirm_broadcast(
|
||||
data = await state.get_data()
|
||||
target = data.get('broadcast_target')
|
||||
message_text = data.get('broadcast_message')
|
||||
selected_buttons = data.get('selected_buttons')
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
has_media = data.get('has_media', False)
|
||||
media_type = data.get('media_type')
|
||||
media_file_id = data.get('media_file_id')
|
||||
media_caption = data.get('media_caption')
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📨 Начинаю рассылку...\n\n"
|
||||
@@ -323,6 +714,10 @@ async def confirm_broadcast(
|
||||
broadcast_history = BroadcastHistory(
|
||||
target_type=target,
|
||||
message_text=message_text,
|
||||
has_media=has_media,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
total_count=len(users),
|
||||
sent_count=0,
|
||||
failed_count=0,
|
||||
@@ -337,13 +732,42 @@ async def confirm_broadcast(
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
broadcast_keyboard = create_broadcast_keyboard(selected_buttons)
|
||||
|
||||
for user in users:
|
||||
try:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
if has_media and media_file_id:
|
||||
if media_type == "photo":
|
||||
await callback.bot.send_photo(
|
||||
chat_id=user.telegram_id,
|
||||
photo=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "video":
|
||||
await callback.bot.send_video(
|
||||
chat_id=user.telegram_id,
|
||||
video=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
elif media_type == "document":
|
||||
await callback.bot.send_document(
|
||||
chat_id=user.telegram_id,
|
||||
document=media_file_id,
|
||||
caption=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
else:
|
||||
await callback.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
reply_markup=broadcast_keyboard
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
if sent_count % 20 == 0:
|
||||
@@ -359,6 +783,10 @@ async def confirm_broadcast(
|
||||
broadcast_history.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type}"
|
||||
|
||||
result_text = f"""
|
||||
✅ <b>Рассылка завершена!</b>
|
||||
|
||||
@@ -366,7 +794,7 @@ async def confirm_broadcast(
|
||||
- Отправлено: {sent_count}
|
||||
- Не доставлено: {failed_count}
|
||||
- Всего пользователей: {len(users)}
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%
|
||||
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
|
||||
|
||||
<b>Администратор:</b> {db_user.full_name}
|
||||
"""
|
||||
@@ -380,7 +808,7 @@ async def confirm_broadcast(
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)}")
|
||||
logger.info(f"Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})")
|
||||
|
||||
|
||||
async def get_target_users_count(db: AsyncSession, target: str) -> int:
|
||||
@@ -413,7 +841,6 @@ async def get_custom_users_count(db: AsyncSession, criteria: str) -> int:
|
||||
|
||||
|
||||
async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
"""Получение пользователей по настраиваемым критериям"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -462,7 +889,6 @@ async def get_custom_users(db: AsyncSession, criteria: str) -> list:
|
||||
|
||||
|
||||
async def get_users_statistics(db: AsyncSession) -> dict:
|
||||
"""Получение статистики пользователей для отображения"""
|
||||
now = datetime.utcnow()
|
||||
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
@@ -557,4 +983,12 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_custom_broadcast, F.data == "admin_msg_custom")
|
||||
dp.callback_query.register(select_custom_criteria, F.data.startswith("criteria_"))
|
||||
|
||||
dp.callback_query.register(toggle_button_selection, F.data.startswith("btn_"))
|
||||
dp.callback_query.register(confirm_button_selection, F.data == "buttons_confirm")
|
||||
dp.callback_query.register(show_button_selector_callback, F.data == "edit_buttons")
|
||||
dp.callback_query.register(handle_media_selection, F.data.startswith("add_media_"))
|
||||
dp.callback_query.register(handle_media_selection, F.data == "skip_media")
|
||||
dp.callback_query.register(handle_media_confirmation, F.data.in_(["confirm_media", "replace_media"]))
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -19,7 +20,6 @@ router = Router()
|
||||
@router.callback_query(F.data == "admin_monitoring")
|
||||
@admin_required
|
||||
async def admin_monitoring_menu(callback: CallbackQuery):
|
||||
"""Главное меню мониторинга"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -127,50 +127,57 @@ async def force_check_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_logs")
|
||||
@router.callback_query(F.data.startswith("admin_mon_logs"))
|
||||
@admin_required
|
||||
async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
page = 1
|
||||
if "_page_" in callback.data:
|
||||
page = int(callback.data.split("_page_")[1])
|
||||
|
||||
async for db in get_db():
|
||||
logs = await monitoring_service.get_monitoring_logs(db, limit=15)
|
||||
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
|
||||
if not logs:
|
||||
text = "📝 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполняла проверки."
|
||||
else:
|
||||
text = "📝 <b>Последние логи мониторинга:</b>\n\n"
|
||||
if not all_logs:
|
||||
text = "📋 <b>Логи мониторинга пусты</b>\n\nСистема еще не выполнила проверки."
|
||||
keyboard = get_monitoring_logs_back_keyboard()
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
return
|
||||
|
||||
per_page = 8
|
||||
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
|
||||
|
||||
text = f"📋 <b>Логи мониторинга</b> (стр. {page}/{paginated_logs.total_pages})\n\n"
|
||||
|
||||
for log in paginated_logs.items:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
for log in logs:
|
||||
icon = "✅" if log['is_success'] else "❌"
|
||||
time_str = log['created_at'].strftime('%m-%d %H:%M')
|
||||
event_type = log['event_type'].replace('_', ' ').title()
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
|
||||
message = log['message']
|
||||
if len(message) > 60:
|
||||
message = message[:60] + "..."
|
||||
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
if len(text) > 3500:
|
||||
text += "...\n\n<i>Показаны последние записи. Для просмотра всех логов используйте файл логов.</i>"
|
||||
break
|
||||
message = log['message']
|
||||
if len(message) > 45:
|
||||
message = message[:45] + "..."
|
||||
|
||||
text += f"{icon} <code>{time_str}</code> {event_type}\n"
|
||||
text += f" 📄 {message}\n\n"
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
total_success = sum(1 for log in all_logs if log['is_success'])
|
||||
total_failed = len(all_logs) - total_success
|
||||
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
|
||||
|
||||
text += f"📊 <b>Общая статистика:</b>\n"
|
||||
text += f"• Всего событий: {len(all_logs)}\n"
|
||||
text += f"• Успешных: {total_success}\n"
|
||||
text += f"• Ошибок: {total_failed}\n"
|
||||
text += f"• Успешность: {success_rate}%"
|
||||
|
||||
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов: {e}")
|
||||
await callback.answer(f"❌ Ошибка получения логов: {str(e)}", show_alert=True)
|
||||
await callback.answer("❌ Ошибка получения логов", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_clear_logs")
|
||||
@@ -178,12 +185,12 @@ async def monitoring_logs_callback(callback: CallbackQuery):
|
||||
async def clear_logs_callback(callback: CallbackQuery):
|
||||
try:
|
||||
async for db in get_db():
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=7)
|
||||
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
|
||||
|
||||
if deleted_count > 0:
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} старых записей логов")
|
||||
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
|
||||
else:
|
||||
await callback.answer("ℹ️ Нет старых логов для удаления")
|
||||
await callback.answer("ℹ️ Логи уже пусты")
|
||||
|
||||
await monitoring_logs_callback(callback)
|
||||
break
|
||||
@@ -196,7 +203,6 @@ async def clear_logs_callback(callback: CallbackQuery):
|
||||
@router.callback_query(F.data == "admin_mon_test_notifications")
|
||||
@admin_required
|
||||
async def test_notifications_callback(callback: CallbackQuery):
|
||||
"""Тест системы уведомлений"""
|
||||
try:
|
||||
test_message = f"""
|
||||
🧪 <b>Тестовое уведомление системы мониторинга</b>
|
||||
@@ -280,10 +286,62 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_monitoring_logs_back_keyboard():
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("monitoring"))
|
||||
@admin_required
|
||||
async def monitoring_command(message: Message):
|
||||
"""Команда /monitoring для быстрого доступа"""
|
||||
try:
|
||||
async for db in get_db():
|
||||
status = await monitoring_service.get_monitoring_status(db)
|
||||
@@ -309,5 +367,4 @@ async def monitoring_command(message: Message):
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Регистрация обработчиков мониторинга"""
|
||||
dp.include_router(router)
|
||||
+223
-42
@@ -8,7 +8,8 @@ from app.states import AdminStates
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
from app.database.crud.rules import get_current_rules_content, create_or_update_rules, clear_all_rules
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,7 +32,9 @@ async def show_rules_management(
|
||||
keyboard = [
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Просмотр правил", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="ℹ️ Помощь по HTML", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -48,16 +51,33 @@ async def view_current_rules(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
is_valid, error_msg = validate_html_tags(current_rules)
|
||||
warning = ""
|
||||
if not is_valid:
|
||||
warning = f"\n\n⚠️ <b>Внимание:</b> В правилах найдена ошибка HTML: {error_msg}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Текущие правила сервиса</b>\n\n{current_rules}{warning}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="✏️ Редактировать", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе правил: {e}")
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при загрузке правил. Возможно, в тексте есть некорректные HTML теги.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🗑️ Очистить правила", callback_data="admin_clear_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -68,20 +88,33 @@ async def start_edit_rules(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n{current_rules[:500]}{'...' if len(current_rules) > 500 else ''}\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
try:
|
||||
current_rules = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
|
||||
|
||||
text = (
|
||||
"✏️ <b>Редактирование правил</b>\n\n"
|
||||
f"<b>Текущие правила:</b>\n<code>{preview}</code>\n\n"
|
||||
"Отправьте новый текст правил сервиса.\n\n"
|
||||
"<i>Поддерживается HTML разметка. Все теги будут проверены перед сохранением.</i>\n\n"
|
||||
"💡 <b>Совет:</b> Нажмите /html_help для просмотра поддерживаемых тегов"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_rules_page)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при начале редактирования правил: {e}")
|
||||
await callback.answer("❌ Ошибка при загрузке правил для редактирования", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -98,19 +131,61 @@ async def process_rules_edit(
|
||||
await message.answer("❌ Текст правил слишком длинный (максимум 4000 символов)")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ <b>Ошибка в HTML разметке:</b>\n{error_msg}\n\n"
|
||||
f"Пожалуйста, исправьте ошибки и отправьте текст заново.\n\n"
|
||||
f"💡 Используйте /html_help для просмотра правильного синтаксиса",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="ℹ️ HTML помощь", callback_data="admin_rules_help")],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
try:
|
||||
preview_text = f"📋 <b>Предварительный просмотр новых правил:</b>\n\n{new_rules}\n\n"
|
||||
preview_text += f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
preview_text += f"Сохранить изменения?"
|
||||
|
||||
if len(preview_text) > 4000:
|
||||
preview_text = (
|
||||
"📋 <b>Предварительный просмотр новых правил:</b>\n\n"
|
||||
f"{new_rules[:500]}...\n\n"
|
||||
f"⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n"
|
||||
f"Текст правил: {len(new_rules)} символов\n"
|
||||
f"Сохранить изменения?"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
preview_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе превью правил: {e}")
|
||||
await message.answer(
|
||||
"⚠️ <b>Подтверждение сохранения правил</b>\n\n"
|
||||
f"Новые правила готовы к сохранению ({len(new_rules)} символов).\n"
|
||||
f"HTML теги проверены и корректны.\n\n"
|
||||
f"Сохранить изменения?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Сохранить", callback_data="admin_save_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
await state.update_data(new_rules=new_rules)
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -128,6 +203,20 @@ async def save_rules(
|
||||
await callback.answer("❌ Ошибка: текст правил не найден", show_alert=True)
|
||||
return
|
||||
|
||||
is_valid, error_msg = validate_html_tags(new_rules)
|
||||
if not is_valid:
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка при сохранении:</b>\n{error_msg}\n\n"
|
||||
f"Правила не были сохранены из-за ошибок в HTML разметке.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
await create_or_update_rules(
|
||||
db=db,
|
||||
@@ -142,10 +231,14 @@ async def save_rules(
|
||||
await refresh_rules_cache(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила сервиса обновлены!</b>\n\n"
|
||||
"Новые правила сохранены в базе данных и будут показываться пользователям.\n\n"
|
||||
"Кеш правил очищен и обновлен.",
|
||||
"✅ <b>Правила сервиса успешно обновлены!</b>\n\n"
|
||||
"✓ Новые правила сохранены в базе данных\n"
|
||||
"✓ HTML теги проверены и корректны\n"
|
||||
"✓ Кеш правил очищен и обновлен\n"
|
||||
"✓ Правила будут показываться пользователям\n\n"
|
||||
f"📊 Размер текста: {len(new_rules)} символов",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="👀 Просмотреть", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
@@ -156,7 +249,90 @@ async def save_rules(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения правил: {e}")
|
||||
await callback.answer("❌ Ошибка сохранения правил", show_alert=True)
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Ошибка при сохранении правил</b>\n\n"
|
||||
"Произошла ошибка при записи в базу данных. Попробуйте еще раз.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔄 Попробовать снова", callback_data="admin_save_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_rules_confirmation(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Очистка правил сервиса</b>\n\n"
|
||||
"⚠️ <b>ВНИМАНИЕ!</b> Вы собираетесь полностью удалить все правила сервиса.\n\n"
|
||||
"После очистки пользователи будут видеть стандартные правила по умолчанию.\n\n"
|
||||
"Это действие нельзя отменить. Продолжить?",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_confirm_clear_rules"),
|
||||
types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_rules")
|
||||
]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def confirm_clear_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
await clear_all_rules(db, db_user.language)
|
||||
|
||||
from app.localization.texts import clear_rules_cache
|
||||
clear_rules_cache()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Правила успешно очищены!</b>\n\n"
|
||||
"✓ Все пользовательские правила удалены\n"
|
||||
"✓ Теперь используются стандартные правила\n"
|
||||
"✓ Кеш правил очищен\n\n"
|
||||
"Пользователи будут видеть правила по умолчанию.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Создать новые", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="👀 Посмотреть текущие", callback_data="admin_view_rules")],
|
||||
[types.InlineKeyboardButton(text="📋 К правилам", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Правила очищены администратором {db_user.telegram_id}")
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при очистке правил: {e}")
|
||||
await callback.answer("❌ Ошибка при очистке правил", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_html_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
help_text = get_html_help_text()
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"ℹ️ <b>Справка по HTML форматированию</b>\n\n{help_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📝 Редактировать правила", callback_data="admin_edit_rules")],
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_rules")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
@@ -165,4 +341,9 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_rules, F.data == "admin_edit_rules")
|
||||
dp.callback_query.register(save_rules, F.data == "admin_save_rules")
|
||||
|
||||
dp.callback_query.register(clear_rules_confirmation, F.data == "admin_clear_rules")
|
||||
dp.callback_query.register(confirm_clear_rules, F.data == "admin_confirm_clear_rules")
|
||||
|
||||
dp.callback_query.register(show_html_help, F.data == "admin_rules_help")
|
||||
|
||||
dp.message.register(process_rules_edit, AdminStates.editing_rules_page)
|
||||
+743
-207
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.states import AdminStates
|
||||
from app.keyboards.admin import get_welcome_text_keyboard, get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.welcome_text import (
|
||||
get_active_welcome_text,
|
||||
set_welcome_text,
|
||||
get_current_welcome_text_or_default,
|
||||
get_available_placeholders,
|
||||
get_current_welcome_text_settings,
|
||||
toggle_welcome_text_status
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_telegram_formatting_info() -> str:
|
||||
return """
|
||||
📝 <b>Поддерживаемые теги форматирования:</b>
|
||||
|
||||
• <code><b>жирный текст</b></code> → <b>жирный текст</b>
|
||||
• <code><i>курсив</i></code> → <i>курсив</i>
|
||||
• <code><u>подчеркнутый</u></code> → <u>подчеркнутый</u>
|
||||
• <code><s>зачеркнутый</s></code> → <s>зачеркнутый</s>
|
||||
• <code><code>моноширинный</code></code> → <code>моноширинный</code>
|
||||
• <code><pre>блок кода</pre></code> → многострочный код
|
||||
• <code><a href="URL">ссылка</a></code> → ссылка
|
||||
|
||||
⚠️ <b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
|
||||
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
|
||||
|
||||
❌ <b>НЕ используйте:</b> <div>, <span>, <p>, <br>, <h1>-<h6>, <img> и другие HTML-теги.
|
||||
"""
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_welcome_text_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_status = await toggle_welcome_text_status(db, db_user.id)
|
||||
|
||||
status_emoji = "🟢" if new_status else "🔴"
|
||||
status_text = "включено" if new_status else "отключено"
|
||||
action_text = "включены" if new_status else "отключены"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"👋 Управление приветственным текстом\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"✅ Приветственные сообщения {action_text}!\n\n"
|
||||
f"Здесь вы можете управлять текстом, который показывается новым пользователям после регистрации.\n\n"
|
||||
f"💡 Доступные плейсхолдеры для автозамены:",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, new_status),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_current_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
is_enabled = settings['is_enabled']
|
||||
|
||||
if not settings['id']:
|
||||
status = "📝 Используется стандартный текст:"
|
||||
else:
|
||||
status = "📝 Текущий приветственный текст:"
|
||||
|
||||
status_emoji = "🟢" if is_enabled else "🔴"
|
||||
status_text = "включено" if is_enabled else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"{status}\n\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, is_enabled),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_placeholders_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>\n {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
help_text = (
|
||||
"💡 Доступные плейсхолдеры для автозамены:\n\n"
|
||||
f"{placeholders_text}\n\n"
|
||||
"📌 Примеры использования:\n"
|
||||
"• <code>Привет, {user_name}! Добро пожаловать!</code>\n"
|
||||
"• <code>Здравствуйте, {first_name}! Рады видеть вас!</code>\n"
|
||||
"• <code>Привет, {username}! Спасибо за регистрацию!</code>\n\n"
|
||||
"При отсутствии данных пользователя используется слово 'друг'."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
help_text,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_formatting_help(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
formatting_info = get_telegram_formatting_info()
|
||||
|
||||
await callback.message.edit_text(
|
||||
formatting_info,
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
current_text = settings['text']
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code> - {desc}" for key, desc in placeholders.items()])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 Редактирование приветственного текста\n\n"
|
||||
f"Текущий текст:\n"
|
||||
f"<code>{current_text}</code>\n\n"
|
||||
f"💡 Доступные плейсхолдеры:\n{placeholders_text}\n\n"
|
||||
f"Отправьте новый текст:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.editing_welcome_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_welcome_text_edit(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) < 10:
|
||||
await message.answer("❌ Текст слишком короткий! Минимум 10 символов.")
|
||||
return
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer("❌ Текст слишком длинный! Максимум 4000 символов.")
|
||||
return
|
||||
|
||||
success = await set_welcome_text(db, new_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
placeholders = get_available_placeholders()
|
||||
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
|
||||
|
||||
await message.answer(
|
||||
f"✅ Приветственный текст успешно обновлен!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Новый текст:\n"
|
||||
f"<code>{new_text}</code>\n\n"
|
||||
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
await message.answer(
|
||||
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def reset_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
success = await set_welcome_text(db, default_text, db_user.id)
|
||||
|
||||
if success:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
status_emoji = "🟢" if settings['is_enabled'] else "🔴"
|
||||
status_text = "включено" if settings['is_enabled'] else "отключено"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Приветственный текст сброшен на стандартный!\n\n"
|
||||
f"{status_emoji} <b>Статус:</b> {status_text}\n\n"
|
||||
f"Стандартный текст:\n"
|
||||
f"<code>{default_text}</code>\n\n"
|
||||
f"💡 Плейсхолдер <code>{{user_name}}</code> будет заменяться на имя пользователя",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled'])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_preview_welcome_text(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
|
||||
class TestUser:
|
||||
def __init__(self):
|
||||
self.first_name = "Иван"
|
||||
self.username = "test_user"
|
||||
|
||||
test_user = TestUser()
|
||||
preview_text = await get_welcome_text_for_user(db, test_user)
|
||||
|
||||
settings = await get_current_welcome_text_settings(db)
|
||||
|
||||
if preview_text:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"Как будет выглядеть текст для пользователя 'Иван' (@test_user):\n\n"
|
||||
f"<code>{preview_text}</code>",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f"👁️ Предварительный просмотр\n\n"
|
||||
f"🔴 Приветственные сообщения отключены.\n"
|
||||
f"Новые пользователи не будут получать приветственный текст после регистрации.",
|
||||
reply_markup=get_welcome_text_keyboard(db_user.language, settings['is_enabled']),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def register_welcome_text_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(
|
||||
show_welcome_text_panel,
|
||||
F.data == "welcome_text_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_welcome_text,
|
||||
F.data == "toggle_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_current_welcome_text,
|
||||
F.data == "show_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_placeholders_help,
|
||||
F.data == "show_placeholders_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_formatting_help,
|
||||
F.data == "show_formatting_help"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_preview_welcome_text,
|
||||
F.data == "preview_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_edit_welcome_text,
|
||||
F.data == "edit_welcome_text"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
reset_welcome_text,
|
||||
F.data == "reset_welcome_text"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_welcome_text_edit,
|
||||
AdminStates.editing_welcome_text
|
||||
)
|
||||
+419
-31
@@ -25,6 +25,40 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
return []
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
|
||||
periods = periods[:6]
|
||||
|
||||
for period in periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
for i in range(0, len(buttons), 2):
|
||||
keyboard_rows.append(buttons[i:i + 2])
|
||||
|
||||
return keyboard_rows
|
||||
|
||||
|
||||
@error_handler
|
||||
|
||||
|
||||
@error_handler
|
||||
async def show_balance_menu(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -149,19 +183,10 @@ async def show_payment_methods(
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
|
||||
payment_text = """
|
||||
💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
Выберите удобный для вас способ оплаты:
|
||||
|
||||
⭐ <b>Telegram Stars</b> - быстро и удобно
|
||||
💳 <b>Банковская карта</b> - через YooKassa/Tribute
|
||||
🛠️ <b>Через поддержку</b> - другие способы
|
||||
|
||||
Выберите способ пополнения:
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
@@ -171,6 +196,22 @@ async def show_payment_methods(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_payment_methods_unavailable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT",
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.",
|
||||
),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -205,10 +246,35 @@ async def start_yookassa_payment(
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
message_text = (
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Введите сумму для пополнения от 100 до 50,000 рублей:",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
@@ -217,6 +283,55 @@ async def start_yookassa_payment(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_yookassa_sbp_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
min_amount_rub = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
message_text = (
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"Выберите сумму пополнения или введите вручную сумму "
|
||||
f"от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
else:
|
||||
message_text = (
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"Введите сумму для пополнения от {min_amount_rub:.0f} до {max_amount_rub:,.0f} рублей:"
|
||||
)
|
||||
|
||||
# Создаем клавиатуру
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
# Если включен быстрый выбор суммы, добавляем кнопки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
quick_amount_buttons = get_quick_amount_buttons(db_user.language)
|
||||
if quick_amount_buttons:
|
||||
# Вставляем кнопки быстрого выбора перед кнопкой "Назад"
|
||||
keyboard.inline_keyboard = quick_amount_buttons + keyboard.inline_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="yookassa_sbp")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_tribute_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -264,7 +379,67 @@ async def start_tribute_payment(
|
||||
await callback.answer("❌ Ошибка создания платежа", show_alert=True)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_successful_topup_with_cart(
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
bot,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
from app.bot import dp
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
storage = dp.storage
|
||||
key = StorageKey(bot_id=bot.id, chat_id=user.telegram_id, user_id=user.telegram_id)
|
||||
|
||||
try:
|
||||
state_data = await storage.get_data(key)
|
||||
current_state = await storage.get_state(key)
|
||||
|
||||
if (current_state == "SubscriptionStates:cart_saved_for_topup" and
|
||||
state_data.get('saved_cart')):
|
||||
|
||||
texts = get_texts(user.language)
|
||||
total_price = state_data.get('total_price', 0)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="🛒 Вернуться к оформлению подписки",
|
||||
callback_data="return_to_saved_cart"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="back_to_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
success_text = (
|
||||
f"✅ Баланс пополнен на {texts.format_price(amount_kopeks)}!\n\n"
|
||||
f"💰 Текущий баланс: {texts.format_price(user.balance_kopeks)}\n\n"
|
||||
f"🛒 У вас есть сохраненная корзина подписки\n"
|
||||
f"Стоимость: {texts.format_price(total_price)}\n\n"
|
||||
f"Хотите продолжить оформление?"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=success_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного пополнения с корзиной: {e}")
|
||||
|
||||
@error_handler
|
||||
async def request_support_topup(
|
||||
@@ -277,7 +452,7 @@ async def request_support_topup(
|
||||
🛠️ <b>Пополнение через поддержку</b>
|
||||
|
||||
Для пополнения баланса обратитесь в техподдержку:
|
||||
{settings.SUPPORT_USERNAME}
|
||||
{settings.get_support_contact_display_html()}
|
||||
|
||||
Укажите:
|
||||
• ID: {db_user.telegram_id}
|
||||
@@ -294,8 +469,8 @@ async def request_support_topup(
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="💬 Написать в поддержку",
|
||||
url=f"https://t.me/{settings.SUPPORT_USERNAME.lstrip('@')}"
|
||||
text="💬 Написать в поддержку",
|
||||
url=settings.get_support_contact_url() or "https://t.me/"
|
||||
)],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
@@ -331,12 +506,27 @@ async def process_topup_amount(
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "stars")
|
||||
|
||||
if payment_method in ["yookassa", "yookassa_sbp"]:
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через YooKassa: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через YooKassa: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
if payment_method == "stars":
|
||||
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
|
||||
elif payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cryptobot":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -360,7 +550,7 @@ async def process_stars_payment_amount(
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
await message.answer("⚠ Оплата Stars временно недоступна")
|
||||
await message.answer("⚠️ Оплата Stars временно недоступна")
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -368,6 +558,7 @@ async def process_stars_payment_amount(
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
|
||||
stars_rate = settings.get_stars_rate()
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
invoice_link = await payment_service.create_stars_invoice(
|
||||
@@ -385,7 +576,7 @@ async def process_stars_payment_amount(
|
||||
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
|
||||
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
|
||||
f"⭐ К оплате: {stars_amount} звезд\n"
|
||||
f"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\n\n"
|
||||
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -395,7 +586,8 @@ async def process_stars_payment_amount(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Stars invoice: {e}")
|
||||
await message.answer("⚠ Ошибка создания платежа")
|
||||
await message.answer("⚠️ Ошибка создания платежа")
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
@@ -412,8 +604,14 @@ async def process_yookassa_payment_amount(
|
||||
await message.answer("❌ Оплата через YooKassa временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < 10000:
|
||||
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты картой: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты картой: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -461,7 +659,7 @@ async def process_yookassa_payment_amount(
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -477,6 +675,91 @@ async def process_yookassa_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_yookassa_sbp_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await message.answer("❌ Оплата через СБП временно недоступна")
|
||||
return
|
||||
|
||||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||||
min_rubles = settings.YOOKASSA_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Минимальная сумма для оплаты через СБП: {min_rubles:.0f} ₽")
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||||
max_rubles = settings.YOOKASSA_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(f"❌ Максимальная сумма для оплаты через СБП: {max_rubles:,.0f} ₽".replace(',', ' '))
|
||||
return
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.get_balance_payment_description(amount_kopeks),
|
||||
receipt_email=None,
|
||||
receipt_phone=None,
|
||||
metadata={
|
||||
"user_telegram_id": str(db_user.telegram_id),
|
||||
"user_username": db_user.username or "",
|
||||
"purpose": "balance_topup_sbp"
|
||||
}
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_url = payment_result.get("confirmation_url")
|
||||
if not confirmation_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты через СБП. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🏦 Оплатить через СБП", url=confirmation_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить через СБП'\n"
|
||||
f"2. Вас перенаправит в приложение вашего банка\n"
|
||||
f"3. Подтвердите платеж через СБП\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
|
||||
f"✅ Принимаем СБП от всех банков-участников\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa СБП для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa СБП платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа через СБП. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def check_yookassa_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -522,7 +805,9 @@ async def check_yookassa_payment_status(
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_failed:
|
||||
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
message_text += (
|
||||
f"\n❌ Платеж не прошел. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
@@ -595,7 +880,6 @@ async def process_cryptobot_payment_amount(
|
||||
return
|
||||
|
||||
try:
|
||||
# Получаем курс из состояния или запрашиваем заново
|
||||
data = await state.get_data()
|
||||
current_rate = data.get('current_rate')
|
||||
|
||||
@@ -603,10 +887,8 @@ async def process_cryptobot_payment_amount(
|
||||
from app.utils.currency_converter import currency_converter
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
|
||||
# Конвертируем рубли в доллары
|
||||
amount_usd = amount_rubles / current_rate
|
||||
|
||||
# Округляем до 2 знаков после запятой
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
@@ -663,7 +945,7 @@ async def process_cryptobot_payment_amount(
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.get_support_contact_display_html()}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -719,7 +1001,9 @@ async def check_cryptobot_payment_status(
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
message_text += (
|
||||
f"\n❌ Платеж истек. Обратитесь в {settings.get_support_contact_display()}"
|
||||
)
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
@@ -729,6 +1013,89 @@ async def check_cryptobot_payment_status(
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_sbp_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
|
||||
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
import json
|
||||
metadata = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
||||
confirmation_token = metadata.get("confirmation_token")
|
||||
|
||||
if not confirmation_token:
|
||||
await callback.answer("❌ Токен подтверждения не найден", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
f"Для оплаты через СБП откройте приложение вашего банка и подтвердите платеж.\\n\\n"
|
||||
f"Если у вас не открылось банковское приложение автоматически, вы можете:\\n"
|
||||
f"1. Скопировать этот токен: <code>{confirmation_token}</code>\\n"
|
||||
f"2. Открыть приложение вашего банка\\n"
|
||||
f"3. Найти функцию оплаты по токену\\n"
|
||||
f"4. Вставить токен и подтвердить платеж",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await callback.answer("Информация об оплате отправлена", show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки embedded платежа СБП: {e}")
|
||||
await callback.answer("❌ Ошибка обработки платежа", show_alert=True)
|
||||
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_quick_amount_selection(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
"""
|
||||
Обработчик выбора суммы через кнопки быстрого выбора
|
||||
"""
|
||||
# Извлекаем сумму из callback_data
|
||||
try:
|
||||
amount_kopeks = int(callback.data.split('_')[-1])
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
# Получаем метод оплаты из состояния
|
||||
data = await state.get_data()
|
||||
payment_method = data.get("payment_method", "yookassa")
|
||||
|
||||
# Проверяем, какой метод оплаты был выбран и вызываем соответствующий обработчик
|
||||
if payment_method == "yookassa":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "yookassa_sbp":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_yookassa_sbp_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
|
||||
return
|
||||
|
||||
except ValueError:
|
||||
await callback.answer("❌ Ошибка обработки суммы", show_alert=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки быстрого выбора суммы: {e}")
|
||||
await callback.answer("❌ Ошибка обработки запроса", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
@@ -761,6 +1128,16 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "topup_yookassa"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_yookassa_sbp_payment,
|
||||
F.data == "topup_yookassa_sbp"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_yookassa_payment_status,
|
||||
F.data.startswith("check_yookassa_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_tribute_payment,
|
||||
F.data == "topup_tribute"
|
||||
@@ -790,3 +1167,14 @@ def register_handlers(dp: Dispatcher):
|
||||
check_cryptobot_payment_status,
|
||||
F.data.startswith("check_cryptobot_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_payment_methods_unavailable,
|
||||
F.data == "payment_methods_unavailable"
|
||||
)
|
||||
|
||||
# Регистрируем обработчик для кнопок быстрого выбора суммы
|
||||
dp.callback_query.register(
|
||||
handle_quick_amount_selection,
|
||||
F.data.startswith("quick_amount_")
|
||||
)
|
||||
|
||||
+13
-7
@@ -4,7 +4,7 @@ from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,8 +18,11 @@ async def handle_unknown_callback(
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await callback.answer(
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
show_alert=True
|
||||
texts.t(
|
||||
"UNKNOWN_CALLBACK_ALERT",
|
||||
"❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
@@ -49,8 +52,11 @@ async def handle_unknown_message(
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
|
||||
await message.answer(
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru")
|
||||
texts.t(
|
||||
"UNKNOWN_COMMAND_MESSAGE",
|
||||
"❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language if db_user else "ru"),
|
||||
)
|
||||
|
||||
|
||||
@@ -61,8 +67,8 @@ async def show_rules(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
rules_text = texts.RULES_TEXT
|
||||
|
||||
rules_text = await get_rules(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
rules_text,
|
||||
|
||||
+98
-57
@@ -7,33 +7,40 @@ from datetime import datetime, timedelta
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_telegram_id, update_user
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.localization.texts import get_texts, get_rules
|
||||
from app.database.models import User
|
||||
from app.utils.user_utils import mark_user_as_had_paid_subscription
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def show_main_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
|
||||
has_active_subscription = bool(db_user.subscription)
|
||||
subscription_is_active = False
|
||||
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -42,12 +49,15 @@ async def show_main_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(
|
||||
db: AsyncSession,
|
||||
user: User
|
||||
@@ -60,34 +70,22 @@ async def mark_user_as_had_paid_subscription(
|
||||
|
||||
|
||||
async def show_service_rules(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = await get_current_rules_content(db, db_user.language)
|
||||
|
||||
|
||||
if not rules_text:
|
||||
texts = get_texts(db_user.language)
|
||||
rules_text = texts._get_default_rules(db_user.language) if hasattr(texts, '_get_default_rules') else """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
rules_text = await get_rules(db_user.language)
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📋 <b>Правила сервиса</b>\n\n{rules_text}",
|
||||
f"{texts.t('RULES_HEADER', '📋 <b>Правила сервиса</b>')}\n\n{rules_text}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -100,17 +98,20 @@ async def handle_back_to_menu(
|
||||
db: AsyncSession
|
||||
):
|
||||
await state.clear()
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
has_active_subscription = db_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
@@ -119,62 +120,102 @@ async def handle_back_to_menu(
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
if not user.subscription:
|
||||
return "❌ Отсутствует"
|
||||
return texts.t("SUB_STATUS_NONE", "❌ Отсутствует")
|
||||
|
||||
subscription = user.subscription
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
|
||||
return texts.t(
|
||||
"SUB_STATUS_EXPIRED",
|
||||
"🔴 Истекла\n📅 {end_date}",
|
||||
).format(end_date=subscription.end_date.strftime('%d.%m.%Y'))
|
||||
|
||||
days_left = (subscription.end_date - current_time).days
|
||||
|
||||
if subscription.is_trial:
|
||||
if days_left > 1:
|
||||
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_ACTIVE",
|
||||
"🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left == 1:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает завтра!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TOMORROW",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return f"🎁 Тестовая подписка\n⚠️ истекает сегодня!"
|
||||
|
||||
return texts.t(
|
||||
"SUB_STATUS_TRIAL_TODAY",
|
||||
"🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
else:
|
||||
if days_left > 7:
|
||||
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_LONG",
|
||||
"💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
).format(
|
||||
end_date=subscription.end_date.strftime('%d.%m.%Y'),
|
||||
days=days_left,
|
||||
)
|
||||
elif days_left > 1:
|
||||
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS",
|
||||
"💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
).format(days=days_left)
|
||||
elif days_left == 1:
|
||||
return f"💎 Активна\n⚠️ истекает завтра!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TOMORROW",
|
||||
"💎 Активна\n⚠️ истекает завтра!",
|
||||
)
|
||||
else:
|
||||
return f"💎 Активна\n⚠️ истекает сегодня!"
|
||||
return texts.t(
|
||||
"SUB_STATUS_ACTIVE_TODAY",
|
||||
"💎 Активна\n⚠️ истекает сегодня!",
|
||||
)
|
||||
|
||||
|
||||
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
|
||||
if not random_message:
|
||||
return base_text
|
||||
|
||||
prompt = action_prompt or ""
|
||||
if prompt and prompt in base_text:
|
||||
parts = base_text.split(prompt, 1)
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}"
|
||||
return base_text.replace(prompt, f"\n{random_message}\n\n{prompt}", 1)
|
||||
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
|
||||
|
||||
async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
if "Выберите действие:" in base_text:
|
||||
parts = base_text.split("Выберите действие:")
|
||||
if len(parts) == 2:
|
||||
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
|
||||
|
||||
if "Выберите действие:" in base_text:
|
||||
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
|
||||
else:
|
||||
return f"{base_text}\n\n{random_message}"
|
||||
return _insert_random_message(base_text, random_message, action_prompt)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
@@ -44,7 +44,10 @@ async def process_promocode(
|
||||
|
||||
if not code:
|
||||
await message.answer(
|
||||
"❌ Введите корректный промокод",
|
||||
texts.t(
|
||||
"PROMOCODE_EMPTY_INPUT",
|
||||
"❌ Введите корректный промокод",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
+280
-116
@@ -11,6 +11,7 @@ from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_referral_analytics,
|
||||
@@ -32,78 +33,155 @@ async def show_referral_info(
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
|
||||
|
||||
referral_text += f"📊 <b>Ваша статистика:</b>\n"
|
||||
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
|
||||
referral_text += f"• Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
|
||||
referral_text += f"• Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
|
||||
referral_text += f"• Конверсия: <b>{summary['conversion_rate']}%</b>\n"
|
||||
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
|
||||
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
|
||||
|
||||
referral_text += f"🎁 <b>Как работают награды:</b>\n"
|
||||
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
|
||||
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
|
||||
|
||||
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
|
||||
referral_text += f"<code>{referral_link}</code>\n\n"
|
||||
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
|
||||
|
||||
referral_text = (
|
||||
texts.t("REFERRAL_PROGRAM_TITLE", "👥 <b>Реферальная программа</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_STATS_HEADER", "📊 <b>Ваша статистика:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_INVITED",
|
||||
"• Приглашено пользователей: <b>{count}</b>",
|
||||
).format(count=summary['invited_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_FIRST_TOPUPS",
|
||||
"• Сделали первое пополнение: <b>{count}</b>",
|
||||
).format(count=summary['paid_referrals_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_ACTIVE",
|
||||
"• Активных рефералов: <b>{count}</b>",
|
||||
).format(count=summary['active_referrals_count'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_CONVERSION",
|
||||
"• Конверсия: <b>{rate}%</b>",
|
||||
).format(rate=summary['conversion_rate'])
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_TOTAL_EARNED",
|
||||
"• Заработано всего: <b>{amount}</b>",
|
||||
).format(amount=texts.format_price(summary['total_earned_kopeks']))
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_STATS_MONTH_EARNED",
|
||||
"• За последний месяц: <b>{amount}</b>",
|
||||
).format(amount=texts.format_price(summary['month_earned_kopeks']))
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_REWARDS_HEADER", "🎁 <b>Как работают награды:</b>")
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_NEW_USER",
|
||||
"• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
).format(
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
)
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_INVITER",
|
||||
"• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
).format(bonus=texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS))
|
||||
+ "\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_REWARD_COMMISSION",
|
||||
"• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
).format(percent=settings.REFERRAL_COMMISSION_PERCENT)
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_LINK_TITLE", "🔗 <b>Ваша реферальная ссылка:</b>")
|
||||
+ f"\n<code>{referral_link}</code>\n\n"
|
||||
+ texts.t("REFERRAL_CODE_TITLE", "🆔 <b>Ваш код:</b> <code>{code}</code>").format(code=db_user.referral_code)
|
||||
+ "\n\n"
|
||||
)
|
||||
|
||||
if summary['recent_earnings']:
|
||||
meaningful_earnings = [
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
earning for earning in summary['recent_earnings'][:5]
|
||||
if earning['amount_kopeks'] > 0
|
||||
]
|
||||
|
||||
|
||||
if meaningful_earnings:
|
||||
referral_text += f"💰 <b>Последние начисления:</b>\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER",
|
||||
"💰 <b>Последние начисления:</b>",
|
||||
) + "\n"
|
||||
for earning in meaningful_earnings[:3]:
|
||||
reason_text = {
|
||||
"referral_first_topup": "🎉 Первое пополнение",
|
||||
"referral_commission_topup": "💰 Комиссия с пополнения",
|
||||
"referral_commission": "💰 Комиссия с покупки"
|
||||
"referral_first_topup": texts.t(
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP",
|
||||
"🎉 Первое пополнение",
|
||||
),
|
||||
"referral_commission_topup": texts.t(
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP",
|
||||
"💰 Комиссия с пополнения",
|
||||
),
|
||||
"referral_commission": texts.t(
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE",
|
||||
"💰 Комиссия с покупки",
|
||||
),
|
||||
}.get(earning['reason'], earning['reason'])
|
||||
|
||||
referral_text += f"• {reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM",
|
||||
"• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
).format(
|
||||
reason=reason_text,
|
||||
amount=texts.format_price(earning['amount_kopeks']),
|
||||
referral_name=earning['referral_name'],
|
||||
) + "\n"
|
||||
referral_text += "\n"
|
||||
|
||||
|
||||
if summary['earnings_by_type']:
|
||||
referral_text += f"📈 <b>Доходы по типам:</b>\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER",
|
||||
"📈 <b>Доходы по типам:</b>",
|
||||
) + "\n"
|
||||
|
||||
if 'referral_first_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_first_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS",
|
||||
"• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
if 'referral_commission_topup' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission_topup']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_TOPUPS",
|
||||
"• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
if 'referral_commission' in summary['earnings_by_type']:
|
||||
data = summary['earnings_by_type']['referral_commission']
|
||||
if data['total_amount_kopeks'] > 0:
|
||||
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_EARNINGS_PURCHASES",
|
||||
"• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
).format(
|
||||
count=data['count'],
|
||||
amount=texts.format_price(data['total_amount_kopeks']),
|
||||
) + "\n"
|
||||
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
if callback.message.text:
|
||||
await callback.message.edit_text(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
referral_text += texts.t(
|
||||
"REFERRAL_INVITE_FOOTER",
|
||||
"📢 Приглашайте друзей и зарабатывайте!",
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
referral_text,
|
||||
get_referral_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -111,6 +189,8 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
@@ -133,7 +213,10 @@ async def show_referral_qr(
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
caption=texts.t(
|
||||
"REFERRAL_LINK_CAPTION",
|
||||
"🔗 Ваша реферальная ссылка:\n{link}",
|
||||
).format(link=referral_link),
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -141,10 +224,12 @@ async def show_referral_qr(
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
caption=texts.t(
|
||||
"REFERRAL_LINK_CAPTION",
|
||||
"🔗 Ваша реферальная ссылка:\n{link}",
|
||||
).format(link=referral_link),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_detailed_referral_list(
|
||||
@@ -154,35 +239,64 @@ async def show_detailed_referral_list(
|
||||
page: int = 1
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
referrals_data = await get_detailed_referral_list(db, db_user.id, limit=10, offset=(page - 1) * 10)
|
||||
|
||||
|
||||
if not referrals_data['referrals']:
|
||||
await callback.message.edit_text(
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
])
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
texts.t(
|
||||
"REFERRAL_LIST_EMPTY",
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
),
|
||||
types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
),
|
||||
parse_mode=None,
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
|
||||
|
||||
text = texts.t(
|
||||
"REFERRAL_LIST_HEADER",
|
||||
"👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
).format(
|
||||
current=referrals_data['current_page'],
|
||||
total=referrals_data['total_pages'],
|
||||
) + "\n\n"
|
||||
|
||||
for i, referral in enumerate(referrals_data['referrals'], 1):
|
||||
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
|
||||
|
||||
topup_emoji = "💰" if referral['has_made_first_topup'] else "⏳"
|
||||
|
||||
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
|
||||
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
|
||||
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
|
||||
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_HEADER",
|
||||
"{index}. {status} <b>{name}</b>",
|
||||
).format(index=i, status=status_emoji, name=referral['full_name']) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_TOPUPS",
|
||||
" {emoji} Пополнений: {count}",
|
||||
).format(emoji=topup_emoji, count=referral['topups_count']) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_EARNED",
|
||||
" 💎 Заработано с него: {amount}",
|
||||
).format(amount=texts.format_price(referral['total_earned_kopeks'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_REGISTERED",
|
||||
" 📅 Регистрация: {days} дн. назад",
|
||||
).format(days=referral['days_since_registration']) + "\n"
|
||||
|
||||
if referral['days_since_activity'] is not None:
|
||||
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY",
|
||||
" 🕐 Активность: {days} дн. назад",
|
||||
).format(days=referral['days_since_activity']) + "\n"
|
||||
else:
|
||||
text += f" 🕐 Активность: давно\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO",
|
||||
" 🕐 Активность: давно",
|
||||
) + "\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
@@ -191,13 +305,13 @@ async def show_detailed_referral_list(
|
||||
|
||||
if referrals_data['has_prev']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
text=texts.t("REFERRAL_LIST_PREV_PAGE", "⬅️ Назад"),
|
||||
callback_data=f"referral_list_page_{page - 1}"
|
||||
))
|
||||
|
||||
|
||||
if referrals_data['has_next']:
|
||||
nav_buttons.append(types.InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
text=texts.t("REFERRAL_LIST_NEXT_PAGE", "Вперед ➡️"),
|
||||
callback_data=f"referral_list_page_{page + 1}"
|
||||
))
|
||||
|
||||
@@ -205,14 +319,14 @@ async def show_detailed_referral_list(
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -223,31 +337,60 @@ async def show_referral_analytics(
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
analytics = await get_referral_analytics(db, db_user.id)
|
||||
|
||||
text = f"📊 <b>Аналитика рефералов</b>\n\n"
|
||||
|
||||
text += f"💰 <b>Доходы по периодам:</b>\n"
|
||||
text += f"• Сегодня: {texts.format_price(analytics['earnings_by_period']['today'])}\n"
|
||||
text += f"• За неделю: {texts.format_price(analytics['earnings_by_period']['week'])}\n"
|
||||
text += f"• За месяц: {texts.format_price(analytics['earnings_by_period']['month'])}\n"
|
||||
text += f"• За квартал: {texts.format_price(analytics['earnings_by_period']['quarter'])}\n\n"
|
||||
|
||||
|
||||
text = texts.t("REFERRAL_ANALYTICS_TITLE", "📊 <b>Аналитика рефералов</b>") + "\n\n"
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER",
|
||||
"💰 <b>Доходы по периодам:</b>",
|
||||
) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY",
|
||||
"• Сегодня: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['today'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK",
|
||||
"• За неделю: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['week'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH",
|
||||
"• За месяц: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['month'])) + "\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER",
|
||||
"• За квартал: {amount}",
|
||||
).format(amount=texts.format_price(analytics['earnings_by_period']['quarter'])) + "\n\n"
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE",
|
||||
"🏆 <b>Топ-{count} рефералов:</b>",
|
||||
).format(count=len(analytics['top_referrals'])) + "\n"
|
||||
for i, ref in enumerate(analytics['top_referrals'], 1):
|
||||
text += f"{i}. {ref['referral_name']}: {texts.format_price(ref['total_earned_kopeks'])} ({ref['earnings_count']} начислений)\n"
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM",
|
||||
"{index}. {name}: {amount} ({count} начислений)",
|
||||
).format(
|
||||
index=i,
|
||||
name=ref['referral_name'],
|
||||
amount=texts.format_price(ref['total_earned_kopeks']),
|
||||
count=ref['earnings_count'],
|
||||
) + "\n"
|
||||
text += "\n"
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
text += texts.t(
|
||||
"REFERRAL_ANALYTICS_FOOTER",
|
||||
"📈 Продолжайте развивать свою реферальную сеть!",
|
||||
)
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -257,34 +400,55 @@ async def create_invite_message(
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
invite_text = f"🎉 Присоединяйся к VPN сервису!\n\n"
|
||||
invite_text += f"💎 При первом пополнении от {texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} ты получишь {texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)} бонусом на баланс!\n\n"
|
||||
invite_text += f"🚀 Быстрое подключение\n"
|
||||
invite_text += f"🌍 Серверы по всему миру\n"
|
||||
invite_text += f"🔒 Надежная защита\n\n"
|
||||
invite_text += f"👇 Переходи по ссылке:\n{referral_link}"
|
||||
|
||||
|
||||
invite_text = (
|
||||
texts.t("REFERRAL_INVITE_TITLE", "🎉 Присоединяйся к VPN сервису!")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_INVITE_BONUS",
|
||||
"💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
).format(
|
||||
minimum=texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS),
|
||||
bonus=texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS),
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_FAST", "🚀 Быстрое подключение")
|
||||
+ "\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_SERVERS", "🌍 Серверы по всему миру")
|
||||
+ "\n"
|
||||
+ texts.t("REFERRAL_INVITE_FEATURE_SECURE", "🔒 Надежная защита")
|
||||
+ "\n\n"
|
||||
+ texts.t("REFERRAL_INVITE_LINK_PROMPT", "👇 Переходи по ссылке:")
|
||||
+ f"\n{referral_link}"
|
||||
)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
text=texts.t("REFERRAL_SHARE_BUTTON", "📤 Поделиться"),
|
||||
switch_inline_query=invite_text
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_referrals"
|
||||
)]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
(
|
||||
texts.t("REFERRAL_INVITE_CREATED_TITLE", "📝 <b>Приглашение создано!</b>")
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION",
|
||||
"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
)
|
||||
+ "\n\n"
|
||||
f"<code>{invite_text}</code>"
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -6,22 +6,29 @@ from app.database.models import User
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
|
||||
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
|
||||
logger.warning(f"Невалидный payload: {query.invoice_payload}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Ошибка валидации платежа. Попробуйте еще раз."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD",
|
||||
"Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
async for db in get_db():
|
||||
@@ -30,26 +37,36 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Пользователь не найден. Обратитесь в поддержку."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_USER_NOT_FOUND",
|
||||
"Пользователь не найден. Обратитесь в поддержку.",
|
||||
),
|
||||
)
|
||||
return
|
||||
break
|
||||
texts = get_texts(user.language or DEFAULT_LANGUAGE)
|
||||
break
|
||||
except Exception as db_error:
|
||||
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR",
|
||||
"Техническая ошибка. Попробуйте позже.",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
await query.answer(ok=True)
|
||||
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
|
||||
await query.answer(
|
||||
ok=False,
|
||||
error_message="Техническая ошибка. Попробуйте позже."
|
||||
error_message=texts.t(
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR",
|
||||
"Техническая ошибка. Попробуйте позже.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -58,25 +75,32 @@ async def handle_successful_payment(
|
||||
db: AsyncSession,
|
||||
**kwargs
|
||||
):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
try:
|
||||
payment = message.successful_payment
|
||||
user_id = message.from_user.id
|
||||
|
||||
|
||||
logger.info(
|
||||
f"💳 Успешный Stars платеж от {user_id}: "
|
||||
f"{payment.total_amount} XTR, "
|
||||
f"payload: {payment.invoice_payload}, "
|
||||
f"charge_id: {payment.telegram_payment_charge_id}"
|
||||
)
|
||||
|
||||
|
||||
user = await get_user_by_telegram_id(db, user_id)
|
||||
texts = get_texts(user.language if user and user.language else DEFAULT_LANGUAGE)
|
||||
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
|
||||
await message.answer(
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_USER_NOT_FOUND",
|
||||
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
success = await payment_service.process_stars_payment(
|
||||
db=db,
|
||||
@@ -88,16 +112,28 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
transaction_id_short = payment.telegram_payment_charge_id[:8]
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
texts.t(
|
||||
"STARS_PAYMENT_SUCCESS",
|
||||
"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
"⭐ Потрачено звезд: {stars_spent}\n"
|
||||
"💰 Зачислено на баланс: {amount} ₽\n"
|
||||
"🆔 ID транзакции: {transaction_id}...\n\n"
|
||||
"Спасибо за пополнение! 🚀",
|
||||
).format(
|
||||
stars_spent=payment.total_amount,
|
||||
amount=int(rubles_amount),
|
||||
transaction_id=transaction_id_short,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
@@ -105,15 +141,21 @@ async def handle_successful_payment(
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR",
|
||||
"❌ Произошла ошибка при зачислении средств. "
|
||||
"Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
|
||||
await message.answer(
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы."
|
||||
texts.t(
|
||||
"STARS_PAYMENT_PROCESSING_ERROR",
|
||||
"❌ Техническая ошибка при обработке платежа. "
|
||||
"Обратитесь в поддержку для решения проблемы.",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
+628
-178
File diff suppressed because it is too large
Load Diff
+1581
-545
File diff suppressed because it is too large
Load Diff
+527
-32
@@ -7,33 +7,95 @@ from app.localization.texts import get_texts
|
||||
def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
|
||||
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
|
||||
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
|
||||
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_USERS, callback_data="admin_users"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REFERRALS, callback_data="admin_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave")
|
||||
InlineKeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS, callback_data="admin_subscriptions")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
|
||||
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"),
|
||||
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
|
||||
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_system_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Обновления", callback_data="admin_updates"),
|
||||
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -49,7 +111,21 @@ def get_admin_users_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="🗑️ Неактивные", callback_data="admin_users_inactive")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⚙️ Фильтры", callback_data="admin_users_filters")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_users_filters_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 По балансу", callback_data="admin_users_balance_filter")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -68,7 +144,7 @@ def get_admin_subscriptions_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_subs_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -83,7 +159,143 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
|
||||
InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_campaign_management_keyboard(
|
||||
campaign_id: int, is_active: bool, language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
status_text = "🔴 Выключить" if is_active else "🟢 Включить"
|
||||
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data=f"admin_campaign_stats_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=status_text,
|
||||
callback_data=f"admin_campaign_toggle_{campaign_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"admin_campaign_edit_{campaign_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"admin_campaign_delete_{campaign_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку", callback_data="admin_campaigns_list"
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_campaign_edit_keyboard(
|
||||
campaign_id: int,
|
||||
*,
|
||||
is_balance_bonus: bool,
|
||||
language: str = "ru",
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Название",
|
||||
callback_data=f"admin_campaign_edit_name_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Параметр",
|
||||
callback_data=f"admin_campaign_edit_start_{campaign_id}",
|
||||
),
|
||||
]
|
||||
]
|
||||
|
||||
if is_balance_bonus:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Бонус на баланс",
|
||||
callback_data=f"admin_campaign_edit_balance_{campaign_id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📅 Длительность",
|
||||
callback_data=f"admin_campaign_edit_sub_days_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Трафик",
|
||||
callback_data=f"admin_campaign_edit_sub_traffic_{campaign_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Устройства",
|
||||
callback_data=f"admin_campaign_edit_sub_devices_{campaign_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌍 Серверы",
|
||||
callback_data=f"admin_campaign_edit_sub_servers_{campaign_id}",
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK, callback_data=f"admin_campaign_manage_{campaign_id}"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -115,7 +327,7 @@ def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="📋 История", callback_data="admin_msg_history")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -134,7 +346,7 @@ def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_mon_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -147,13 +359,13 @@ def get_admin_remnawave_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Синхронизация", callback_data="admin_rw_sync"),
|
||||
InlineKeyboardButton(text="🌍 Управление сквадами", callback_data="admin_rw_squads")
|
||||
InlineKeyboardButton(text="🌐 Управление сквадами", callback_data="admin_rw_squads")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📈 Трафик", callback_data="admin_rw_traffic")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -166,25 +378,24 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Доходы", callback_data="admin_stats_revenue"),
|
||||
InlineKeyboardButton(text="🤝 Рефералы", callback_data="admin_stats_referrals")
|
||||
InlineKeyboardButton(text="🤝 Партнерка", callback_data="admin_stats_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Общая сводка", callback_data="admin_stats_summary")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_promo")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup:
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
|
||||
InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
[
|
||||
@@ -208,7 +419,7 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_users_list")
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
@@ -315,8 +526,8 @@ def get_custom_criteria_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="⚡ Активные сегодня", callback_data="criteria_active_today")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="👤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
InlineKeyboardButton(text="💤 Неактивные 7+ дней", callback_data="criteria_inactive_week"),
|
||||
InlineKeyboardButton(text="💤 Неактивные 30+ дней", callback_data="criteria_inactive_month")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🤝 Через рефералов", callback_data="criteria_referrals"),
|
||||
@@ -481,7 +692,7 @@ def get_monitoring_keyboard() -> InlineKeyboardMarkup:
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="admin_mon_force_check"),
|
||||
InlineKeyboardButton(text="📝 Логи", callback_data="admin_mon_logs")
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications"),
|
||||
@@ -503,6 +714,170 @@ def get_monitoring_logs_keyboard() -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_logs_navigation_keyboard(
|
||||
current_page: int,
|
||||
total_pages: int,
|
||||
has_logs: bool = True
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page - 1}"
|
||||
))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page_info"
|
||||
))
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_mon_logs_page_{current_page + 1}"
|
||||
))
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
management_row = []
|
||||
|
||||
if has_logs:
|
||||
management_row.extend([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
|
||||
])
|
||||
else:
|
||||
management_row.append(
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs")
|
||||
)
|
||||
|
||||
keyboard.append(management_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад к мониторингу", callback_data="admin_monitoring")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_log_detail_keyboard(log_id: int, current_page: int = 1) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить этот лог",
|
||||
callback_data=f"admin_mon_delete_log_{log_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К списку логов",
|
||||
callback_data=f"admin_mon_logs_page_{current_page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_monitoring_clear_confirm_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, очистить", callback_data="admin_mon_clear_logs_confirm"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_mon_logs")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🗑️ Очистить ВСЕ логи", callback_data="admin_mon_clear_all_logs")
|
||||
]
|
||||
])
|
||||
|
||||
def get_monitoring_status_keyboard(
|
||||
is_running: bool,
|
||||
last_check_ago_minutes: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
control_row = []
|
||||
if is_running:
|
||||
control_row.extend([
|
||||
InlineKeyboardButton(text="⏹️ Остановить", callback_data="admin_mon_stop"),
|
||||
InlineKeyboardButton(text="🔄 Перезапустить", callback_data="admin_mon_restart")
|
||||
])
|
||||
else:
|
||||
control_row.append(
|
||||
InlineKeyboardButton(text="▶️ Запустить", callback_data="admin_mon_start")
|
||||
)
|
||||
|
||||
keyboard.append(control_row)
|
||||
|
||||
monitoring_row = []
|
||||
|
||||
if not is_running or last_check_ago_minutes > 10:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⚡ Срочная проверка",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
else:
|
||||
monitoring_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить сейчас",
|
||||
callback_data="admin_mon_force_check"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(monitoring_row)
|
||||
|
||||
info_row = [
|
||||
InlineKeyboardButton(text="📋 Логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_mon_statistics")
|
||||
]
|
||||
keyboard.append(info_row)
|
||||
|
||||
test_row = [
|
||||
InlineKeyboardButton(text="🧪 Тест уведомлений", callback_data="admin_mon_test_notifications")
|
||||
]
|
||||
keyboard.append(test_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_monitoring_settings_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="⏱️ Интервал проверки", callback_data="admin_mon_set_interval"),
|
||||
InlineKeyboardButton(text="🔔 Уведомления", callback_data="admin_mon_toggle_notifications")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💳 Настройки автооплаты", callback_data="admin_mon_autopay_settings"),
|
||||
InlineKeyboardButton(text="🧹 Автоочистка логов", callback_data="admin_mon_auto_cleanup")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ К мониторингу", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_log_type_filter_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Успешные", callback_data="admin_mon_logs_filter_success"),
|
||||
InlineKeyboardButton(text="❌ Ошибки", callback_data="admin_mon_logs_filter_error")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Циклы мониторинга", callback_data="admin_mon_logs_filter_cycle"),
|
||||
InlineKeyboardButton(text="💳 Автооплаты", callback_data="admin_mon_logs_filter_autopay")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📋 Все логи", callback_data="admin_mon_logs"),
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")
|
||||
]
|
||||
])
|
||||
|
||||
def get_admin_servers_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -646,8 +1021,8 @@ def get_maintenance_keyboard(
|
||||
callback_data="maintenance_panel"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_submenu_settings"
|
||||
)
|
||||
])
|
||||
|
||||
@@ -660,3 +1035,123 @@ def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_welcome_text_keyboard(language: str = "ru", is_enabled: bool = True) -> InlineKeyboardMarkup:
|
||||
|
||||
toggle_text = "🔴 Отключить" if is_enabled else "🟢 Включить"
|
||||
toggle_callback = "toggle_welcome_text"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=toggle_callback)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
|
||||
InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👁️ Предпросмотр", callback_data="preview_welcome_text"),
|
||||
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"),
|
||||
InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_communications")
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
DEFAULT_BROADCAST_BUTTONS = ("home",)
|
||||
|
||||
BROADCAST_BUTTONS = {
|
||||
"balance": {"text": "💰 Пополнить баланс", "callback": "balance_topup"},
|
||||
"referrals": {"text": "🤝 Партнерка", "callback": "menu_referrals"},
|
||||
"promocode": {"text": "🎫 Промокод", "callback": "menu_promocode"},
|
||||
"connect": {"text": "🔗 Подключиться", "callback": "subscription_connect"},
|
||||
"subscription": {"text": "📱 Подписка", "callback": "menu_subscription"},
|
||||
"support": {"text": "🛠️ Техподдержка", "callback": "menu_support"},
|
||||
"home": {"text": "🏠 На главную", "callback": "back_to_menu"},
|
||||
}
|
||||
|
||||
BROADCAST_BUTTON_ROWS: tuple[tuple[str, ...], ...] = (
|
||||
("balance", "referrals"),
|
||||
("promocode", "connect"),
|
||||
("subscription", "support"),
|
||||
("home",),
|
||||
)
|
||||
|
||||
BROADCAST_BUTTON_LABELS = {key: value["text"] for key, value in BROADCAST_BUTTONS.items()}
|
||||
|
||||
|
||||
def get_message_buttons_selector_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return get_updated_message_buttons_selector_keyboard_with_media(list(DEFAULT_BROADCAST_BUTTONS), False, language)
|
||||
|
||||
def get_broadcast_media_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📷 Добавить фото", callback_data="add_media_photo"),
|
||||
InlineKeyboardButton(text="🎥 Добавить видео", callback_data="add_media_video")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📄 Добавить документ", callback_data="add_media_document"),
|
||||
InlineKeyboardButton(text="⏭️ Пропустить медиа", callback_data="skip_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_media_confirm_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Использовать это медиа", callback_data="confirm_media"),
|
||||
InlineKeyboardButton(text="🔄 Заменить медиа", callback_data="replace_media")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⏭️ Без медиа", callback_data="skip_media"),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
def get_updated_message_buttons_selector_keyboard_with_media(selected_buttons: list, has_media: bool = False, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
selected_buttons = selected_buttons or []
|
||||
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
for row in BROADCAST_BUTTON_ROWS:
|
||||
row_buttons: list[InlineKeyboardButton] = []
|
||||
for button_key in row:
|
||||
button_config = BROADCAST_BUTTONS[button_key]
|
||||
base_text = button_config["text"]
|
||||
if button_key in selected_buttons:
|
||||
if " " in base_text:
|
||||
toggle_text = f"✅ {base_text.split(' ', 1)[1]}"
|
||||
else:
|
||||
toggle_text = f"✅ {base_text}"
|
||||
else:
|
||||
toggle_text = base_text
|
||||
row_buttons.append(
|
||||
InlineKeyboardButton(text=toggle_text, callback_data=f"btn_{button_key}")
|
||||
)
|
||||
if row_buttons:
|
||||
keyboard.append(row_buttons)
|
||||
|
||||
if has_media:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🖼️ Изменить медиа", callback_data="change_media")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Продолжить", callback_data="buttons_confirm")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")
|
||||
]
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
+494
-166
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -33,19 +33,19 @@ def get_admin_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="👥 Пользователи"),
|
||||
KeyboardButton(text="📱 Подписки")
|
||||
KeyboardButton(text=texts.ADMIN_USERS),
|
||||
KeyboardButton(text=texts.ADMIN_SUBSCRIPTIONS)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🎫 Промокоды"),
|
||||
KeyboardButton(text="📨 Рассылки")
|
||||
KeyboardButton(text=texts.ADMIN_PROMOCODES),
|
||||
KeyboardButton(text=texts.ADMIN_MESSAGES)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📊 Статистика"),
|
||||
KeyboardButton(text="🔧 Мониторинг")
|
||||
KeyboardButton(text=texts.ADMIN_STATISTICS),
|
||||
KeyboardButton(text=texts.ADMIN_MONITORING)
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="🏠 Главное меню")
|
||||
KeyboardButton(text=texts.t("ADMIN_MAIN_MENU", "🏠 Главное меню"))
|
||||
]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
@@ -81,9 +81,10 @@ def get_confirmation_reply_keyboard(language: str = "ru") -> ReplyKeyboardMarkup
|
||||
|
||||
|
||||
def get_skip_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="⏭️ Пропустить")]
|
||||
[KeyboardButton(text=texts.REFERRAL_CODE_SKIP)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
@@ -95,10 +96,11 @@ def remove_keyboard() -> ReplyKeyboardRemove:
|
||||
|
||||
|
||||
def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📱 Отправить контакт", request_contact=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
[KeyboardButton(text=texts.t("SEND_CONTACT_BUTTON", "📱 Отправить контакт"), request_contact=True)],
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
@@ -106,11 +108,12 @@ def get_contact_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
|
||||
|
||||
def get_location_keyboard(language: str = "ru") -> ReplyKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="📍 Отправить геолокацию", request_location=True)],
|
||||
[KeyboardButton(text="❌ Отмена")]
|
||||
[KeyboardButton(text=texts.t("SEND_LOCATION_BUTTON", "📍 Отправить геолокацию"), request_location=True)],
|
||||
[KeyboardButton(text=texts.CANCEL)]
|
||||
],
|
||||
resize_keyboard=True,
|
||||
one_time_keyboard=True
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Sample localization for English language.
|
||||
# Keys correspond to constants defined in app/localization/texts.py.
|
||||
WELCOME: |
|
||||
Welcome to Remnawave Bedolaga Bot!
|
||||
Update this text to greet users in your own style.
|
||||
|
||||
MENU:
|
||||
BALANCE: "Balance"
|
||||
SUBSCRIPTION: "Subscription"
|
||||
|
||||
RULES_TEXT: |
|
||||
Remnawave service rules:
|
||||
1. Follow the law of your jurisdiction.
|
||||
2. Do not distribute spam or malicious content.
|
||||
3. Respect other community members.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Пример локализации на русском языке.
|
||||
# Ключи соответствуют именам констант из app/localization/texts.py.
|
||||
WELCOME: |
|
||||
Добро пожаловать в Remnawave Bedolaga Bot!
|
||||
Эти строки заменят стандартное приветствие бота.
|
||||
|
||||
MENU:
|
||||
BALANCE: "Баланс"
|
||||
SUBSCRIPTION: "Подписка"
|
||||
|
||||
RULES_TEXT: |
|
||||
Правила сервиса Remnawave:
|
||||
1. Следуйте законам своей страны.
|
||||
2. Не распространяйте спам и вредоносный контент.
|
||||
3. Уважайте других пользователей.
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_FALLBACK_LANGUAGE = "ru"
|
||||
|
||||
_BASE_DIR = Path(__file__).resolve().parent
|
||||
_DEFAULT_LOCALES_DIR = _BASE_DIR / "locales"
|
||||
|
||||
|
||||
def _normalize_language_code(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _resolve_user_locales_dir() -> Path:
|
||||
path = Path(settings.LOCALES_PATH).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = Path.cwd() / path
|
||||
return path
|
||||
|
||||
|
||||
def _locale_file_exists(language: str) -> bool:
|
||||
code = _normalize_language_code(language)
|
||||
if not code:
|
||||
return False
|
||||
|
||||
default_candidate = _DEFAULT_LOCALES_DIR / f"{code}.json"
|
||||
if default_candidate.exists():
|
||||
return True
|
||||
|
||||
user_dir = _resolve_user_locales_dir()
|
||||
for extension in (".json", ".yml", ".yaml"):
|
||||
if (user_dir / f"{code}{extension}").exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_fallback_language(available_map: Dict[str, str]) -> str:
|
||||
candidates = []
|
||||
if _FALLBACK_LANGUAGE:
|
||||
candidates.append(_FALLBACK_LANGUAGE)
|
||||
candidates.extend(available_map.values())
|
||||
|
||||
seen = set()
|
||||
for candidate in candidates:
|
||||
normalized = _normalize_language_code(candidate)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
|
||||
if normalized in available_map:
|
||||
return available_map[normalized]
|
||||
|
||||
if _locale_file_exists(normalized):
|
||||
return normalized
|
||||
|
||||
if _FALLBACK_LANGUAGE and _locale_file_exists(_FALLBACK_LANGUAGE):
|
||||
return _FALLBACK_LANGUAGE
|
||||
|
||||
return _FALLBACK_LANGUAGE or "ru"
|
||||
|
||||
|
||||
def _determine_default_language() -> str:
|
||||
try:
|
||||
raw_default = settings.DEFAULT_LANGUAGE
|
||||
except AttributeError:
|
||||
raw_default = None
|
||||
|
||||
configured = raw_default.strip() if isinstance(raw_default, str) else ""
|
||||
|
||||
try:
|
||||
available_languages = settings.get_available_languages()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning("Failed to load available languages from settings: %s", error)
|
||||
available_languages = []
|
||||
|
||||
available_map = {
|
||||
_normalize_language_code(lang): lang.strip()
|
||||
for lang in available_languages
|
||||
if isinstance(lang, str) and lang.strip()
|
||||
}
|
||||
|
||||
if configured:
|
||||
normalized_configured = _normalize_language_code(configured)
|
||||
|
||||
if normalized_configured in available_map:
|
||||
return available_map[normalized_configured]
|
||||
|
||||
if not available_map and _locale_file_exists(normalized_configured):
|
||||
return normalized_configured
|
||||
|
||||
if _locale_file_exists(normalized_configured):
|
||||
_logger.warning(
|
||||
"Configured default language '%s' is not listed in AVAILABLE_LANGUAGES. Falling back to '%s'.",
|
||||
configured,
|
||||
_FALLBACK_LANGUAGE,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Configured default language '%s' is not available. Falling back to '%s'.",
|
||||
configured,
|
||||
_FALLBACK_LANGUAGE,
|
||||
)
|
||||
else:
|
||||
_logger.debug("DEFAULT_LANGUAGE is not set. Falling back to '%s'.", _FALLBACK_LANGUAGE)
|
||||
|
||||
fallback_language = _select_fallback_language(available_map)
|
||||
|
||||
if _normalize_language_code(fallback_language) != _normalize_language_code(_FALLBACK_LANGUAGE):
|
||||
_logger.warning(
|
||||
"Fallback language '%s' is not available. Using '%s' instead.",
|
||||
_FALLBACK_LANGUAGE,
|
||||
fallback_language,
|
||||
)
|
||||
|
||||
return fallback_language or _FALLBACK_LANGUAGE
|
||||
|
||||
|
||||
DEFAULT_LANGUAGE = _determine_default_language()
|
||||
|
||||
|
||||
def _normalize_key(raw_key: Any) -> str:
|
||||
key = str(raw_key).strip().replace(" ", "_")
|
||||
return key.upper()
|
||||
|
||||
|
||||
def _flatten_locale_dict(data: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]:
|
||||
flattened: Dict[str, Any] = {}
|
||||
for key, value in (data or {}).items():
|
||||
composite_key = _normalize_key(key)
|
||||
if parent_key:
|
||||
composite_key = f"{parent_key}_{composite_key}"
|
||||
|
||||
if isinstance(value, dict):
|
||||
flattened.update(_flatten_locale_dict(value, composite_key))
|
||||
else:
|
||||
flattened[composite_key] = value
|
||||
return flattened
|
||||
|
||||
|
||||
def _normalize_locale_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized: Dict[str, Any] = {}
|
||||
for key, value in (data or {}).items():
|
||||
if isinstance(value, dict):
|
||||
normalized.update(_flatten_locale_dict(value, _normalize_key(key)))
|
||||
else:
|
||||
normalized[_normalize_key(key)] = value
|
||||
return normalized
|
||||
|
||||
|
||||
def ensure_locale_templates() -> None:
|
||||
destination = _resolve_user_locales_dir()
|
||||
try:
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as error:
|
||||
_logger.warning("Unable to create locales directory %s: %s", destination, error)
|
||||
return
|
||||
|
||||
if any(destination.glob("*")):
|
||||
return
|
||||
|
||||
if not _DEFAULT_LOCALES_DIR.exists():
|
||||
_logger.debug("Default locales directory %s is missing", _DEFAULT_LOCALES_DIR)
|
||||
return
|
||||
|
||||
for template in _DEFAULT_LOCALES_DIR.iterdir():
|
||||
if not template.is_file():
|
||||
continue
|
||||
target_path = destination / template.name
|
||||
try:
|
||||
shutil.copyfile(template, target_path)
|
||||
except Exception as error:
|
||||
_logger.warning(
|
||||
"Failed to copy default locale %s to %s: %s",
|
||||
template,
|
||||
target_path,
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
def _load_default_locale(language: str) -> Dict[str, Any]:
|
||||
default_path = _DEFAULT_LOCALES_DIR / f"{language}.json"
|
||||
if not default_path.exists():
|
||||
return {}
|
||||
return _normalize_locale_dict(_load_locale_file(default_path))
|
||||
|
||||
|
||||
def _load_user_locale(language: str) -> Dict[str, Any]:
|
||||
user_dir = _resolve_user_locales_dir()
|
||||
for extension in (".json", ".yml", ".yaml"):
|
||||
candidate = user_dir / f"{language}{extension}"
|
||||
if candidate.exists():
|
||||
return _normalize_locale_dict(_load_locale_file(candidate))
|
||||
return {}
|
||||
|
||||
|
||||
def _load_locale_file(path: Path) -> Dict[str, Any]:
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
if suffix == ".json":
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
if suffix in {".yml", ".yaml"}:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ModuleNotFoundError as import_error:
|
||||
raise RuntimeError(
|
||||
"PyYAML is required to load YAML locale files. Install PyYAML or provide JSON files."
|
||||
) from import_error
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as error:
|
||||
_logger.warning("Failed to parse locale file %s: %s", path, error)
|
||||
return {}
|
||||
|
||||
_logger.warning("Unsupported locale format for %s", path)
|
||||
return {}
|
||||
|
||||
|
||||
def _merge_dicts(base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
|
||||
result = dict(base)
|
||||
for key, value in overrides.items():
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
result[key] = _merge_dicts(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def load_locale(language: str) -> Dict[str, Any]:
|
||||
language = language or DEFAULT_LANGUAGE
|
||||
defaults = _load_default_locale(language)
|
||||
overrides = _load_user_locale(language)
|
||||
merged = _merge_dicts(defaults, overrides)
|
||||
|
||||
if not merged and language != DEFAULT_LANGUAGE:
|
||||
_logger.warning(
|
||||
"Locale %s not found. Falling back to default language %s.",
|
||||
language,
|
||||
DEFAULT_LANGUAGE,
|
||||
)
|
||||
return load_locale(DEFAULT_LANGUAGE)
|
||||
return merged
|
||||
|
||||
|
||||
def clear_locale_cache() -> None:
|
||||
load_locale.cache_clear()
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
"CREATE_INVITE_BUTTON": "📝 Create invite",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Disable",
|
||||
"ENABLE_BUTTON": "✅ Enable",
|
||||
"ERROR": "❌ An error occurred",
|
||||
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
|
||||
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
"MY_BALANCE_BUTTON": "💰 My balance",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
|
||||
"NO": "❌ No",
|
||||
"NO_SERVERS_AVAILABLE": "❌ No servers available",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
|
||||
"OTHER_APPS_BUTTON": "📋 Other apps",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"SEND_CONTACT_BUTTON": "📱 Share contact",
|
||||
"SEND_LOCATION_BUTTON": "📍 Share location",
|
||||
"SHOW_QR_BUTTON": "📱 Show QR code",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Active",
|
||||
"SUCCESS": "✅ Success",
|
||||
"REGISTRATION_COMPLETING": "✅ Completing registration...",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes",
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
|
||||
"BALANCE_BUTTON": "💰 Balance: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 Transaction history",
|
||||
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
"CONTACT_SUPPORT": "💬 Contact support",
|
||||
"CREATE_INVITE": "📝 Create invite",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PROMOCODE_ENTER": "🎫 Enter promo code",
|
||||
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
|
||||
"PROMOCODE_INVALID": "❌ Invalid promo code",
|
||||
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
|
||||
"PROMOCODE_USED": "ℹ️ Promo code has already been used",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
|
||||
"RULES_ACCEPT": "✅ I accept the rules",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
"SELECT_TRAFFIC": "Choose traffic package:",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
|
||||
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"TOP_UP_TRIBUTE": "💎 Bank card",
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Paid",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
|
||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Share",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
"ADMIN_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 История операций",
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Выключить",
|
||||
"ENABLE_BUTTON": "✅ Включить",
|
||||
"ERROR": "❌ Произошла ошибка",
|
||||
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
|
||||
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
"MY_BALANCE_BUTTON": "💰 Мой баланс",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
|
||||
"NO": "❌ Нет",
|
||||
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
|
||||
"OPERATION_CANCELLED": "❌ Операция отменена",
|
||||
"OTHER_APPS_BUTTON": "📋 Другие приложения",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
|
||||
"PROMOCODE_ENTER": "🎫 Введите промокод:",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
|
||||
"PROMOCODE_EXPIRED": "❌ Промокод истек",
|
||||
"PROMOCODE_INVALID": "❌ Неверный промокод",
|
||||
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
|
||||
"PROMOCODE_USED": "❌ Промокод уже использован",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
|
||||
"RULES_ACCEPT": "✅ Принимаю правила",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
"SELECT_TRAFFIC": "Выберите пакет трафика:",
|
||||
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
|
||||
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
|
||||
"SHOW_QR_BUTTON": "📱 Показать QR код",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Активна",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"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_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
|
||||
"TOP_UP_TRIBUTE": "💎 Банковская карта",
|
||||
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Платная",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
|
||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
}
|
||||
+192
-483
@@ -1,517 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.loader import (
|
||||
DEFAULT_LANGUAGE,
|
||||
clear_locale_cache,
|
||||
load_locale,
|
||||
)
|
||||
|
||||
_cached_rules = {}
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
def _get_default_rules(language: str = "ru") -> str:
|
||||
if language == "en":
|
||||
return """
|
||||
🔒 <b>Service Usage Rules</b>
|
||||
_cached_rules: Dict[str, str] = {}
|
||||
|
||||
1. It is forbidden to use the service for illegal activities
|
||||
2. Copyright infringement is prohibited
|
||||
3. Spam and malware distribution are prohibited
|
||||
4. Using the service for DDoS attacks is prohibited
|
||||
5. One account - one user
|
||||
6. Refunds are made only in exceptional cases
|
||||
7. Administration reserves the right to block an account for violating the rules
|
||||
|
||||
<b>By accepting the rules, you agree to comply with them.</b>
|
||||
"""
|
||||
else:
|
||||
return """
|
||||
📋 <b>Правила использования сервиса</b>
|
||||
def _get_cached_rules_value(language: str) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
1. Запрещается использование сервиса для незаконной деятельности
|
||||
2. Запрещается нарушение авторских прав
|
||||
3. Запрещается спам и рассылка вредоносного ПО
|
||||
4. Запрещается использование сервиса для DDoS атак
|
||||
5. Один аккаунт - один пользователь
|
||||
6. Возврат средств производится только в исключительных случаях
|
||||
7. Администрация оставляет за собой право заблокировать аккаунт при нарушении правил
|
||||
default = _get_default_rules(language)
|
||||
_cached_rules[language] = default
|
||||
return default
|
||||
|
||||
|
||||
def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
language_code = (language or DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
|
||||
if language_code == "ru":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Техническая поддержка</b>\n\n"
|
||||
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"Мы поможем с:\n"
|
||||
"• Настройкой подключения\n"
|
||||
"• Решением технических проблем \n"
|
||||
"• Вопросами по оплате\n"
|
||||
"• Другими вопросами\n\n"
|
||||
"⏰ Время ответа: обычно в течение 1-2 часов\n"
|
||||
),
|
||||
}
|
||||
|
||||
if language_code == "en":
|
||||
return {
|
||||
"PERIOD_14_DAYS": f"📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": f"📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": f"📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": f"📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": f"📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": f"📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"TRAFFIC_5GB": f"📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": f"📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": f"📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": f"📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": f"📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Technical support</b>\n\n"
|
||||
"For any questions contact our support:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"We can help with:\n"
|
||||
"• Connection setup\n"
|
||||
"• Troubleshooting issues\n"
|
||||
"• Payment questions\n"
|
||||
"• Other requests\n\n"
|
||||
"⏰ Response time: usually within 1-2 hours\n"
|
||||
),
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
<b>Принимая правила, вы соглашаетесь соблюдать их.</b>
|
||||
"""
|
||||
|
||||
class Texts:
|
||||
def __init__(self, language: str = "ru"):
|
||||
self.language = language
|
||||
|
||||
@property
|
||||
def RULES_TEXT(self) -> str:
|
||||
if self.language in _cached_rules:
|
||||
return _cached_rules[self.language]
|
||||
|
||||
return _get_default_rules(self.language)
|
||||
|
||||
BACK = "⬅️ Назад"
|
||||
CANCEL = "❌ Отмена"
|
||||
CONFIRM = "✅ Подтвердить"
|
||||
CONTINUE = "➡️ Продолжить"
|
||||
YES = "✅ Да"
|
||||
NO = "❌ Нет"
|
||||
LOADING = "⏳ Загрузка..."
|
||||
ERROR = "❌ Произошла ошибка"
|
||||
SUCCESS = "✅ Успешно"
|
||||
|
||||
def __init__(self, language: str = DEFAULT_LANGUAGE):
|
||||
self.language = language or DEFAULT_LANGUAGE
|
||||
raw_data = load_locale(self.language)
|
||||
self._values = {key: value for key, value in raw_data.items()}
|
||||
|
||||
if self.language != DEFAULT_LANGUAGE:
|
||||
fallback_data = load_locale(DEFAULT_LANGUAGE)
|
||||
else:
|
||||
fallback_data = self._values
|
||||
|
||||
self._fallback_values = {
|
||||
key: value for key, value in fallback_data.items() if key not in self._values
|
||||
}
|
||||
|
||||
self._values.update(_build_dynamic_values(self.language))
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "language":
|
||||
return super().__getattribute__(item)
|
||||
try:
|
||||
return self._get_value(item)
|
||||
except KeyError as error:
|
||||
raise AttributeError(item) from error
|
||||
|
||||
def __getitem__(self, item: str) -> Any:
|
||||
return self._get_value(item)
|
||||
|
||||
def get(self, item: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self._get_value(item)
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def t(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return self._get_value(key)
|
||||
except KeyError:
|
||||
if default is not None:
|
||||
return default
|
||||
raise
|
||||
|
||||
def _get_value(self, item: str) -> Any:
|
||||
if item == "RULES_TEXT":
|
||||
return _get_cached_rules_value(self.language)
|
||||
|
||||
if item in self._values:
|
||||
return self._values[item]
|
||||
|
||||
if item in self._fallback_values:
|
||||
return self._fallback_values[item]
|
||||
|
||||
_logger.warning(
|
||||
"Missing localization key '%s' for language '%s'",
|
||||
item,
|
||||
self.language,
|
||||
)
|
||||
raise KeyError(item)
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{int(kopeks / 100)} ₽"
|
||||
|
||||
return settings.format_price(kopeks)
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
if gb == 0:
|
||||
return "∞ (безлимит)"
|
||||
elif gb >= 1024:
|
||||
return f"{gb/1024:.1f} ТБ"
|
||||
else:
|
||||
return f"{gb:.0f} ГБ"
|
||||
if gb >= 1024:
|
||||
return f"{gb / 1024:.1f} ТБ"
|
||||
return f"{gb:.0f} ГБ"
|
||||
|
||||
|
||||
class RussianTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("ru")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Добро пожаловать в VPN сервис!</b>
|
||||
def get_texts(language: str = DEFAULT_LANGUAGE) -> Texts:
|
||||
return Texts(language)
|
||||
|
||||
Наш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.
|
||||
|
||||
🔐 <b>Преимущества:</b>
|
||||
• Высокая скорость подключения
|
||||
• Серверы в разных странах
|
||||
• Надежная защита данных
|
||||
• Круглосуточная поддержка
|
||||
|
||||
Для начала работы выберите язык интерфейса:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Язык интерфейса установлен: <b>Русский</b>"
|
||||
|
||||
RULES_ACCEPT = "✅ Принимаю правила"
|
||||
RULES_DECLINE = "❌ Не принимаю"
|
||||
RULES_REQUIRED = "❗️ Для использования сервиса необходимо принять правила!"
|
||||
|
||||
REFERRAL_CODE_QUESTION = """
|
||||
🤝 <b>У вас есть реферальный код от друга?</b>
|
||||
|
||||
Если у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!
|
||||
|
||||
Введите код или нажмите "Пропустить":
|
||||
"""
|
||||
|
||||
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
|
||||
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
|
||||
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
|
||||
|
||||
MAIN_MENU = """👤 <b>{user_name}</b>
|
||||
|
||||
📱 <b>Подписка:</b> {subscription_status}
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
MENU_BALANCE = "💰 Баланс"
|
||||
MENU_SUBSCRIPTION = "📱 Подписка"
|
||||
MENU_TRIAL = "🧪 Тестовая подписка"
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Рефералы"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
SUBSCRIPTION_ACTIVE = "✅ Активна"
|
||||
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
|
||||
|
||||
SUBSCRIPTION_INFO = """
|
||||
📱 <b>Информация о подписке</b>
|
||||
|
||||
📊 <b>Статус:</b> {status}
|
||||
🎭 <b>Тип:</b> {type}
|
||||
📅 <b>Действует до:</b> {end_date}
|
||||
⏰ <b>Осталось дней:</b> {days_left}
|
||||
|
||||
📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}
|
||||
🌍 <b>Серверы:</b> {countries_count} стран
|
||||
📱 <b>Устройства:</b> {devices_used} / {devices_limit}
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
"""
|
||||
|
||||
TRIAL_AVAILABLE = """
|
||||
🎁 <b>Тестовая подписка</b>
|
||||
|
||||
Вы можете получить бесплатную тестовую подписку:
|
||||
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
|
||||
Давайте настроим вашу подписку под ваши потребности.
|
||||
|
||||
Сначала выберите период подписки:
|
||||
"""
|
||||
|
||||
SELECT_PERIOD = "Выберите период:"
|
||||
SELECT_TRAFFIC = "Выберите пакет трафика:"
|
||||
SELECT_COUNTRIES = "Выберите страны:"
|
||||
SELECT_DEVICES = "Количество устройств:"
|
||||
|
||||
PERIOD_14_DAYS = f"📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}"
|
||||
PERIOD_30_DAYS = f"📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}"
|
||||
PERIOD_60_DAYS = f"📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}"
|
||||
PERIOD_90_DAYS = f"📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}"
|
||||
PERIOD_180_DAYS = f"📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}"
|
||||
PERIOD_360_DAYS = f"📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}"
|
||||
|
||||
TRAFFIC_5GB = f"📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"
|
||||
TRAFFIC_10GB = f"📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"
|
||||
TRAFFIC_25GB = f"📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"
|
||||
TRAFFIC_50GB = f"📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"
|
||||
TRAFFIC_100GB = f"📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"
|
||||
TRAFFIC_250GB = f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}"
|
||||
TRAFFIC_UNLIMITED = f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}"
|
||||
|
||||
SUBSCRIPTION_SUMMARY = """
|
||||
📋 <b>Итоговая конфигурация</b>
|
||||
|
||||
📅 <b>Период:</b> {period} дней
|
||||
📈 <b>Трафик:</b> {traffic}
|
||||
🌍 <b>Страны:</b> {countries}
|
||||
📱 <b>Устройства:</b> {devices}
|
||||
|
||||
💰 <b>Итого к оплате:</b> {total_price}
|
||||
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
|
||||
|
||||
<b>Пополните баланс на {amount} и попробуйте снова.</b>
|
||||
"""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
💰 <b>Баланс: {balance}</b>
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
|
||||
BALANCE_HISTORY = "📊 История операций"
|
||||
BALANCE_TOP_UP = "💳 Пополнить"
|
||||
BALANCE_SUPPORT_REQUEST = "🛠️ Запрос через поддержку"
|
||||
|
||||
TOP_UP_AMOUNT = "💳 Введите сумму для пополнения (в рублях):"
|
||||
TOP_UP_METHODS = """
|
||||
💳 <b>Выберите способ оплаты</b>
|
||||
|
||||
Сумма: {amount}
|
||||
"""
|
||||
|
||||
TOP_UP_STARS = "⭐ Telegram Stars"
|
||||
TOP_UP_TRIBUTE = "💎 Банковская карта"
|
||||
|
||||
PROMOCODE_ENTER = "🎫 Введите промокод:"
|
||||
PROMOCODE_SUCCESS = "🎉 Промокод активирован! {description}"
|
||||
PROMOCODE_INVALID = "❌ Неверный промокод"
|
||||
PROMOCODE_EXPIRED = "❌ Промокод истек"
|
||||
PROMOCODE_USED = "❌ Промокод уже использован"
|
||||
|
||||
REFERRAL_INFO = """
|
||||
🤝 <b>Реферальная программа</b>
|
||||
|
||||
👥 <b>Приглашено:</b> {referrals_count} друзей
|
||||
💰 <b>Заработано:</b> {earned_amount}
|
||||
|
||||
🔗 <b>Ваша реферальная ссылка:</b>
|
||||
<code>{referral_link}</code>
|
||||
|
||||
🎫 <b>Ваш промокод:</b>
|
||||
<code>{referral_code}</code>
|
||||
|
||||
💰 <b>Условия:</b>
|
||||
• За каждого друга: {registration_bonus}
|
||||
• Процент с пополнений: {commission_percent}%
|
||||
"""
|
||||
|
||||
REFERRAL_INVITE_MESSAGE = """
|
||||
🎯 <b>Приглашение в VPN сервис</b>
|
||||
|
||||
Привет! Приглашаю тебя в отличный VPN сервис!
|
||||
|
||||
🎁 По моей ссылке ты получишь бонус: {bonus}
|
||||
|
||||
🔗 Переходи: {link}
|
||||
🎫 Или используй промокод: {code}
|
||||
|
||||
💪 Быстро, надежно, недорого!
|
||||
"""
|
||||
|
||||
CREATE_INVITE = "📝 Создать приглашение"
|
||||
|
||||
TRIAL_ENDING_SOON = """
|
||||
🎁 <b>Тестовая подписка скоро закончится!</b>
|
||||
|
||||
Ваша тестовая подписка истекает через несколько часов.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_ACTIVE = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.
|
||||
|
||||
⏰ Ориентировочное время завершения: неизвестно
|
||||
🔄 Попробуйте позже
|
||||
|
||||
Приносим извинения за временные неудобства.
|
||||
"""
|
||||
|
||||
MAINTENANCE_MODE_API_ERROR = """
|
||||
🔧 Технические работы!
|
||||
|
||||
Сервис временно недоступен из-за проблем с подключением к серверам.
|
||||
|
||||
⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.
|
||||
|
||||
🔄 Последняя проверка: {last_check}
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRING_PAID = """
|
||||
⚠️ <b>Подписка истекает через {days_text}!</b>
|
||||
|
||||
Ваша платная подписка истекает {end_date}.
|
||||
|
||||
💳 <b>Автоплатеж:</b> {autopay_status}
|
||||
|
||||
{action_text}
|
||||
"""
|
||||
|
||||
AUTOPAY_ENABLED_TEXT = "Включен - подписка продлится автоматически"
|
||||
AUTOPAY_DISABLED_TEXT = "Отключен - не забудьте продлить вручную!"
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
|
||||
🔧 Доступ к серверам заблокирован до продления.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
|
||||
Новая дата окончания: {new_end_date}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
|
||||
💰 Ваш баланс: {balance}
|
||||
💳 Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
SUPPORT_INFO = f"""
|
||||
🛠️ <b>Техническая поддержка</b>
|
||||
|
||||
По всем вопросам обращайтесь к нашей поддержке:
|
||||
|
||||
👤 {settings.SUPPORT_USERNAME}
|
||||
|
||||
Мы поможем с:
|
||||
• Настройкой подключения
|
||||
• Решением технических проблем
|
||||
• Вопросами по оплате
|
||||
• Другими вопросами
|
||||
|
||||
⏰ Время ответа: обычно в течение 1-2 часов
|
||||
"""
|
||||
|
||||
CONTACT_SUPPORT = "💬 Написать в поддержку"
|
||||
|
||||
ADMIN_PANEL = """
|
||||
⚙️ <b>Административная панель</b>
|
||||
|
||||
Выберите раздел для управления:
|
||||
"""
|
||||
|
||||
ADMIN_USERS = "👥 Пользователи"
|
||||
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
|
||||
ACCESS_DENIED = "❌ Доступ запрещен"
|
||||
USER_NOT_FOUND = "❌ Пользователь не найден"
|
||||
SUBSCRIPTION_NOT_FOUND = "❌ Подписка не найдена"
|
||||
INVALID_AMOUNT = "❌ Неверная сумма"
|
||||
OPERATION_CANCELLED = "❌ Операция отменена"
|
||||
|
||||
SUBSCRIPTION_EXPIRING = """
|
||||
⚠️ <b>Подписка истекает!</b>
|
||||
|
||||
Ваша подписка истекает через {days} дней.
|
||||
|
||||
Не забудьте продлить подписку, чтобы не потерять доступ к серверам.
|
||||
"""
|
||||
|
||||
SUBSCRIPTION_EXPIRED = """
|
||||
❌ <b>Подписка истекла</b>
|
||||
|
||||
Ваша подписка истекла. Для восстановления доступа продлите подписку.
|
||||
"""
|
||||
|
||||
AUTOPAY_SUCCESS = """
|
||||
✅ <b>Автоплатеж выполнен</b>
|
||||
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
|
||||
Не удалось списать средства для продления подписки.
|
||||
Недостаточно средств на балансе: {balance}
|
||||
Требуется: {required}
|
||||
|
||||
Пополните баланс и продлите подписку вручную.
|
||||
"""
|
||||
|
||||
|
||||
class EnglishTexts(Texts):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("en")
|
||||
|
||||
WELCOME = """
|
||||
🎉 <b>Welcome to VPN Service!</b>
|
||||
|
||||
Our service provides fast and secure internet access without restrictions.
|
||||
|
||||
🔐 <b>Advantages:</b>
|
||||
• High connection speed
|
||||
• Servers in different countries
|
||||
• Reliable data protection
|
||||
• 24/7 support
|
||||
|
||||
To get started, select interface language:
|
||||
"""
|
||||
|
||||
LANGUAGE_SELECTED = "🌐 Interface language set: <b>English</b>"
|
||||
|
||||
BACK = "⬅️ Back"
|
||||
CANCEL = "❌ Cancel"
|
||||
CONFIRM = "✅ Confirm"
|
||||
CONTINUE = "➡️ Continue"
|
||||
YES = "✅ Yes"
|
||||
NO = "❌ No"
|
||||
|
||||
MENU_BALANCE = "💰 Balance"
|
||||
MENU_SUBSCRIPTION = "📱 Subscription"
|
||||
MENU_TRIAL = "🎁 Trial subscription"
|
||||
INSUFFICIENT_BALANCE = """❌ Insufficient balance. " \
|
||||
|
||||
Top up {amount} and try again."""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
|
||||
|
||||
|
||||
LANGUAGES = {
|
||||
"ru": RussianTexts,
|
||||
"en": EnglishTexts
|
||||
}
|
||||
|
||||
|
||||
def get_texts(language: str = "ru") -> Texts:
|
||||
return LANGUAGES.get(language, RussianTexts)()
|
||||
|
||||
async def get_rules_from_db(language: str = "ru") -> str:
|
||||
async def get_rules_from_db(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
try:
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.rules import get_current_rules_content
|
||||
|
||||
|
||||
async for db in get_db():
|
||||
rules = await get_current_rules_content(db, language)
|
||||
if rules:
|
||||
_cached_rules[language] = rules
|
||||
return rules
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил из БД: {e}")
|
||||
|
||||
default_rules = _get_default_rules(language)
|
||||
_cached_rules[language] = default_rules
|
||||
return default_rules
|
||||
|
||||
def get_rules_sync(language: str = "ru") -> str:
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
_logger.warning("Failed to load rules from DB for %s: %s", language, error)
|
||||
|
||||
default = _get_default_rules(language)
|
||||
_cached_rules[language] = default
|
||||
return default
|
||||
|
||||
|
||||
def _get_default_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
default_key = "RULES_TEXT_DEFAULT"
|
||||
locale = load_locale(language)
|
||||
if default_key in locale:
|
||||
return locale[default_key]
|
||||
fallback = load_locale(DEFAULT_LANGUAGE)
|
||||
return fallback.get(default_key, "")
|
||||
|
||||
|
||||
def get_rules_sync(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
rules = loop.run_until_complete(get_rules_from_db(language))
|
||||
loop.close()
|
||||
return rules
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка получения правил: {e}")
|
||||
return _get_default_rules(language)
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(get_rules(language))
|
||||
|
||||
async def refresh_rules_cache(language: str = "ru"):
|
||||
try:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
|
||||
await get_rules_from_db(language)
|
||||
print(f"✅ Кеш правил для языка {language} обновлен")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления кеша правил: {e}")
|
||||
loop.create_task(get_rules(language))
|
||||
return _get_cached_rules_value(language)
|
||||
|
||||
def clear_rules_cache():
|
||||
global _cached_rules
|
||||
|
||||
async def get_rules(language: str = DEFAULT_LANGUAGE) -> str:
|
||||
if language in _cached_rules:
|
||||
return _cached_rules[language]
|
||||
|
||||
return await get_rules_from_db(language)
|
||||
|
||||
|
||||
async def refresh_rules_cache(language: str = DEFAULT_LANGUAGE) -> None:
|
||||
if language in _cached_rules:
|
||||
del _cached_rules[language]
|
||||
await get_rules_from_db(language)
|
||||
|
||||
|
||||
def clear_rules_cache() -> None:
|
||||
_cached_rules.clear()
|
||||
print("✅ Кеш правил очищен")
|
||||
|
||||
|
||||
def reload_locales() -> None:
|
||||
clear_locale_cache()
|
||||
|
||||
+57
-23
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
@@ -8,11 +9,31 @@ from aiogram.fsm.context import FSMContext
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.crud.user import get_user_by_telegram_id, create_user
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.states import RegistrationStates
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _refresh_remnawave_description(
|
||||
remnawave_uuid: str,
|
||||
description: str,
|
||||
telegram_id: int
|
||||
) -> None:
|
||||
try:
|
||||
remnawave_service = RemnaWaveService()
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(uuid=remnawave_uuid, description=description)
|
||||
logger.info(
|
||||
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(
|
||||
f"❌ [Middleware] Ошибка обновления RemnaWave для {telegram_id}: {remnawave_error}"
|
||||
)
|
||||
|
||||
|
||||
class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
@@ -21,7 +42,7 @@ class AuthMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
|
||||
user: TgUser = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = event.from_user
|
||||
@@ -42,24 +63,23 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
is_channel_check = (isinstance(event, CallbackQuery)
|
||||
and event.data == "sub_channel_check")
|
||||
|
||||
is_start_command = (isinstance(event, Message)
|
||||
and event.text
|
||||
and event.text.startswith('/start'))
|
||||
|
||||
if is_reg_process or is_channel_check or is_start_command:
|
||||
if is_start_command:
|
||||
logger.info(f"🚀 Пропускаем команду /start от пользователя {user.id}")
|
||||
elif is_channel_check:
|
||||
logger.info(f"🔍 Пропускаем незарегистрированного пользователя {user.id} для проверки канала")
|
||||
else:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
data['db'] = db
|
||||
data['db_user'] = None
|
||||
data['is_admin'] = False
|
||||
@@ -135,27 +155,41 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if db_user.username != user.username:
|
||||
old_username = db_user.username
|
||||
db_user.username = user.username
|
||||
logger.info(f"📝 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}' → '{db_user.username}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.first_name != user.first_name:
|
||||
old_first_name = db_user.first_name
|
||||
db_user.first_name = user.first_name
|
||||
logger.info(f"📝 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}' → '{db_user.first_name}'")
|
||||
profile_updated = True
|
||||
|
||||
if db_user.last_name != user.last_name:
|
||||
old_last_name = db_user.last_name
|
||||
db_user.last_name = user.last_name
|
||||
logger.info(f"📝 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}' → '{db_user.last_name}'")
|
||||
profile_updated = True
|
||||
|
||||
db_user.last_activity = datetime.utcnow()
|
||||
|
||||
|
||||
if profile_updated:
|
||||
db_user.updated_at = datetime.utcnow()
|
||||
logger.info(f"💾 [Middleware] Профиль пользователя {user.id} обновлен в middleware")
|
||||
|
||||
|
||||
if db_user.remnawave_uuid:
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=db_user.full_name,
|
||||
username=db_user.username,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
asyncio.create_task(
|
||||
_refresh_remnawave_description(
|
||||
remnawave_uuid=db_user.remnawave_uuid,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
data['db'] = db
|
||||
@@ -170,4 +204,4 @@ class AuthMiddleware(BaseMiddleware):
|
||||
if hasattr(event, 'data'):
|
||||
logger.error(f"Callback data: {event.data}")
|
||||
await db.rollback()
|
||||
raise
|
||||
raise
|
||||
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware, Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Update, Message, CallbackQuery
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
|
||||
from app.config import settings
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = (
|
||||
ChatMemberStatus.LEFT,
|
||||
ChatMemberStatus.KICKED,
|
||||
ChatMemberStatus.RESTRICTED
|
||||
)
|
||||
self.GOOD_MEMBER_STATUS = (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR
|
||||
)
|
||||
logger.info("🔧 ChannelCheckerMiddleware инициализирован")
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
elif isinstance(event, Update):
|
||||
if event.message:
|
||||
telegram_id = event.message.from_user.id
|
||||
elif event.callback_query:
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id is None:
|
||||
logger.debug("❌ telegram_id не найден, пропускаем")
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_reg_process:
|
||||
logger.debug("✅ Событие разрешено (процесс регистрации), пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
bot: Bot = data["bot"]
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
|
||||
if not channel_id:
|
||||
logger.warning("⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
is_required = settings.CHANNEL_IS_REQUIRED_SUB
|
||||
|
||||
if not is_required:
|
||||
logger.debug("⚠️ Обязательная подписка отключена, пропускаем проверку")
|
||||
return await handler(event, data)
|
||||
|
||||
channel_link = settings.CHANNEL_LINK
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
|
||||
if member.status in self.GOOD_MEMBER_STATUS:
|
||||
return await handler(event, data)
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
|
||||
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
|
||||
return
|
||||
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
else:
|
||||
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error(f"❌ Бот заблокирован в канале {channel_id}: {e}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except TelegramBadRequest as e:
|
||||
if "chat not found" in str(e).lower():
|
||||
logger.error(f"❌ Канал {channel_id} не найден: {e}")
|
||||
elif "user not found" in str(e).lower():
|
||||
logger.error(f"❌ Пользователь {telegram_id} не найден: {e}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запроса к каналу {channel_id}: {e}")
|
||||
return await self._deny_message(event, bot, channel_link)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
|
||||
return await handler(event, data)
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
|
||||
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
|
||||
|
||||
user = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, "from_user", None)
|
||||
elif isinstance(event, Update):
|
||||
if event.message and event.message.from_user:
|
||||
user = event.message.from_user
|
||||
elif event.callback_query and event.callback_query.from_user:
|
||||
user = event.callback_query.from_user
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
if user and user.language_code:
|
||||
language = user.language_code.split('-')[0]
|
||||
|
||||
texts = get_texts(language)
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
|
||||
text = texts.t(
|
||||
"CHANNEL_REQUIRED_TEXT",
|
||||
"🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!",
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, CallbackQuery):
|
||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
elif isinstance(event, Update) and event.message:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при отправке сообщения о подписке: {e}")
|
||||
@@ -0,0 +1,148 @@
|
||||
import logging
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import TelegramObject, CallbackQuery, Message
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GlobalErrorMiddleware(BaseMiddleware):
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
return await self._handle_telegram_error(event, e)
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка в GlobalErrorMiddleware: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if self._is_old_query_error(error_message):
|
||||
return await self._handle_old_query(event, error)
|
||||
elif self._is_message_not_modified_error(error_message):
|
||||
return await self._handle_message_not_modified(event, error)
|
||||
elif self._is_bad_request_error(error_message):
|
||||
return await self._handle_bad_request(event, error)
|
||||
else:
|
||||
logger.error(f"Неизвестная Telegram API ошибка: {error}")
|
||||
raise error
|
||||
|
||||
def _is_old_query_error(self, error_message: str) -> bool:
|
||||
return any(phrase in error_message for phrase in [
|
||||
"query is too old",
|
||||
"query id is invalid",
|
||||
"response timeout expired"
|
||||
])
|
||||
|
||||
def _is_message_not_modified_error(self, error_message: str) -> bool:
|
||||
return "message is not modified" in error_message
|
||||
|
||||
def _is_bad_request_error(self, error_message: str) -> bool:
|
||||
return any(phrase in error_message for phrase in [
|
||||
"message not found",
|
||||
"chat not found",
|
||||
"bot was blocked by the user",
|
||||
"user is deactivated"
|
||||
])
|
||||
|
||||
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
if isinstance(event, CallbackQuery):
|
||||
user_info = self._get_user_info(event)
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
|
||||
else:
|
||||
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}")
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
logger.debug(f"📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}")
|
||||
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
logger.debug("✅ Успешно ответили на callback после 'message not modified'")
|
||||
except TelegramBadRequest as answer_error:
|
||||
if not self._is_old_query_error(str(answer_error).lower()):
|
||||
logger.error(f"❌ Ошибка при ответе на callback: {answer_error}")
|
||||
|
||||
return None
|
||||
|
||||
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "bot was blocked" in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown"
|
||||
logger.info(f"🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}")
|
||||
return None
|
||||
elif "user is deactivated" in error_message:
|
||||
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else "Unknown"
|
||||
logger.info(f"👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}")
|
||||
return None
|
||||
elif "chat not found" in error_message or "message not found" in error_message:
|
||||
logger.warning(f"🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}")
|
||||
raise error
|
||||
|
||||
def _get_user_info(self, event: TelegramObject) -> str:
|
||||
if hasattr(event, 'from_user') and event.from_user:
|
||||
if event.from_user.username:
|
||||
return f"@{event.from_user.username}"
|
||||
else:
|
||||
return f"ID:{event.from_user.id}"
|
||||
return "Unknown"
|
||||
|
||||
|
||||
class ErrorStatisticsMiddleware(BaseMiddleware):
|
||||
|
||||
def __init__(self):
|
||||
self.error_counts = {
|
||||
'old_queries': 0,
|
||||
'message_not_modified': 0,
|
||||
'bot_blocked': 0,
|
||||
'user_deactivated': 0,
|
||||
'other_errors': 0
|
||||
}
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: Dict[str, Any]
|
||||
) -> Any:
|
||||
try:
|
||||
return await handler(event, data)
|
||||
except TelegramBadRequest as e:
|
||||
self._count_error(e)
|
||||
raise
|
||||
|
||||
def _count_error(self, error: TelegramBadRequest):
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "query is too old" in error_message:
|
||||
self.error_counts['old_queries'] += 1
|
||||
elif "message is not modified" in error_message:
|
||||
self.error_counts['message_not_modified'] += 1
|
||||
elif "bot was blocked" in error_message:
|
||||
self.error_counts['bot_blocked'] += 1
|
||||
elif "user is deactivated" in error_message:
|
||||
self.error_counts['user_deactivated'] += 1
|
||||
else:
|
||||
self.error_counts['other_errors'] += 1
|
||||
|
||||
def get_statistics(self) -> dict:
|
||||
return self.error_counts.copy()
|
||||
|
||||
def reset_statistics(self):
|
||||
for key in self.error_counts:
|
||||
self.error_counts[key] = 0
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
@@ -707,4 +707,97 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_subscription_update_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
update_type: str,
|
||||
old_value: Any,
|
||||
new_value: Any,
|
||||
price_paid: int = 0
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
update_types = {
|
||||
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
|
||||
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
|
||||
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
|
||||
}
|
||||
|
||||
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
|
||||
|
||||
message = f"""{title}
|
||||
|
||||
👤 <b>Пользователь:</b> {user.full_name}
|
||||
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
|
||||
📱 <b>Username:</b> @{user.username or 'отсутствует'}
|
||||
|
||||
🔧 <b>Изменение:</b>
|
||||
📋 Параметр: {param_name}"""
|
||||
|
||||
if update_type == "servers":
|
||||
old_servers_info = await self._format_servers_detailed(old_value)
|
||||
new_servers_info = await self._format_servers_detailed(new_value)
|
||||
|
||||
message += f"""
|
||||
📉 Было: {old_servers_info}
|
||||
📈 Стало: {new_servers_info}"""
|
||||
else:
|
||||
message += f"""
|
||||
📉 Было: {self._format_update_value(old_value, update_type)}
|
||||
📈 Стало: {self._format_update_value(new_value, update_type)}"""
|
||||
|
||||
if price_paid > 0:
|
||||
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
|
||||
else:
|
||||
message += f"\n💸 Бесплатно"
|
||||
|
||||
message += f"""
|
||||
|
||||
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Рефер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
|
||||
return False
|
||||
|
||||
async def _format_servers_detailed(self, server_uuids: List[str]) -> str:
|
||||
if not server_uuids:
|
||||
return "Нет серверов"
|
||||
|
||||
try:
|
||||
from app.handlers.subscription import get_servers_display_names
|
||||
servers_names = await get_servers_display_names(server_uuids)
|
||||
|
||||
if servers_names and servers_names != "Нет серверов":
|
||||
return f"{len(server_uuids)} серверов ({servers_names})"
|
||||
else:
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка получения названий серверов для уведомления: {e}")
|
||||
return f"{len(server_uuids)} серверов"
|
||||
|
||||
def _format_update_value(self, value: Any, update_type: str) -> str:
|
||||
if update_type == "traffic":
|
||||
if value == 0:
|
||||
return "♾ Безлимитный"
|
||||
return f"{value} ГБ"
|
||||
elif update_type == "devices":
|
||||
return f"{value} устройств"
|
||||
elif update_type == "servers":
|
||||
if isinstance(value, list):
|
||||
return f"{len(value)} серверов"
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
import asyncio
|
||||
import json as json_lib
|
||||
import logging
|
||||
import gzip
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
import aiofiles
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text, inspect
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db, engine
|
||||
from app.database.models import (
|
||||
User, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, Squad, ServiceRule, SystemSetting, MonitoringLog,
|
||||
SubscriptionConversion, SentNotification, BroadcastHistory,
|
||||
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
|
||||
CryptoBotPayment, WelcomeText, Base
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupMetadata:
|
||||
timestamp: str
|
||||
version: str = "1.1"
|
||||
database_type: str = "postgresql"
|
||||
backup_type: str = "full"
|
||||
tables_count: int = 0
|
||||
total_records: int = 0
|
||||
compressed: bool = True
|
||||
file_size_bytes: int = 0
|
||||
created_by: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupSettings:
|
||||
auto_backup_enabled: bool = True
|
||||
backup_interval_hours: int = 24
|
||||
backup_time: str = "03:00"
|
||||
max_backups_keep: int = 7
|
||||
compression_enabled: bool = True
|
||||
include_logs: bool = False
|
||||
backup_location: str = "/app/data/backups"
|
||||
|
||||
|
||||
class BackupService:
|
||||
|
||||
def __init__(self, bot=None):
|
||||
self.bot = bot
|
||||
self.backup_dir = Path(settings.SQLITE_PATH).parent / "backups"
|
||||
self.backup_dir.mkdir(exist_ok=True)
|
||||
self._auto_backup_task = None
|
||||
self._settings = self._load_settings()
|
||||
|
||||
self.backup_models_ordered = [
|
||||
ServiceRule,
|
||||
SystemSetting,
|
||||
Squad,
|
||||
PromoCode,
|
||||
ServerSquad,
|
||||
|
||||
User,
|
||||
|
||||
WelcomeText,
|
||||
Subscription,
|
||||
Transaction,
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionConversion,
|
||||
BroadcastHistory,
|
||||
UserMessage,
|
||||
|
||||
SentNotification,
|
||||
SubscriptionServer,
|
||||
]
|
||||
|
||||
if self._settings.include_logs:
|
||||
self.backup_models_ordered.append(MonitoringLog)
|
||||
|
||||
def _load_settings(self) -> BackupSettings:
|
||||
return BackupSettings(
|
||||
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
|
||||
backup_interval_hours=int(os.getenv("BACKUP_INTERVAL_HOURS", "24")),
|
||||
backup_time=os.getenv("BACKUP_TIME", "03:00"),
|
||||
max_backups_keep=int(os.getenv("BACKUP_MAX_KEEP", "7")),
|
||||
compression_enabled=os.getenv("BACKUP_COMPRESSION", "true").lower() == "true",
|
||||
include_logs=os.getenv("BACKUP_INCLUDE_LOGS", "false").lower() == "true",
|
||||
backup_location=os.getenv("BACKUP_LOCATION", "/app/data/backups")
|
||||
)
|
||||
|
||||
def _parse_backup_time(self) -> Tuple[int, int]:
|
||||
time_str = (self._settings.backup_time or "").strip()
|
||||
|
||||
try:
|
||||
parts = time_str.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError("Invalid time format")
|
||||
|
||||
hours, minutes = map(int, parts)
|
||||
|
||||
if not (0 <= hours < 24 and 0 <= minutes < 60):
|
||||
raise ValueError("Hours or minutes out of range")
|
||||
|
||||
return hours, minutes
|
||||
|
||||
except ValueError:
|
||||
default_hours, default_minutes = 3, 0
|
||||
logger.warning(
|
||||
"Некорректное значение BACKUP_TIME='%s'. Используется значение по умолчанию 03:00.",
|
||||
self._settings.backup_time
|
||||
)
|
||||
self._settings.backup_time = "03:00"
|
||||
return default_hours, default_minutes
|
||||
|
||||
def _calculate_next_backup_datetime(self, reference: Optional[datetime] = None) -> datetime:
|
||||
reference = reference or datetime.now()
|
||||
hours, minutes = self._parse_backup_time()
|
||||
|
||||
next_run = reference.replace(hour=hours, minute=minutes, second=0, microsecond=0)
|
||||
if next_run <= reference:
|
||||
next_run += timedelta(days=1)
|
||||
|
||||
return next_run
|
||||
|
||||
def _get_backup_interval(self) -> timedelta:
|
||||
hours = self._settings.backup_interval_hours
|
||||
|
||||
if hours <= 0:
|
||||
logger.warning(
|
||||
"Некорректное значение BACKUP_INTERVAL_HOURS=%s. Используется значение по умолчанию 24.",
|
||||
hours
|
||||
)
|
||||
hours = 24
|
||||
self._settings.backup_interval_hours = hours
|
||||
|
||||
return timedelta(hours=hours)
|
||||
|
||||
async def create_backup(
|
||||
self,
|
||||
created_by: Optional[int] = None,
|
||||
compress: bool = True,
|
||||
include_logs: bool = None
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
try:
|
||||
logger.info("📄 Начинаем создание бекапа...")
|
||||
|
||||
if include_logs is None:
|
||||
include_logs = self._settings.include_logs
|
||||
|
||||
models_to_backup = self.backup_models_ordered.copy()
|
||||
if not include_logs and MonitoringLog in models_to_backup:
|
||||
models_to_backup.remove(MonitoringLog)
|
||||
elif include_logs and MonitoringLog not in models_to_backup:
|
||||
models_to_backup.append(MonitoringLog)
|
||||
|
||||
backup_data = {}
|
||||
total_records = 0
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
for model in models_to_backup:
|
||||
table_name = model.__tablename__
|
||||
logger.info(f"📊 Экспортируем таблицу: {table_name}")
|
||||
|
||||
query = select(model)
|
||||
|
||||
if model == User:
|
||||
query = query.options(selectinload(User.subscription))
|
||||
elif model == Subscription:
|
||||
query = query.options(selectinload(Subscription.user))
|
||||
elif model == Transaction:
|
||||
query = query.options(selectinload(Transaction.user))
|
||||
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
|
||||
table_data = []
|
||||
for record in records:
|
||||
record_dict = {}
|
||||
for column in model.__table__.columns:
|
||||
value = getattr(record, column.name)
|
||||
|
||||
if value is None:
|
||||
record_dict[column.name] = None
|
||||
elif isinstance(value, datetime):
|
||||
record_dict[column.name] = value.isoformat()
|
||||
elif isinstance(value, (list, dict)):
|
||||
record_dict[column.name] = json_lib.dumps(value) if value else None
|
||||
elif hasattr(value, '__dict__'):
|
||||
record_dict[column.name] = str(value)
|
||||
else:
|
||||
record_dict[column.name] = value
|
||||
|
||||
table_data.append(record_dict)
|
||||
|
||||
backup_data[table_name] = table_data
|
||||
total_records += len(table_data)
|
||||
|
||||
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
|
||||
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при экспорте данных: {e}")
|
||||
raise e
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
metadata = BackupMetadata(
|
||||
timestamp=datetime.utcnow().isoformat(),
|
||||
database_type="postgresql" if settings.is_postgresql() else "sqlite",
|
||||
backup_type="full",
|
||||
tables_count=len(models_to_backup),
|
||||
total_records=total_records,
|
||||
compressed=compress,
|
||||
created_by=created_by,
|
||||
file_size_bytes=0
|
||||
)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"backup_{timestamp}.json"
|
||||
if compress:
|
||||
filename += ".gz"
|
||||
|
||||
backup_path = self.backup_dir / filename
|
||||
|
||||
backup_structure = {
|
||||
"metadata": asdict(metadata),
|
||||
"data": backup_data
|
||||
}
|
||||
|
||||
if compress:
|
||||
backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
|
||||
async with aiofiles.open(backup_path, 'wb') as f:
|
||||
compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
|
||||
await f.write(compressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
|
||||
|
||||
file_size = backup_path.stat().st_size
|
||||
backup_structure["metadata"]["file_size_bytes"] = file_size
|
||||
|
||||
if compress:
|
||||
backup_json_str = json_lib.dumps(backup_structure, ensure_ascii=False, indent=2)
|
||||
async with aiofiles.open(backup_path, 'wb') as f:
|
||||
compressed_data = gzip.compress(backup_json_str.encode('utf-8'))
|
||||
await f.write(compressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json_lib.dumps(backup_structure, ensure_ascii=False, indent=2))
|
||||
|
||||
await self._cleanup_old_backups()
|
||||
|
||||
size_mb = file_size / 1024 / 1024
|
||||
message = (f"✅ Бекап успешно создан!\n"
|
||||
f"📁 Файл: {filename}\n"
|
||||
f"📊 Таблиц: {len(models_to_backup)}\n"
|
||||
f"📈 Записей: {total_records:,}\n"
|
||||
f"💾 Размер: {size_mb:.2f} MB")
|
||||
|
||||
logger.info(message)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification(
|
||||
"success", message, str(backup_path)
|
||||
)
|
||||
|
||||
await self._send_backup_file_to_chat(str(backup_path))
|
||||
|
||||
return True, message, str(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка создания бекапа: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("error", error_msg)
|
||||
|
||||
return False, error_msg, None
|
||||
|
||||
async def restore_backup(
|
||||
self,
|
||||
backup_file_path: str,
|
||||
clear_existing: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
try:
|
||||
logger.info(f"📄 Начинаем восстановление из {backup_file_path}")
|
||||
|
||||
backup_path = Path(backup_file_path)
|
||||
if not backup_path.exists():
|
||||
return False, f"❌ Файл бекапа не найден: {backup_file_path}"
|
||||
|
||||
if backup_path.suffix == '.gz':
|
||||
async with aiofiles.open(backup_path, 'rb') as f:
|
||||
compressed_data = await f.read()
|
||||
uncompressed_data = gzip.decompress(compressed_data).decode('utf-8')
|
||||
backup_structure = json_lib.loads(uncompressed_data)
|
||||
else:
|
||||
async with aiofiles.open(backup_path, 'r', encoding='utf-8') as f:
|
||||
file_content = await f.read()
|
||||
backup_structure = json_lib.loads(file_content)
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
backup_data = backup_structure.get("data", {})
|
||||
|
||||
if not backup_data:
|
||||
return False, "❌ Файл бекапа не содержит данных"
|
||||
|
||||
logger.info(f"📊 Загружен бекап от {metadata.get('timestamp')}")
|
||||
logger.info(f"📈 Содержит {metadata.get('total_records', 0)} записей")
|
||||
|
||||
restored_records = 0
|
||||
restored_tables = 0
|
||||
|
||||
async for db in get_db():
|
||||
try:
|
||||
if clear_existing:
|
||||
logger.warning("🗑️ Очищаем существующие данные...")
|
||||
await self._clear_database_tables(db)
|
||||
|
||||
models_by_table = {model.__tablename__: model for model in self.backup_models_ordered}
|
||||
|
||||
await self._restore_users_without_referrals(db, backup_data, models_by_table)
|
||||
|
||||
for model in self.backup_models_ordered:
|
||||
table_name = model.__tablename__
|
||||
|
||||
if table_name == "users":
|
||||
continue
|
||||
|
||||
records = backup_data.get(table_name, [])
|
||||
if not records:
|
||||
continue
|
||||
|
||||
logger.info(f"🔥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
|
||||
|
||||
for record_data in records:
|
||||
try:
|
||||
processed_data = self._process_record_data(record_data, model, table_name)
|
||||
|
||||
primary_key_col = self._get_primary_key_column(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]
|
||||
)
|
||||
)
|
||||
existing = existing_record.scalar_one_or_none()
|
||||
|
||||
if existing and not clear_existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != primary_key_col:
|
||||
setattr(existing, key, value)
|
||||
logger.debug(f"Обновлена существующая запись {primary_key_col}={processed_data[primary_key_col]} в {table_name}")
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = model(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
restored_records += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
|
||||
logger.error(f"Проблемные данные: {record_data}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
restored_tables += 1
|
||||
logger.info(f"✅ Таблица {table_name} восстановлена")
|
||||
|
||||
await self._update_user_referrals(db, backup_data)
|
||||
|
||||
await db.commit()
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка при восстановлении: {e}")
|
||||
raise e
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
message = (f"✅ Восстановление завершено!\n"
|
||||
f"📊 Таблиц: {restored_tables}\n"
|
||||
f"📈 Записей: {restored_records:,}\n"
|
||||
f"📅 Дата бекапа: {metadata.get('timestamp', 'неизвестно')}")
|
||||
|
||||
logger.info(message)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_success", message)
|
||||
|
||||
return True, message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка восстановления: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
|
||||
if self.bot:
|
||||
await self._send_backup_notification("restore_error", error_msg)
|
||||
|
||||
return False, error_msg
|
||||
|
||||
async def _restore_users_without_referrals(self, db: AsyncSession, backup_data: dict, models_by_table: dict):
|
||||
users_data = backup_data.get("users", [])
|
||||
if not users_data:
|
||||
return
|
||||
|
||||
logger.info(f"👥 Восстанавливаем {len(users_data)} пользователей без реферальных связей")
|
||||
|
||||
User = models_by_table["users"]
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
processed_data = self._process_record_data(user_data, User, "users")
|
||||
processed_data['referred_by_id'] = None
|
||||
|
||||
if 'id' in processed_data:
|
||||
existing_user = await db.execute(
|
||||
select(User).where(User.id == processed_data['id'])
|
||||
)
|
||||
existing = existing_user.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
for key, value in processed_data.items():
|
||||
if key != 'id':
|
||||
setattr(existing, key, value)
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
db.add(instance)
|
||||
else:
|
||||
instance = User(**processed_data)
|
||||
db.add(instance)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при восстановлении пользователя: {e}")
|
||||
await db.rollback()
|
||||
raise e
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ Пользователи без реферальных связей восстановлены")
|
||||
|
||||
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
|
||||
users_data = backup_data.get("users", [])
|
||||
if not users_data:
|
||||
return
|
||||
|
||||
logger.info("🔗 Обновляем реферальные связи пользователей")
|
||||
|
||||
for user_data in users_data:
|
||||
try:
|
||||
referred_by_id = user_data.get('referred_by_id')
|
||||
user_id = user_data.get('id')
|
||||
|
||||
if referred_by_id and user_id:
|
||||
referrer_result = await db.execute(
|
||||
select(User).where(User.id == referred_by_id)
|
||||
)
|
||||
referrer = referrer_result.scalar_one_or_none()
|
||||
|
||||
if referrer:
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
user.referred_by_id = referred_by_id
|
||||
else:
|
||||
logger.warning(f"Пользователь {user_id} не найден для обновления реферальной связи")
|
||||
else:
|
||||
logger.warning(f"Реферер {referred_by_id} не найден для пользователя {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обновлении реферальной связи: {e}")
|
||||
continue
|
||||
|
||||
await db.commit()
|
||||
logger.info("✅ Реферальные связи обновлены")
|
||||
|
||||
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
|
||||
processed_data = {}
|
||||
|
||||
for key, value in record_data.items():
|
||||
if value is None:
|
||||
processed_data[key] = None
|
||||
continue
|
||||
|
||||
column = getattr(model.__table__.columns, key, None)
|
||||
if column is None:
|
||||
logger.warning(f"Колонка {key} не найдена в модели {table_name}")
|
||||
continue
|
||||
|
||||
column_type_str = str(column.type).upper()
|
||||
|
||||
if ('DATETIME' in column_type_str or 'TIMESTAMP' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
if 'T' in value:
|
||||
processed_data[key] = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||||
else:
|
||||
processed_data[key] = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Не удалось парсить дату {value} для поля {key}: {e}")
|
||||
processed_data[key] = datetime.utcnow()
|
||||
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 ('INTEGER' in column_type_str or 'INT' in column_type_str or 'BIGINT' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = int(value)
|
||||
except ValueError:
|
||||
processed_data[key] = 0
|
||||
elif ('FLOAT' in column_type_str or 'REAL' in column_type_str or 'NUMERIC' in column_type_str) and isinstance(value, str):
|
||||
try:
|
||||
processed_data[key] = float(value)
|
||||
except ValueError:
|
||||
processed_data[key] = 0.0
|
||||
elif 'JSON' in column_type_str:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
processed_data[key] = json_lib.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
processed_data[key] = value
|
||||
elif isinstance(value, (list, dict)):
|
||||
processed_data[key] = value
|
||||
else:
|
||||
processed_data[key] = None
|
||||
else:
|
||||
processed_data[key] = value
|
||||
|
||||
return processed_data
|
||||
|
||||
def _get_primary_key_column(self, model) -> Optional[str]:
|
||||
for col in model.__table__.columns:
|
||||
if col.primary_key:
|
||||
return col.name
|
||||
return None
|
||||
|
||||
async def _clear_database_tables(self, db: AsyncSession):
|
||||
tables_order = [
|
||||
"subscription_servers", "sent_notifications",
|
||||
"user_messages", "broadcast_history", "subscription_conversions",
|
||||
"referral_earnings", "promocode_uses", "transactions",
|
||||
"yookassa_payments", "cryptobot_payments", "welcome_texts",
|
||||
"subscriptions", "users", "promocodes", "server_squads",
|
||||
"squads", "service_rules", "system_settings", "monitoring_logs"
|
||||
]
|
||||
|
||||
for table_name in tables_order:
|
||||
try:
|
||||
await db.execute(text(f"DELETE FROM {table_name}"))
|
||||
logger.info(f"🗑️ Очищена таблица {table_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {e}")
|
||||
|
||||
async def get_backup_list(self) -> List[Dict[str, Any]]:
|
||||
backups = []
|
||||
|
||||
try:
|
||||
for backup_file in sorted(self.backup_dir.glob("backup_*.json*"), reverse=True):
|
||||
try:
|
||||
if backup_file.suffix == '.gz':
|
||||
with gzip.open(backup_file, 'rt', encoding='utf-8') as f:
|
||||
backup_structure = json_lib.load(f)
|
||||
else:
|
||||
with open(backup_file, 'r', encoding='utf-8') as f:
|
||||
backup_structure = json_lib.load(f)
|
||||
|
||||
metadata = backup_structure.get("metadata", {})
|
||||
file_stats = backup_file.stat()
|
||||
|
||||
backup_info = {
|
||||
"filename": backup_file.name,
|
||||
"filepath": str(backup_file),
|
||||
"timestamp": metadata.get("timestamp"),
|
||||
"tables_count": metadata.get("tables_count", 0),
|
||||
"total_records": metadata.get("total_records", 0),
|
||||
"compressed": metadata.get("compressed", False),
|
||||
"file_size_bytes": file_stats.st_size,
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": metadata.get("created_by"),
|
||||
"database_type": metadata.get("database_type", "unknown"),
|
||||
"version": metadata.get("version", "1.0")
|
||||
}
|
||||
|
||||
backups.append(backup_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка чтения метаданных {backup_file}: {e}")
|
||||
file_stats = backup_file.stat()
|
||||
backups.append({
|
||||
"filename": backup_file.name,
|
||||
"filepath": str(backup_file),
|
||||
"timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(),
|
||||
"tables_count": "?",
|
||||
"total_records": "?",
|
||||
"compressed": backup_file.suffix == '.gz',
|
||||
"file_size_bytes": file_stats.st_size,
|
||||
"file_size_mb": round(file_stats.st_size / 1024 / 1024, 2),
|
||||
"created_by": None,
|
||||
"database_type": "unknown",
|
||||
"version": "unknown",
|
||||
"error": f"Ошибка чтения: {str(e)}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка бекапов: {e}")
|
||||
|
||||
return backups
|
||||
|
||||
async def delete_backup(self, backup_filename: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
backup_path = self.backup_dir / backup_filename
|
||||
|
||||
if not backup_path.exists():
|
||||
return False, f"❌ Файл бекапа не найден: {backup_filename}"
|
||||
|
||||
backup_path.unlink()
|
||||
message = f"✅ Бекап {backup_filename} удален"
|
||||
logger.info(message)
|
||||
|
||||
return True, message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ Ошибка удаления бекапа: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
async def _cleanup_old_backups(self):
|
||||
try:
|
||||
backups = await self.get_backup_list()
|
||||
|
||||
if len(backups) > self._settings.max_backups_keep:
|
||||
backups.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
|
||||
for backup in backups[self._settings.max_backups_keep:]:
|
||||
try:
|
||||
await self.delete_backup(backup["filename"])
|
||||
logger.info(f"🗑️ Удален старый бекап: {backup['filename']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления старого бекапа {backup['filename']}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки старых бекапов: {e}")
|
||||
|
||||
async def get_backup_settings(self) -> BackupSettings:
|
||||
return self._settings
|
||||
|
||||
async def update_backup_settings(self, **kwargs) -> bool:
|
||||
try:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self._settings, key):
|
||||
setattr(self._settings, key, value)
|
||||
|
||||
if self._settings.auto_backup_enabled:
|
||||
await self.start_auto_backup()
|
||||
else:
|
||||
await self.stop_auto_backup()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления настроек бекапов: {e}")
|
||||
return False
|
||||
|
||||
async def start_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
|
||||
if self._settings.auto_backup_enabled:
|
||||
next_run = self._calculate_next_backup_datetime()
|
||||
interval = self._get_backup_interval()
|
||||
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop(next_run))
|
||||
logger.info(
|
||||
"📄 Автобекапы включены, интервал: %.2fч, ближайший запуск: %s",
|
||||
interval.total_seconds() / 3600,
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S")
|
||||
)
|
||||
|
||||
async def stop_auto_backup(self):
|
||||
if self._auto_backup_task and not self._auto_backup_task.done():
|
||||
self._auto_backup_task.cancel()
|
||||
logger.info("ℹ️ Автобекапы остановлены")
|
||||
|
||||
async def _auto_backup_loop(self, next_run: Optional[datetime] = None):
|
||||
next_run = next_run or self._calculate_next_backup_datetime()
|
||||
interval = self._get_backup_interval()
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now()
|
||||
delay = (next_run - now).total_seconds()
|
||||
|
||||
if delay > 0:
|
||||
logger.info(
|
||||
"⏰ Следующий автоматический бекап запланирован на %s (через %.2f ч)",
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
delay / 3600
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.info(
|
||||
"⏰ Время автоматического бекапа %s уже наступило, запускаем немедленно",
|
||||
next_run.strftime("%d.%m.%Y %H:%M:%S")
|
||||
)
|
||||
|
||||
logger.info("📄 Запуск автоматического бекапа...")
|
||||
success, message, _ = await self.create_backup()
|
||||
|
||||
if success:
|
||||
logger.info(f"✅ Автобекап завершен: {message}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка автобекапа: {message}")
|
||||
|
||||
next_run = next_run + interval
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в цикле автобекапов: {e}")
|
||||
next_run = datetime.now() + interval
|
||||
|
||||
async def _send_backup_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
file_path: str = None
|
||||
):
|
||||
try:
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
return
|
||||
|
||||
icons = {
|
||||
"success": "✅",
|
||||
"error": "❌",
|
||||
"restore_success": "🔥",
|
||||
"restore_error": "❌"
|
||||
}
|
||||
|
||||
icon = icons.get(event_type, "ℹ️")
|
||||
notification_text = f"{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{message}"
|
||||
|
||||
if file_path:
|
||||
notification_text += f"\n📁 <code>{Path(file_path).name}</code>"
|
||||
|
||||
notification_text += f"\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
admin_service = AdminNotificationService(self.bot)
|
||||
await admin_service._send_message(notification_text)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о бекапе: {e}")
|
||||
|
||||
async def _send_backup_file_to_chat(self, file_path: str):
|
||||
try:
|
||||
if not settings.is_backup_send_enabled():
|
||||
return
|
||||
|
||||
chat_id = settings.get_backup_send_chat_id()
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
send_kwargs = {
|
||||
'chat_id': chat_id,
|
||||
'document': FSInputFile(file_path),
|
||||
'caption': (
|
||||
f"📦 <b>Резервная копия</b>\n\n"
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
),
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
if settings.BACKUP_SEND_TOPIC_ID:
|
||||
send_kwargs['message_thread_id'] = settings.BACKUP_SEND_TOPIC_ID
|
||||
|
||||
await self.bot.send_document(**send_kwargs)
|
||||
logger.info(f"Бекап отправлен в чат {chat_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки бекапа в чат: {e}")
|
||||
|
||||
|
||||
backup_service = BackupService()
|
||||
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import record_campaign_registration
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CampaignBonusResult:
|
||||
success: bool
|
||||
bonus_type: Optional[str] = None
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: Optional[int] = None
|
||||
subscription_traffic_gb: Optional[int] = None
|
||||
subscription_device_limit: Optional[int] = None
|
||||
subscription_squads: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AdvertisingCampaignService:
|
||||
def __init__(self) -> None:
|
||||
self.subscription_service = SubscriptionService()
|
||||
|
||||
async def apply_campaign_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
if not campaign.is_active:
|
||||
logger.warning(
|
||||
"⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
if campaign.is_balance_bonus:
|
||||
return await self._apply_balance_bonus(db, user, campaign)
|
||||
|
||||
if campaign.is_subscription_bonus:
|
||||
return await self._apply_subscription_bonus(db, user, campaign)
|
||||
|
||||
logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
async def _apply_balance_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
amount = campaign.balance_bonus_kopeks or 0
|
||||
if amount <= 0:
|
||||
logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
description = f"Бонус за регистрацию по кампании '{campaign.name}'"
|
||||
success = await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if not success:
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="balance",
|
||||
balance_bonus_kopeks=amount,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"💰 Пользователю %s начислен бонус %s₽ по кампании %s",
|
||||
user.telegram_id,
|
||||
amount / 100,
|
||||
campaign.id,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="balance",
|
||||
balance_kopeks=amount,
|
||||
)
|
||||
|
||||
async def _apply_subscription_bonus(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign: AdvertisingCampaign,
|
||||
) -> CampaignBonusResult:
|
||||
existing_subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if existing_subscription:
|
||||
logger.warning(
|
||||
"⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
duration_days = campaign.subscription_duration_days or 0
|
||||
if duration_days <= 0:
|
||||
logger.info(
|
||||
"ℹ️ Кампания %s не содержит корректной длительности подписки",
|
||||
campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
traffic_limit = campaign.subscription_traffic_gb
|
||||
device_limit = (
|
||||
campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
)
|
||||
squads = list(campaign.subscription_squads or [])
|
||||
|
||||
if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
|
||||
squads = [settings.TRIAL_SQUAD_UUID]
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.subscription_service.create_remnawave_user(db, new_subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
|
||||
campaign.id,
|
||||
error,
|
||||
)
|
||||
|
||||
await record_campaign_registration(
|
||||
db,
|
||||
campaign_id=campaign.id,
|
||||
user_id=user.id,
|
||||
bonus_type="subscription",
|
||||
subscription_duration_days=duration_days,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
|
||||
user.telegram_id,
|
||||
campaign.id,
|
||||
duration_days,
|
||||
)
|
||||
|
||||
return CampaignBonusResult(
|
||||
success=True,
|
||||
bonus_type="subscription",
|
||||
subscription_days=duration_days,
|
||||
subscription_traffic_gb=traffic_limit or 0,
|
||||
subscription_device_limit=device_limit,
|
||||
subscription_squads=squads,
|
||||
)
|
||||
@@ -264,7 +264,14 @@ class MaintenanceService:
|
||||
self._is_checking = True
|
||||
self._status.last_check = datetime.utcnow()
|
||||
|
||||
api = RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY)
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
api = RemnaWaveAPI(
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
async with api:
|
||||
is_connected = await test_api_connection(api)
|
||||
|
||||
@@ -158,8 +158,13 @@ class MonitoringService:
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -693,7 +698,9 @@ class MonitoringService:
|
||||
self,
|
||||
db: AsyncSession,
|
||||
limit: int = 50,
|
||||
event_type: Optional[str] = None
|
||||
event_type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = 20
|
||||
) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
@@ -703,7 +710,11 @@ class MonitoringService:
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
query = query.limit(limit)
|
||||
if page > 1 or per_page != 20:
|
||||
offset = (page - 1) * per_page
|
||||
query = query.offset(offset).limit(per_page)
|
||||
else:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
@@ -723,25 +734,54 @@ class MonitoringService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения логов мониторинга: {e}")
|
||||
return []
|
||||
|
||||
async def get_monitoring_logs_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
event_type: Optional[str] = None
|
||||
) -> int:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
query = select(func.count(MonitoringLog.id))
|
||||
|
||||
if event_type:
|
||||
query = query.where(MonitoringLog.event_type == event_type)
|
||||
|
||||
result = await db.execute(query)
|
||||
count = result.scalar()
|
||||
|
||||
return count or 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения количества логов: {e}")
|
||||
return 0
|
||||
|
||||
async def cleanup_old_logs(self, db: AsyncSession, days: int = 30) -> int:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
if days == 0:
|
||||
result = await db.execute(delete(MonitoringLog))
|
||||
else:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(MonitoringLog).where(MonitoringLog.created_at < cutoff_date)
|
||||
)
|
||||
|
||||
deleted_count = result.rowcount
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Удалено {deleted_count} старых записей логов")
|
||||
if days == 0:
|
||||
logger.info(f"🗑️ Удалены все логи мониторинга ({deleted_count} записей)")
|
||||
else:
|
||||
logger.info(f"🗑️ Удалено {deleted_count} старых записей логов (старше {days} дней)")
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка очистки логов: {e}")
|
||||
await db.rollback()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+175
-18
@@ -4,7 +4,7 @@ import hmac
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram.types import LabeledPrice
|
||||
from aiogram.types import LabeledPrice, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -12,10 +12,20 @@ from app.services.yookassa_service import YooKassaService
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import add_user_balance, get_user_by_id
|
||||
from app.database.crud.user import (
|
||||
add_user_balance,
|
||||
get_user_by_id,
|
||||
get_user_by_telegram_id,
|
||||
)
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.database.database import get_db
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,7 +37,49 @@ class PaymentService:
|
||||
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
|
||||
self.stars_service = TelegramStarsService(bot) if bot else None
|
||||
self.cryptobot_service = CryptoBotService() if settings.is_cryptobot_enabled() else None
|
||||
|
||||
|
||||
async def build_topup_success_keyboard(self, user) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(user.language if user else "ru")
|
||||
|
||||
has_active_subscription = (
|
||||
user
|
||||
and user.subscription
|
||||
and not user.subscription.is_trial
|
||||
and user.subscription.is_active
|
||||
)
|
||||
|
||||
first_button = InlineKeyboardButton(
|
||||
text=(
|
||||
texts.MENU_EXTEND_SUBSCRIPTION
|
||||
if has_active_subscription
|
||||
else texts.MENU_BUY_SUBSCRIPTION
|
||||
),
|
||||
callback_data=(
|
||||
"subscription_extend" if has_active_subscription else "menu_buy"
|
||||
),
|
||||
)
|
||||
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = [[first_button]]
|
||||
|
||||
if user:
|
||||
draft_exists = await has_subscription_checkout_draft(user.id)
|
||||
if should_offer_checkout_resume(user, draft_exists):
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data="subscription_resume_checkout",
|
||||
)
|
||||
])
|
||||
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")
|
||||
])
|
||||
keyboard_rows.append([
|
||||
InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
|
||||
async def create_stars_invoice(
|
||||
self,
|
||||
amount_kopeks: int,
|
||||
@@ -118,6 +170,8 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -126,7 +180,8 @@ class PaymentService:
|
||||
f"🦊 Способ: Telegram Stars\n"
|
||||
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}₽"
|
||||
@@ -226,6 +281,94 @@ class PaymentService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}")
|
||||
return None
|
||||
|
||||
async def create_yookassa_sbp_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.yookassa_service:
|
||||
logger.error("YooKassa сервис не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
payment_metadata = metadata or {}
|
||||
payment_metadata.update({
|
||||
"user_id": str(user_id),
|
||||
"amount_kopeks": str(amount_kopeks),
|
||||
"type": "balance_topup_sbp"
|
||||
})
|
||||
|
||||
yookassa_response = await self.yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
metadata=payment_metadata,
|
||||
receipt_email=receipt_email,
|
||||
receipt_phone=receipt_phone
|
||||
)
|
||||
|
||||
if not yookassa_response or yookassa_response.get("error"):
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП: {yookassa_response}")
|
||||
return None
|
||||
|
||||
yookassa_created_at = None
|
||||
if yookassa_response.get("created_at"):
|
||||
try:
|
||||
dt_with_tz = datetime.fromisoformat(
|
||||
yookassa_response["created_at"].replace('Z', '+00:00')
|
||||
)
|
||||
yookassa_created_at = dt_with_tz.replace(tzinfo=None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось парсить created_at: {e}")
|
||||
yookassa_created_at = None
|
||||
|
||||
confirmation_token = None
|
||||
if yookassa_response.get("confirmation"):
|
||||
confirmation_token = yookassa_response["confirmation"].get("confirmation_token")
|
||||
|
||||
if confirmation_token:
|
||||
payment_metadata["confirmation_token"] = confirmation_token
|
||||
|
||||
local_payment = await create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_response["id"],
|
||||
amount_kopeks=amount_kopeks,
|
||||
currency="RUB",
|
||||
description=description,
|
||||
status=yookassa_response["status"],
|
||||
confirmation_url=yookassa_response.get("confirmation_url"),
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type="bank_card",
|
||||
yookassa_created_at=yookassa_created_at,
|
||||
test_mode=yookassa_response.get("test_mode", False)
|
||||
)
|
||||
|
||||
logger.info(f"Создан платеж YooKassa СБП {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"yookassa_payment_id": yookassa_response["id"],
|
||||
"confirmation_url": yookassa_response.get("confirmation_url"),
|
||||
"confirmation_token": confirmation_token,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"amount_rubles": amount_rubles,
|
||||
"status": yookassa_response["status"],
|
||||
"created_at": local_payment.created_at
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}")
|
||||
return None
|
||||
|
||||
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
@@ -309,6 +452,8 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -316,9 +461,12 @@ class PaymentService:
|
||||
f"🦊 Способ: Банковская карта\n"
|
||||
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
@@ -382,20 +530,29 @@ class PaymentService:
|
||||
telegram_id: int,
|
||||
amount_kopeks: int
|
||||
) -> None:
|
||||
|
||||
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!")
|
||||
|
||||
async for db in get_db():
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
break
|
||||
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
message = (
|
||||
f"✅ <b>Платеж успешно завершен!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"💳 Способ: Банковская карта (YooKassa)\n\n"
|
||||
f"Средства зачислены на ваш баланс!"
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
|
||||
@@ -578,10 +735,8 @@ class PaymentService:
|
||||
)
|
||||
|
||||
if not updated_payment.transaction_id:
|
||||
# Получаем сумму в USD из платежа
|
||||
amount_usd = updated_payment.amount_float
|
||||
|
||||
# Конвертируем в рубли по текущему курсу с улучшенной обработкой ошибок
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
@@ -593,7 +748,6 @@ class PaymentService:
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
# Проверяем корректность конвертированной суммы
|
||||
if amount_kopeks <= 0:
|
||||
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
|
||||
return False
|
||||
@@ -641,6 +795,8 @@ class PaymentService:
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -649,7 +805,8 @@ class PaymentService:
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
|
||||
@@ -23,10 +23,13 @@ logger = logging.getLogger(__name__)
|
||||
class RemnaWaveService:
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY,
|
||||
secret_key=settings.REMNAWAVE_SECRET_KEY
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
def _parse_remnawave_date(self, date_str: str) -> datetime:
|
||||
@@ -351,7 +354,7 @@ class RemnaWaveService:
|
||||
|
||||
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'inbounds': inbound_uuids
|
||||
@@ -776,6 +779,11 @@ class RemnaWaveService:
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
stats["updated"] += 1
|
||||
@@ -790,7 +798,11 @@ class RemnaWaveService:
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -873,7 +885,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
squad = await api.get_internal_squad_by_uuid(squad_uuid)
|
||||
if squad:
|
||||
return {
|
||||
@@ -890,7 +902,7 @@ class RemnaWaveService:
|
||||
|
||||
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -899,7 +911,7 @@ class RemnaWaveService:
|
||||
|
||||
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
|
||||
return response.get('response', {}).get('eventSent', False)
|
||||
except Exception as e:
|
||||
@@ -908,7 +920,7 @@ class RemnaWaveService:
|
||||
|
||||
async def delete_squad(self, squad_uuid: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api.delete_internal_squad(squad_uuid)
|
||||
return response
|
||||
except Exception as e:
|
||||
@@ -917,7 +929,7 @@ class RemnaWaveService:
|
||||
|
||||
async def get_all_inbounds(self) -> List[Dict]:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
response = await api._make_request('GET', '/api/config-profiles/inbounds')
|
||||
inbounds_data = response.get('response', {}).get('inbounds', [])
|
||||
|
||||
@@ -938,7 +950,7 @@ class RemnaWaveService:
|
||||
|
||||
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
data = {
|
||||
'uuid': squad_uuid,
|
||||
'name': new_name
|
||||
@@ -951,7 +963,7 @@ class RemnaWaveService:
|
||||
|
||||
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
|
||||
try:
|
||||
async with RemnaWaveAPI(settings.REMNAWAVE_API_URL, settings.REMNAWAVE_API_KEY) as api:
|
||||
async with self.api as api:
|
||||
squad = await api.create_internal_squad(name, inbound_uuids)
|
||||
return squad is not None
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
from typing import Optional
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.cache import UserCache
|
||||
|
||||
|
||||
_CHECKOUT_SESSION_KEY = "subscription_checkout"
|
||||
_CHECKOUT_TTL_SECONDS = 3600
|
||||
|
||||
|
||||
async def save_subscription_checkout_draft(
|
||||
user_id: int, data: dict, ttl: int = _CHECKOUT_TTL_SECONDS
|
||||
) -> bool:
|
||||
"""Persist subscription checkout draft data in cache."""
|
||||
|
||||
return await UserCache.set_user_session(user_id, _CHECKOUT_SESSION_KEY, data, ttl)
|
||||
|
||||
|
||||
async def get_subscription_checkout_draft(user_id: int) -> Optional[dict]:
|
||||
"""Retrieve subscription checkout draft from cache."""
|
||||
|
||||
return await UserCache.get_user_session(user_id, _CHECKOUT_SESSION_KEY)
|
||||
|
||||
|
||||
async def clear_subscription_checkout_draft(user_id: int) -> bool:
|
||||
"""Remove stored subscription checkout draft for the user."""
|
||||
|
||||
return await UserCache.delete_user_session(user_id, _CHECKOUT_SESSION_KEY)
|
||||
|
||||
|
||||
async def has_subscription_checkout_draft(user_id: int) -> bool:
|
||||
draft = await get_subscription_checkout_draft(user_id)
|
||||
return draft is not None
|
||||
|
||||
|
||||
def should_offer_checkout_resume(user: User, has_draft: bool) -> bool:
|
||||
"""
|
||||
Determine whether checkout resume button should be available for the user.
|
||||
|
||||
Only users without an active paid subscription or users currently on trial
|
||||
are eligible to continue assembling the subscription from the saved draft.
|
||||
"""
|
||||
|
||||
if not has_draft:
|
||||
return False
|
||||
|
||||
subscription = getattr(user, "subscription", None)
|
||||
|
||||
if subscription is None:
|
||||
return True
|
||||
|
||||
return bool(getattr(subscription, "is_trial", False))
|
||||
@@ -19,13 +19,32 @@ from app.utils.pricing_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
|
||||
strategy_mapping = {
|
||||
'NO_RESET': 'NO_RESET',
|
||||
'DAY': 'DAY',
|
||||
'WEEK': 'WEEK',
|
||||
'MONTH': 'MONTH'
|
||||
}
|
||||
|
||||
mapped_strategy = strategy_mapping.get(strategy, 'NO_RESET')
|
||||
logger.info(f"🔄 Стратегия сброса трафика из конфига: {strategy} -> {mapped_strategy}")
|
||||
return getattr(TrafficLimitStrategy, mapped_strategy)
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self):
|
||||
auth_params = settings.get_remnawave_auth_params()
|
||||
self.api = RemnaWaveAPI(
|
||||
base_url=settings.REMNAWAVE_API_URL,
|
||||
api_key=settings.REMNAWAVE_API_KEY
|
||||
base_url=auth_params["base_url"],
|
||||
api_key=auth_params["api_key"],
|
||||
secret_key=auth_params["secret_key"],
|
||||
username=auth_params["username"],
|
||||
password=auth_params["password"]
|
||||
)
|
||||
|
||||
async def create_remnawave_user(
|
||||
@@ -62,8 +81,13 @@ class SubscriptionService:
|
||||
status=UserStatus.ACTIVE,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -75,10 +99,14 @@ class SubscriptionService:
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
telegram_id=user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=f"Bot user: {user.full_name}",
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -90,7 +118,8 @@ class SubscriptionService:
|
||||
|
||||
logger.info(f"✅ Создан/обновлен RemnaWave пользователь для подписки {subscription.id}")
|
||||
logger.info(f"🔗 Ссылка на подписку: {updated_user.subscription_url}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
@@ -131,8 +160,13 @@ class SubscriptionService:
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
traffic_limit_strategy=get_traffic_reset_strategy(),
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
@@ -141,7 +175,8 @@ class SubscriptionService:
|
||||
|
||||
status_text = "активным" if is_actually_active else "истёкшим"
|
||||
logger.info(f"✅ Обновлен RemnaWave пользователь {user.remnawave_uuid} со статусом {status_text}")
|
||||
logger.info(f"📊 Стратегия сброса трафика: MONTH")
|
||||
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
|
||||
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
|
||||
return updated_user
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
@@ -265,9 +300,12 @@ class SubscriptionService:
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
@@ -295,9 +333,12 @@ class SubscriptionService:
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
if servers_price > 0:
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
@@ -440,9 +481,16 @@ class SubscriptionService:
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price, server_prices
|
||||
@@ -476,9 +524,18 @@ class SubscriptionService:
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
return total_price
|
||||
|
||||
@@ -5,8 +5,6 @@ from datetime import datetime
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.database.models import Transaction, TransactionType, PaymentMethod
|
||||
@@ -15,6 +13,7 @@ from app.database.crud.transaction import (
|
||||
)
|
||||
from app.database.crud.user import get_user_by_telegram_id, add_user_balance
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -215,10 +214,17 @@ class TributeService:
|
||||
logger.error(f"Ошибка обработки возврата Tribute: {e}")
|
||||
|
||||
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
|
||||
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
break
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
@@ -226,22 +232,18 @@ class TributeService:
|
||||
f"🎉 Средства зачислены на баланс!\n\n"
|
||||
f"Спасибо за оплату! 🙏"
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
])
|
||||
|
||||
|
||||
await self.bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
|
||||
try:
|
||||
|
||||
+248
-95
@@ -3,7 +3,8 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from app.database.crud.user import (
|
||||
get_user_by_id, get_user_by_telegram_id, get_users_list,
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
@@ -12,8 +13,10 @@ from app.database.crud.user import (
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -22,6 +25,59 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class UserService:
|
||||
|
||||
async def _send_balance_notification(
|
||||
self,
|
||||
bot: Bot,
|
||||
user: User,
|
||||
amount_kopeks: int,
|
||||
admin_name: str
|
||||
) -> bool:
|
||||
"""Отправляет уведомление пользователю о пополнении/списании баланса"""
|
||||
try:
|
||||
if amount_kopeks > 0:
|
||||
# Пополнение
|
||||
emoji = "💰"
|
||||
action = "пополнен"
|
||||
amount_text = f"+{settings.format_price(amount_kopeks)}"
|
||||
message = (
|
||||
f"{emoji} <b>Баланс пополнен!</b>\n\n"
|
||||
f"💵 <b>Сумма:</b> {amount_text}\n"
|
||||
f"👤 <b>Администратор:</b> {admin_name}\n"
|
||||
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
f"Спасибо за использование нашего сервиса! 🎉"
|
||||
)
|
||||
else:
|
||||
# Списание
|
||||
emoji = "💸"
|
||||
action = "списан"
|
||||
amount_text = f"-{settings.format_price(abs(amount_kopeks))}"
|
||||
message = (
|
||||
f"{emoji} <b>Средства списаны с баланса</b>\n\n"
|
||||
f"💵 <b>Сумма:</b> {amount_text}\n"
|
||||
f"👤 <b>Администратор:</b> {admin_name}\n"
|
||||
f"💳 <b>Текущий баланс:</b> {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Уведомление о изменении баланса отправлено пользователю {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"⚠️ Пользователь {user.telegram_id} заблокировал бота")
|
||||
return False
|
||||
except TelegramBadRequest as e:
|
||||
logger.error(f"❌ Ошибка Telegram API при отправке уведомления пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при отправке уведомления пользователю {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_user_profile(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@@ -89,13 +145,14 @@ class UserService:
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
status: Optional[UserStatus] = None
|
||||
status: Optional[UserStatus] = None,
|
||||
order_by_balance: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
users = await get_users_list(
|
||||
db, offset=offset, limit=limit, status=status
|
||||
db, offset=offset, limit=limit, status=status, order_by_balance=order_by_balance
|
||||
)
|
||||
total_count = await get_users_count(db, status=status)
|
||||
|
||||
@@ -127,22 +184,41 @@ class UserService:
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
admin_id: int
|
||||
admin_id: int,
|
||||
bot: Optional[Bot] = None,
|
||||
admin_name: Optional[str] = None
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Сохраняем старый баланс для уведомления
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
return True
|
||||
success = True
|
||||
else:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
return success
|
||||
|
||||
# Отправляем уведомление пользователю, если операция прошла успешно
|
||||
if success and bot:
|
||||
# Обновляем пользователя для получения нового баланса
|
||||
await db.refresh(user)
|
||||
|
||||
# Получаем имя администратора
|
||||
if not admin_name:
|
||||
admin_user = await get_user_by_id(db, admin_id)
|
||||
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
|
||||
|
||||
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
|
||||
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
@@ -238,48 +314,124 @@ class UserService:
|
||||
logger.info(f"🗑️ Начинаем полное удаление пользователя {user_id} (Telegram ID: {user.telegram_id})")
|
||||
|
||||
if user.remnawave_uuid:
|
||||
from app.config import settings
|
||||
delete_mode = settings.get_remnawave_user_delete_mode()
|
||||
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
if delete_mode == "delete":
|
||||
# Удаляем пользователя из панели Remnawave
|
||||
async with remnawave_service.api as api:
|
||||
delete_success = await api.delete_user(user.remnawave_uuid)
|
||||
if delete_success:
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
|
||||
else:
|
||||
logger.warning(f"⚠️ Не удалось удалить пользователя {user.remnawave_uuid} из панели Remnawave")
|
||||
else:
|
||||
# Деактивируем пользователя в панели Remnawave
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован (режим: {delete_mode})")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
|
||||
logger.warning(f"⚠️ Ошибка обработки пользователя в Remnawave (режим: {delete_mode}): {e}")
|
||||
# Если основное действие не удалось, попытаемся хотя бы деактивировать
|
||||
if delete_mode == "delete":
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован как fallback")
|
||||
except Exception as fallback_e:
|
||||
logger.error(f"❌ Ошибка деактивации RemnaWave как fallback: {fallback_e}")
|
||||
|
||||
try:
|
||||
from app.database.models import UserMessage
|
||||
from sqlalchemy import update
|
||||
sent_notifications_result = await db.execute(
|
||||
select(SentNotification).where(SentNotification.user_id == user_id)
|
||||
)
|
||||
sent_notifications = sent_notifications_result.scalars().all()
|
||||
|
||||
result = await db.execute(
|
||||
if sent_notifications:
|
||||
logger.info(f"🔄 Удаляем {len(sent_notifications)} уведомлений")
|
||||
await db.execute(
|
||||
delete(SentNotification).where(SentNotification.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления уведомлений: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
subscription_servers_result = await db.execute(
|
||||
select(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
subscription_servers = subscription_servers_result.scalars().all()
|
||||
|
||||
if subscription_servers:
|
||||
logger.info(f"🔄 Удаляем {len(subscription_servers)} связей подписка-сервер")
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления связей подписка-сервер: {e}")
|
||||
|
||||
try:
|
||||
user_messages_result = await db.execute(
|
||||
update(UserMessage)
|
||||
.where(UserMessage.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
|
||||
if user_messages_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
|
||||
|
||||
|
||||
try:
|
||||
from app.database.models import PromoCode
|
||||
from sqlalchemy import update
|
||||
|
||||
result = await db.execute(
|
||||
promocodes_result = await db.execute(
|
||||
update(PromoCode)
|
||||
.where(PromoCode.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
|
||||
if promocodes_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {promocodes_result.rowcount} промокодов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления промокодов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
welcome_texts_result = await db.execute(
|
||||
update(WelcomeText)
|
||||
.where(WelcomeText.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
if welcome_texts_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {welcome_texts_result.rowcount} приветственных текстов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления приветственных текстов: {e}")
|
||||
|
||||
try:
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
@@ -287,18 +439,20 @@ class UserService:
|
||||
|
||||
if yookassa_payments:
|
||||
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
|
||||
await db.execute(
|
||||
update(YooKassaPayment)
|
||||
.where(YooKassaPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ YooKassa платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
from app.database.models import CryptoBotPayment
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
cryptobot_result = await db.execute(
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
@@ -306,14 +460,19 @@ class UserService:
|
||||
|
||||
if cryptobot_payments:
|
||||
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
|
||||
await db.execute(
|
||||
update(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.user_id == user_id)
|
||||
.values(transaction_id=None)
|
||||
)
|
||||
await db.flush()
|
||||
await db.execute(
|
||||
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ CryptoBot платежи удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
@@ -326,49 +485,55 @@ class UserService:
|
||||
delete(Transaction).where(Transaction.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Транзакции удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления транзакций: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
promocode_uses_result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
|
||||
promocode_uses = promocode_uses_result.scalars().all()
|
||||
|
||||
if promocode_uses:
|
||||
logger.info(f"🔄 Удаляем {len(promocode_uses)} использований промокодов")
|
||||
await db.execute(
|
||||
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
referral_earnings_result = await db.execute(
|
||||
select(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
|
||||
referral_earnings = referral_earnings_result.scalars().all()
|
||||
|
||||
if referral_earnings:
|
||||
logger.info(f"🔄 Удаляем {len(referral_earnings)} реферальных доходов")
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
|
||||
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
referral_records_result = await db.execute(
|
||||
select(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
|
||||
referral_records = referral_records_result.scalars().all()
|
||||
|
||||
if referral_records:
|
||||
logger.info(f"🔄 Удаляем {len(referral_records)} записей о рефералах")
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
|
||||
|
||||
logger.error(f"❌ Ошибка удаления записей о рефералах: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import BroadcastHistory
|
||||
await db.execute(
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
from app.database.models import SubscriptionConversion
|
||||
conversions_result = await db.execute(
|
||||
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
@@ -380,46 +545,34 @@ class UserService:
|
||||
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"✅ Записи конверсий удалены")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
|
||||
try:
|
||||
broadcast_history_result = await db.execute(
|
||||
select(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
broadcast_history = broadcast_history_result.scalars().all()
|
||||
|
||||
if broadcast_history:
|
||||
logger.info(f"🔄 Удаляем {len(broadcast_history)} записей истории рассылок")
|
||||
await db.execute(
|
||||
delete(SubscriptionServer).where(
|
||||
SubscriptionServer.subscription_id == user.subscription.id
|
||||
)
|
||||
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
|
||||
|
||||
if user.subscription:
|
||||
try:
|
||||
from app.database.models import Subscription
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
|
||||
|
||||
try:
|
||||
if user.subscription:
|
||||
logger.info(f"🔄 Удаляем подписку {user.subscription.id}")
|
||||
await db.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await db.flush()
|
||||
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
referrals_result = await db.execute(
|
||||
update(User)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.values(referred_by_id=None)
|
||||
)
|
||||
if referrals_result.rowcount > 0:
|
||||
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
|
||||
|
||||
logger.error(f"❌ Ошибка удаления подписки: {e}")
|
||||
|
||||
try:
|
||||
await db.execute(
|
||||
delete(User).where(User.id == user_id)
|
||||
|
||||
@@ -11,7 +11,6 @@ from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YooKassaService:
|
||||
|
||||
def __init__(self,
|
||||
@@ -149,6 +148,110 @@ class YooKassaService:
|
||||
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def create_sbp_payment(
|
||||
self,
|
||||
amount: float,
|
||||
currency: str,
|
||||
description: str,
|
||||
metadata: Dict[str, Any],
|
||||
receipt_email: Optional[str] = None,
|
||||
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.configured:
|
||||
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж через СБП.")
|
||||
return None
|
||||
|
||||
customer_contact_for_receipt = {}
|
||||
if receipt_email:
|
||||
customer_contact_for_receipt["email"] = receipt_email
|
||||
elif receipt_phone:
|
||||
customer_contact_for_receipt["phone"] = receipt_phone
|
||||
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
|
||||
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
else:
|
||||
logger.error(
|
||||
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
|
||||
return {
|
||||
"error": True,
|
||||
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
|
||||
}
|
||||
|
||||
try:
|
||||
builder = PaymentRequestBuilder()
|
||||
|
||||
builder.set_amount({
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
})
|
||||
|
||||
builder.set_capture(True)
|
||||
|
||||
builder.set_confirmation({
|
||||
"type": "redirect",
|
||||
"return_url": self.return_url
|
||||
})
|
||||
|
||||
builder.set_description(description)
|
||||
|
||||
builder.set_metadata(metadata)
|
||||
|
||||
builder.set_payment_method_data({
|
||||
"type": "sbp"
|
||||
})
|
||||
|
||||
receipt_items_list: List[Dict[str, Any]] = [{
|
||||
"description": description[:128],
|
||||
"quantity": "1.00",
|
||||
"amount": {
|
||||
"value": str(round(amount, 2)),
|
||||
"currency": currency.upper()
|
||||
},
|
||||
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
|
||||
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
|
||||
}]
|
||||
|
||||
receipt_data_dict: Dict[str, Any] = {
|
||||
"customer": customer_contact_for_receipt,
|
||||
"items": receipt_items_list
|
||||
}
|
||||
|
||||
builder.set_receipt(receipt_data_dict)
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
payment_request = builder.build()
|
||||
|
||||
logger.info(
|
||||
f"Создание платежа YooKassa СБП (Idempotence-Key: {idempotence_key}). "
|
||||
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
|
||||
|
||||
logger.info(
|
||||
f"Ответ YooKassa Payment.create (СБП): ID={response.id}, Status={response.status}, Paid={response.paid}")
|
||||
|
||||
return {
|
||||
"id": response.id,
|
||||
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
|
||||
"status": response.status,
|
||||
"metadata": response.metadata,
|
||||
"amount_value": float(response.amount.value),
|
||||
"amount_currency": response.amount.currency,
|
||||
"idempotence_key_used": idempotence_key,
|
||||
"paid": response.paid,
|
||||
"refundable": response.refundable,
|
||||
"created_at": response.created_at.isoformat() if hasattr(
|
||||
response.created_at, 'isoformat') else str(response.created_at),
|
||||
"description_from_yk": response.description,
|
||||
"test_mode": response.test if hasattr(response, 'test') else None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания платежа YooKassa СБП: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_payment_info(
|
||||
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
+32
-6
@@ -1,11 +1,9 @@
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class RegistrationStates(StatesGroup):
|
||||
waiting_for_rules_accept = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class SubscriptionStates(StatesGroup):
|
||||
selecting_period = State()
|
||||
selecting_traffic = State()
|
||||
@@ -18,7 +16,7 @@ class SubscriptionStates(StatesGroup):
|
||||
adding_devices = State()
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
cart_saved_for_topup = State()
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
@@ -30,7 +28,6 @@ class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
@@ -45,8 +42,26 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_value = State()
|
||||
setting_promocode_uses = State()
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
creating_campaign_name = State()
|
||||
creating_campaign_start = State()
|
||||
creating_campaign_bonus = State()
|
||||
creating_campaign_balance = State()
|
||||
creating_campaign_subscription_days = State()
|
||||
creating_campaign_subscription_traffic = State()
|
||||
creating_campaign_subscription_devices = State()
|
||||
creating_campaign_subscription_servers = State()
|
||||
|
||||
editing_campaign_name = State()
|
||||
editing_campaign_start = State()
|
||||
editing_campaign_balance = State()
|
||||
editing_campaign_subscription_days = State()
|
||||
editing_campaign_subscription_traffic = State()
|
||||
editing_campaign_subscription_devices = State()
|
||||
editing_campaign_subscription_servers = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
@@ -69,12 +84,16 @@ class AdminStates(StatesGroup):
|
||||
creating_server_name = State()
|
||||
creating_server_price = State()
|
||||
creating_server_country = State()
|
||||
|
||||
|
||||
editing_welcome_text = State()
|
||||
waiting_for_message_buttons = "waiting_for_message_buttons"
|
||||
|
||||
# Состояния для отслеживания источника перехода
|
||||
viewing_user_from_balance_list = State()
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
@@ -85,3 +104,10 @@ class SquadCreateStates(StatesGroup):
|
||||
|
||||
class SquadRenameStates(StatesGroup):
|
||||
waiting_for_new_name = State()
|
||||
|
||||
class AdminSubmenuStates(StatesGroup):
|
||||
in_users_submenu = State()
|
||||
in_promo_submenu = State()
|
||||
in_communications_submenu = State()
|
||||
in_settings_submenu = State()
|
||||
in_system_submenu = State()
|
||||
|
||||
+8
-3
@@ -203,14 +203,19 @@ class UserCache:
|
||||
|
||||
@staticmethod
|
||||
async def set_user_session(
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
user_id: int,
|
||||
session_key: str,
|
||||
data: Any,
|
||||
expire: int = 1800
|
||||
) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.set(key, data, expire)
|
||||
|
||||
@staticmethod
|
||||
async def delete_user_session(user_id: int, session_key: str) -> bool:
|
||||
key = cache_key("session", user_id, session_key)
|
||||
return await cache.delete(key)
|
||||
|
||||
|
||||
class SystemCache:
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import TelegramObject, Message, CallbackQuery
|
||||
|
||||
from app.states import RegistrationStates
|
||||
|
||||
|
||||
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
registration_callbacks = [
|
||||
"rules_accept",
|
||||
"rules_decline",
|
||||
"referral_skip"
|
||||
]
|
||||
|
||||
if current_state in registration_states:
|
||||
return True
|
||||
|
||||
if (isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and event.data in registration_callbacks):
|
||||
return True
|
||||
|
||||
return False
|
||||
+78
-25
@@ -3,6 +3,7 @@ import functools
|
||||
from typing import Callable, Any
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
@@ -25,10 +26,16 @@ def admin_required(func: Callable) -> Callable:
|
||||
if not user or not settings.is_admin(user.id):
|
||||
texts = get_texts()
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
try:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ACCESS_DENIED)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
except TelegramBadRequest as e:
|
||||
if "query is too old" in str(e).lower():
|
||||
logger.warning(f"Попытка ответить на устаревший callback query от {user.id if user else 'Unknown'}")
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.warning(f"Попытка доступа к админской функции от {user.id if user else 'Unknown'}")
|
||||
return
|
||||
@@ -44,28 +51,71 @@ def error_handler(func: Callable) -> Callable:
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except TelegramBadRequest as e:
|
||||
error_message = str(e).lower()
|
||||
|
||||
if "query is too old" in error_message or "query id is invalid" in error_message:
|
||||
event = _extract_event(args)
|
||||
if event and isinstance(event, types.CallbackQuery):
|
||||
user_info = f"@{event.from_user.username}" if event.from_user.username else f"ID:{event.from_user.id}"
|
||||
logger.warning(f"🕐 Игнорируем устаревший callback '{event.data}' от {user_info} в {func.__name__}")
|
||||
else:
|
||||
logger.warning(f"🕐 Игнорируем устаревший запрос в {func.__name__}: {e}")
|
||||
return None
|
||||
|
||||
elif "message is not modified" in error_message:
|
||||
logger.debug(f"📝 Сообщение не изменено в {func.__name__}")
|
||||
event = _extract_event(args)
|
||||
if event and isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except TelegramBadRequest as answer_error:
|
||||
if "query is too old" not in str(answer_error).lower():
|
||||
logger.error(f"Ошибка при ответе на callback: {answer_error}")
|
||||
return None
|
||||
|
||||
else:
|
||||
logger.error(f"Telegram API error в {func.__name__}: {e}")
|
||||
await _send_error_message(args, kwargs, e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в {func.__name__}: {e}", exc_info=True)
|
||||
|
||||
event = None
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
event = arg
|
||||
break
|
||||
|
||||
if event:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
await _send_error_message(args, kwargs, e)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _extract_event(args) -> types.TelegramObject:
|
||||
for arg in args:
|
||||
if isinstance(arg, (types.Message, types.CallbackQuery)):
|
||||
return arg
|
||||
return None
|
||||
|
||||
|
||||
async def _send_error_message(args, kwargs, original_error):
|
||||
try:
|
||||
event = _extract_event(args)
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not event:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(texts.ERROR)
|
||||
elif isinstance(event, types.CallbackQuery):
|
||||
await event.answer(texts.ERROR, show_alert=True)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
if "query is too old" in str(e).lower():
|
||||
logger.warning("Не удалось отправить сообщение об ошибке - callback query устарел")
|
||||
else:
|
||||
logger.error(f"Ошибка при отправке сообщения об ошибке: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка при отправке сообщения об ошибке: {e}")
|
||||
|
||||
|
||||
def state_cleanup(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
@@ -91,10 +141,13 @@ def typing_action(func: Callable) -> Callable:
|
||||
**kwargs
|
||||
) -> Any:
|
||||
if isinstance(event, types.Message):
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
try:
|
||||
await event.bot.send_chat_action(
|
||||
chat_id=event.chat.id,
|
||||
action="typing"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось отправить typing action: {e}")
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
@@ -114,4 +167,4 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
return decorator
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
|
||||
|
||||
def is_qr_message(message: Message) -> bool:
|
||||
return bool(message.caption and message.caption.startswith("\U0001F517 Ваша реферальная ссылка"))
|
||||
|
||||
|
||||
_original_answer = Message.answer
|
||||
_original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
if LOGO_PATH.exists():
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
|
||||
async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
if self.photo:
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
elif is_qr_message(self) and LOGO_PATH.exists():
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
|
||||
|
||||
def patch_message_methods():
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return
|
||||
Message.answer = _answer_with_photo
|
||||
Message.edit_text = _edit_with_photo
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from app.config import settings
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
def get_available_payment_methods() -> List[Dict[str, str]]:
|
||||
"""
|
||||
Возвращает список доступных способов оплаты с их настройками
|
||||
"""
|
||||
methods = []
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
methods.append({
|
||||
"id": "stars",
|
||||
"name": "Telegram Stars",
|
||||
"icon": "⭐",
|
||||
"description": "быстро и удобно",
|
||||
"callback": "topup_stars"
|
||||
})
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
methods.append({
|
||||
"id": "yookassa",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через YooKassa",
|
||||
"callback": "topup_yookassa"
|
||||
})
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
methods.append({
|
||||
"id": "tribute",
|
||||
"name": "Банковская карта",
|
||||
"icon": "💳",
|
||||
"description": "через Tribute",
|
||||
"callback": "topup_tribute"
|
||||
})
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
methods.append({
|
||||
"id": "cryptobot",
|
||||
"name": "Криптовалюта",
|
||||
"icon": "🪙",
|
||||
"description": "через CryptoBot",
|
||||
"callback": "topup_cryptobot"
|
||||
})
|
||||
|
||||
# Поддержка всегда доступна
|
||||
methods.append({
|
||||
"id": "support",
|
||||
"name": "Через поддержку",
|
||||
"icon": "🛠️",
|
||||
"description": "другие способы",
|
||||
"callback": "topup_support"
|
||||
})
|
||||
|
||||
return methods
|
||||
|
||||
def get_payment_methods_text(language: str) -> str:
|
||||
"""
|
||||
Генерирует текст с описанием доступных способов оплаты
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
methods = get_available_payment_methods()
|
||||
|
||||
if len(methods) <= 1: # Только поддержка
|
||||
return texts.t(
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT",
|
||||
"""💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент автоматические способы оплаты временно недоступны.
|
||||
Обратитесь в техподдержку для пополнения баланса.
|
||||
|
||||
Выберите способ пополнения:""",
|
||||
)
|
||||
|
||||
text = texts.t(
|
||||
"PAYMENT_METHODS_TITLE",
|
||||
"💳 <b>Способы пополнения баланса</b>",
|
||||
) + "\n\n"
|
||||
text += texts.t(
|
||||
"PAYMENT_METHODS_PROMPT",
|
||||
"Выберите удобный для вас способ оплаты:",
|
||||
) + "\n\n"
|
||||
|
||||
for method in methods:
|
||||
method_id = method['id'].upper()
|
||||
name = texts.t(
|
||||
f"PAYMENT_METHOD_{method_id}_NAME",
|
||||
f"{method['icon']} <b>{method['name']}</b>",
|
||||
)
|
||||
description = texts.t(
|
||||
f"PAYMENT_METHOD_{method_id}_DESCRIPTION",
|
||||
method['description'],
|
||||
)
|
||||
text += f"{name} - {description}\n"
|
||||
|
||||
text += "\n" + texts.t(
|
||||
"PAYMENT_METHODS_FOOTER",
|
||||
"Выберите способ пополнения:",
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
def is_payment_method_available(method_id: str) -> bool:
|
||||
"""
|
||||
Проверяет, доступен ли конкретный способ оплаты
|
||||
"""
|
||||
if method_id == "stars":
|
||||
return settings.TELEGRAM_STARS_ENABLED
|
||||
elif method_id == "yookassa":
|
||||
return settings.is_yookassa_enabled()
|
||||
elif method_id == "tribute":
|
||||
return settings.TRIBUTE_ENABLED
|
||||
elif method_id == "cryptobot":
|
||||
return settings.is_cryptobot_enabled()
|
||||
elif method_id == "support":
|
||||
return True # Поддержка всегда доступна
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_payment_method_status() -> Dict[str, bool]:
|
||||
"""
|
||||
Возвращает статус всех способов оплаты
|
||||
"""
|
||||
return {
|
||||
"stars": settings.TELEGRAM_STARS_ENABLED,
|
||||
"yookassa": settings.is_yookassa_enabled(),
|
||||
"tribute": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot": settings.is_cryptobot_enabled(),
|
||||
"support": True
|
||||
}
|
||||
|
||||
def get_enabled_payment_methods_count() -> int:
|
||||
"""
|
||||
Возвращает количество включенных способов оплаты (не считая поддержку)
|
||||
"""
|
||||
count = 0
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
count += 1
|
||||
if settings.is_yookassa_enabled():
|
||||
count += 1
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
count += 1
|
||||
if settings.is_cryptobot_enabled():
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,63 @@
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
from .message_patch import LOGO_PATH, is_qr_message
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and not is_qr_message(message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Только если режим логотипа выключен, используем фото из сообщения
|
||||
elif message.photo:
|
||||
return message.photo[-1].file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
async def edit_or_answer_photo(
|
||||
callback: types.CallbackQuery,
|
||||
caption: str,
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
+20
-14
@@ -3,7 +3,7 @@ import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy import select, func, and_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
@@ -28,25 +28,31 @@ async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> s
|
||||
return f"ref{timestamp}"
|
||||
|
||||
|
||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
|
||||
async def mark_user_as_had_paid_subscription(db: AsyncSession, user: User) -> bool:
|
||||
try:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
if user.has_had_paid_subscription:
|
||||
logger.debug(f"Пользователь {user.id} уже отмечен как имевший платную подписку")
|
||||
return True
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
if not user.has_had_paid_subscription:
|
||||
user.has_had_paid_subscription = True
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
has_had_paid_subscription=True,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"✅ Пользователь {user.id} отмечен как имевший платную подписку")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
|
||||
logger.error(f"Ошибка отметки пользователя {user.id} как имевшего платную подписку: {e}")
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.error(f"Ошибка отката транзакции: {rollback_error}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+86
-18
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
@@ -14,6 +14,11 @@ ALLOWED_HTML_TAGS = {
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
SELF_CLOSING_TAGS = {
|
||||
'br', 'hr', 'img'
|
||||
}
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
@@ -158,7 +163,8 @@ def validate_referral_code(code: str) -> bool:
|
||||
|
||||
return validate_promocode(code)
|
||||
|
||||
def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
|
||||
def validate_html_tags(text: str) -> Tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
@@ -168,21 +174,34 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS:
|
||||
if tag_name_lower not in ALLOWED_HTML_TAGS and tag_name_lower not in SELF_CLOSING_TAGS:
|
||||
return False, f"Неподдерживаемый тег: <{tag_name}>"
|
||||
|
||||
return validate_html_structure(text)
|
||||
|
||||
|
||||
def validate_html_structure(text: str) -> Tuple[bool, str]:
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?/?>'
|
||||
|
||||
matches = re.finditer(tag_pattern, text)
|
||||
tag_stack = []
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
for match in matches:
|
||||
full_tag = match.group(0)
|
||||
is_closing = bool(match.group(1))
|
||||
tag_name = match.group(2).lower()
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name_lower)
|
||||
else:
|
||||
if full_tag.endswith('/>') or tag_name in SELF_CLOSING_TAGS:
|
||||
continue
|
||||
|
||||
if not is_closing:
|
||||
tag_stack.append(tag_name)
|
||||
else:
|
||||
if not tag_stack:
|
||||
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
|
||||
|
||||
last_tag = tag_stack.pop()
|
||||
if last_tag != tag_name_lower:
|
||||
if last_tag != tag_name:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
@@ -191,16 +210,65 @@ def validate_html_tags(text: str) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def fix_html_tags(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
|
||||
fixes = [
|
||||
(r'<a href=([^"\s>]+)>', r'<a href="\1">'),
|
||||
(r'<(br|hr|img[^>]*?)>', r'<\1 />'),
|
||||
(r'<<([^>]+)>>', r'<\1>'),
|
||||
(r'<\s+([^>]+)\s+>', r'<\1>'),
|
||||
]
|
||||
|
||||
result = text
|
||||
for pattern, replacement in fixes:
|
||||
result = re.sub(pattern, replacement, result, flags=re.IGNORECASE)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_html_help_text() -> str:
|
||||
return """<b>Поддерживаемые HTML теги:</b>
|
||||
|
||||
- <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
- <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
- <code><u>подчеркнутый</u></code>
|
||||
- <code><s>зачеркнутый</s></code>
|
||||
- <code><code>моноширинный</code></code>
|
||||
- <code><pre>блок кода</pre></code>
|
||||
- <code><a href="url">ссылка</a></code>
|
||||
- <code><blockquote>цитата</blockquote></code>
|
||||
• <code><b>жирный</b></code> или <code><strong>жирный</strong></code>
|
||||
• <code><i>курсив</i></code> или <code><em>курсив</em></code>
|
||||
• <code><u>подчеркнутый</u></code>
|
||||
• <code><s>зачеркнутый</s></code>
|
||||
• <code><code>моноширинный</code></code>
|
||||
• <code><pre>блок кода</pre></code>
|
||||
• <code><a href="url">ссылка</a></code>
|
||||
• <code><blockquote>цитата</blockquote></code>
|
||||
|
||||
<b>Неподдерживаемые теги:</b> <br>, <p>, <div>, <span>, <spoiler> и другие"""
|
||||
<b>⚠️ Важные правила:</b>
|
||||
• Каждый открывающий тег должен быть закрыт
|
||||
• Теги должны быть правильно вложены
|
||||
• Атрибуты ссылок берите в кавычки
|
||||
|
||||
<b>❌ Неправильно:</b>
|
||||
<code><b>жирный <i>курсив</b></i></code>
|
||||
<code><a href=google.com>ссылка</a></code>
|
||||
|
||||
<b>✅ Правильно:</b>
|
||||
<code><b>жирный <i>курсив</i></b></code>
|
||||
<code><a href="https://google.com">ссылка</a></code>"""
|
||||
|
||||
|
||||
def validate_rules_content(text: str) -> Tuple[bool, str, Optional[str]]:
|
||||
if not text or not text.strip():
|
||||
return False, "Текст правил не может быть пустым", None
|
||||
|
||||
if len(text) > 4000:
|
||||
return False, f"Текст слишком длинный: {len(text)} символов (максимум 4000)", None
|
||||
|
||||
is_valid_html, html_error = validate_html_tags(text)
|
||||
if not is_valid_html:
|
||||
fixed_text = fix_html_tags(text)
|
||||
fixed_is_valid, _ = validate_html_tags(fixed_text)
|
||||
|
||||
if fixed_is_valid and fixed_text != text:
|
||||
return False, html_error, fixed_text
|
||||
else:
|
||||
return False, html_error, None
|
||||
|
||||
return True, "", None
|
||||
|
||||
@@ -57,16 +57,20 @@ services:
|
||||
REDIS_URL: "redis://redis:6379/0"
|
||||
|
||||
TZ: "Europe/Moscow"
|
||||
LOCALES_PATH: "${LOCALES_PATH:-/app/locales}"
|
||||
volumes:
|
||||
# Логи
|
||||
- ./logs:/app/logs:rw
|
||||
# Данные приложения (для SQLite в случае переключения)
|
||||
- ./data:/app/data:rw
|
||||
- ./locales:/app/locales:rw
|
||||
# Конфигурация приложения
|
||||
# - ./app-config.json:/app/app-config.json:ro
|
||||
# Timezone
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
# Логотип для сообщений
|
||||
- ./vpn_logo.png:/app/vpn_logo.png:ro
|
||||
ports:
|
||||
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
|
||||
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
|
||||
|
||||
@@ -17,6 +17,8 @@ from app.services.version_service import version_service
|
||||
from app.external.webhook_server import WebhookServer
|
||||
from app.external.yookassa_webhook import start_yookassa_webhook_server
|
||||
from app.database.universal_migration import run_universal_migration
|
||||
from app.services.backup_service import backup_service
|
||||
from app.localization.loader import ensure_locale_templates
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -42,6 +44,11 @@ async def main():
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
|
||||
|
||||
try:
|
||||
ensure_locale_templates()
|
||||
except Exception as error:
|
||||
logger.warning("Failed to prepare locale templates: %s", error)
|
||||
|
||||
killer = GracefulExit()
|
||||
signal.signal(signal.SIGINT, killer.exit_gracefully)
|
||||
signal.signal(signal.SIGTERM, killer.exit_gracefully)
|
||||
@@ -85,19 +92,40 @@ async def main():
|
||||
admin_notification_service = AdminNotificationService(bot)
|
||||
version_service.bot = bot
|
||||
version_service.set_notification_service(admin_notification_service)
|
||||
logger.info(f"🔄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📄 Сервис версий настроен для репозитория: {version_service.repo}")
|
||||
logger.info(f"📦 Текущая версия: {version_service.current_version}")
|
||||
|
||||
logger.info("🔗 Бот подключен к сервисам мониторинга и техработ")
|
||||
|
||||
logger.info("🗄️ Инициализация сервиса бекапов...")
|
||||
try:
|
||||
backup_service.bot = bot
|
||||
|
||||
settings_obj = await backup_service.get_backup_settings()
|
||||
if settings_obj.auto_backup_enabled:
|
||||
await backup_service.start_auto_backup()
|
||||
logger.info("✅ Автобекапы запущены")
|
||||
|
||||
logger.info("✅ Сервис бекапов инициализирован")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка инициализации сервиса бекапов: {e}")
|
||||
|
||||
payment_service = PaymentService(bot)
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info("🌐 Запуск Tribute webhook сервера...")
|
||||
webhook_needed = settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()
|
||||
|
||||
if webhook_needed:
|
||||
enabled_services = []
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
enabled_services.append("Tribute")
|
||||
if settings.is_cryptobot_enabled():
|
||||
enabled_services.append("CryptoBot")
|
||||
|
||||
logger.info(f"🌐 Запуск webhook сервера для: {', '.join(enabled_services)}...")
|
||||
webhook_server = WebhookServer(bot)
|
||||
await webhook_server.start()
|
||||
else:
|
||||
logger.info("ℹ️ Tribute отключен, webhook сервер не запускается")
|
||||
logger.info("ℹ️ Tribute и CryptoBot отключены, webhook сервер не запускается")
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info("💳 Запуск YooKassa webhook сервера...")
|
||||
@@ -119,7 +147,7 @@ async def main():
|
||||
maintenance_task = None
|
||||
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("🔄 Запуск сервиса проверки версий...")
|
||||
logger.info("📄 Запуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
@@ -129,11 +157,14 @@ async def main():
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("🎯 Активные webhook endpoints:")
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if webhook_needed:
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" CryptoBot: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
if settings.is_yookassa_enabled():
|
||||
logger.info(f" YooKassa: {settings.WEBHOOK_URL}:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
|
||||
logger.info("🔄 Активные фоновые сервисы:")
|
||||
logger.info("📄 Активные фоновые сервисы:")
|
||||
logger.info(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
@@ -221,6 +252,12 @@ async def main():
|
||||
await version_check_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки сервиса бекапов: {e}")
|
||||
|
||||
if polling_task and not polling_task.done():
|
||||
logger.info("ℹ️ Остановка polling...")
|
||||
@@ -231,7 +268,7 @@ async def main():
|
||||
pass
|
||||
|
||||
if webhook_server:
|
||||
logger.info("ℹ️ Остановка Tribute webhook сервера...")
|
||||
logger.info("ℹ️ Остановка webhook сервера...")
|
||||
await webhook_server.stop()
|
||||
|
||||
if 'bot' in locals():
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""add advertising campaigns tables"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "5d1f1f8b2e9a"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"advertising_campaigns",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_start_parameter",
|
||||
"advertising_campaigns",
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_id",
|
||||
"advertising_campaigns",
|
||||
["id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"advertising_campaign_registrations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaign_registrations_id",
|
||||
"advertising_campaign_registrations",
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations")
|
||||
op.drop_table("advertising_campaign_registrations")
|
||||
op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns")
|
||||
op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns")
|
||||
op.drop_table("advertising_campaigns")
|
||||
+15
-12
@@ -1,31 +1,34 @@
|
||||
# Основные зависимости
|
||||
aiogram==3.7.0
|
||||
aiohttp==3.9.1
|
||||
asyncpg==0.29.0
|
||||
SQLAlchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
aiosqlite==0.19.0
|
||||
aiogram==3.22.0
|
||||
aiohttp==3.12.15
|
||||
asyncpg==0.30.0
|
||||
SQLAlchemy==2.0.43
|
||||
alembic==1.16.5
|
||||
aiosqlite==0.21.0
|
||||
|
||||
# Дополнительные зависимости
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.11.9
|
||||
pydantic-settings==2.10.1
|
||||
python-dotenv==1.1.1
|
||||
redis==5.0.1
|
||||
PyYAML==6.0.2
|
||||
|
||||
# YooKassa SDK
|
||||
yookassa==3.0.0
|
||||
yookassa==3.7.0
|
||||
|
||||
# Логирование и мониторинг
|
||||
structlog==23.2.0
|
||||
|
||||
# Планировщик задач для техработ
|
||||
APScheduler==3.10.4
|
||||
APScheduler==3.11.0
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.8.2
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2023.4
|
||||
cryptography>=41.0.0
|
||||
qrcode[pil]==7.4.2
|
||||
|
||||
# Для работы с версиями
|
||||
packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 716 KiB |
Reference in New Issue
Block a user