Compare commits
527 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ef7b1d107 | |||
| 0cb9ab5704 | |||
| 4d109b948a | |||
| a2c0551349 | |||
| a66719b865 | |||
| b201543014 | |||
| e269f31056 | |||
| 0c1db66be6 | |||
| 5e46507f04 | |||
| f3616abec2 | |||
| 86ec46dbe7 | |||
| 863b5df0c2 | |||
| 929b3b8e95 | |||
| f79393c46e | |||
| 559dd1df71 | |||
| cb959bdee7 | |||
| ac1b0ec8b3 | |||
| 03856d30ec | |||
| cd98d150d6 | |||
| 92814ae535 | |||
| 3268c2a509 | |||
| a09b6c1431 | |||
| 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 | |||
| 16ae480430 | |||
| 0b222c06a1 | |||
| ff340e90d6 | |||
| 4125141d01 | |||
| 2f40ad53b5 | |||
| 2d3674eff7 | |||
| 560e60b856 | |||
| 7cf20234b8 | |||
| ecab0a0f45 | |||
| 587722ed5c | |||
| bd7d9b3d5e | |||
| 60787a2aa9 | |||
| 9675ac3c14 | |||
| ca9a2f5997 | |||
| 652eb4f5a7 | |||
| 6ffbad747d | |||
| af46c596a9 | |||
| 1fcc218f26 | |||
| 39a5d674c9 | |||
| bdfe6ae1cf | |||
| 06e6c65ada | |||
| 8a7e4ff1c9 | |||
| f0a1e1c338 | |||
| 843af67483 | |||
| 80d251c2d1 | |||
| a1c2907cce | |||
| c1369f113c | |||
| 8ea5bc5330 | |||
| 285dda4795 | |||
| fae423177b | |||
| 51bf43289d | |||
| 3fd87eee9a | |||
| ad14ab5d08 | |||
| aab68767fd | |||
| 84f10229da | |||
| 3a6ce31103 | |||
| da7675a853 | |||
| 09726f8df0 | |||
| 8072ce6954 | |||
| 1e870e49c8 | |||
| eeca712a3e | |||
| 29c3e455e4 | |||
| c23a777783 | |||
| 0439279602 | |||
| 27c81d451b | |||
| 339881f288 | |||
| ef6bfb8ea3 | |||
| 4bd33fbbe2 | |||
| 8a5f097808 | |||
| 239eb9705d | |||
| fee141ea2b | |||
| ec7c121e03 | |||
| 8b047fd18c | |||
| 0c04868bef | |||
| deaf16e6ae | |||
| 389cac3e0a | |||
| c04968818f | |||
| 3a391d79d2 | |||
| d1a8019c31 | |||
| 1c211b0a41 | |||
| 7c1ba6d92b | |||
| c0953eecf3 | |||
| 7f70826023 | |||
| a7aaa6fc88 | |||
| a72b3bf705 | |||
| 71656df0bd | |||
| 7d5cff0aa9 | |||
| 4e27396a09 | |||
| 52a72a11b0 | |||
| c17886489b | |||
| ade2f106ba | |||
| a096b2bc1a | |||
| de65390680 | |||
| bd5f92c121 | |||
| ad53a6bc09 | |||
| f3b2c8056e | |||
| 1e90903d54 | |||
| 8537875537 | |||
| a988e562a0 | |||
| d014a54181 | |||
| 4e5d57bf92 | |||
| 6f8ebbb9f0 | |||
| 131ac32596 | |||
| 56017d023c | |||
| 3afdc6cd31 | |||
| 52a89eac77 | |||
| 0cdd311832 | |||
| 1d5b52ef5b | |||
| 3f37be4509 | |||
| 1636af1585 | |||
| 127409955c | |||
| 423ce5e41b | |||
| 770b7e8358 | |||
| 0daf08ceed | |||
| 1cda2259c6 | |||
| 5879080f33 | |||
| cd3ad8bff9 | |||
| 033a748069 | |||
| de890dbd7f | |||
| 7c5ae09a6e | |||
| e587e3b087 | |||
| d698b3cfe7 | |||
| a01680fa2d | |||
| 531fc0fb22 | |||
| bb8e513598 | |||
| f56679cd9f | |||
| 9885e050a1 | |||
| 68be5cbe4b | |||
| 3315c83e0b | |||
| ca9b6f4063 | |||
| cc490b616f | |||
| 988f9eb4eb | |||
| c41eeb2c8b | |||
| 881963ca7b | |||
| db6733083c | |||
| 77851b090a | |||
| e0ada505aa | |||
| 3179f33b67 | |||
| 9bbd72b65f | |||
| 68ace1358b | |||
| f7934fd0fe | |||
| 15cbbc04ce | |||
| d4260106cb | |||
| 0a46bd18db | |||
| 0ccbad4077 | |||
| 013ce09846 | |||
| 736ec95572 | |||
| bddf36261d | |||
| cbd74049dd | |||
| 84f3db4548 | |||
| c07ccf0daf | |||
| 3ff4906214 | |||
| 1bc5133c95 | |||
| dcbad389b3 | |||
| dfea057a25 | |||
| 3a6bf029d1 | |||
| 667041558e | |||
| 01e51f2dfd | |||
| 483971d12f | |||
| fad93dab53 | |||
| ff13b7685b | |||
| 4281701957 | |||
| a0ff5dbcd4 | |||
| ed9ac80e68 | |||
| e9662b9b42 | |||
| ba3fc35a14 | |||
| 18471f0162 | |||
| 71073fd0e5 | |||
| 2e1ee6bb28 | |||
| 239f14acaf | |||
| e87a62c180 | |||
| cb275fc4b6 | |||
| 4042dee884 | |||
| 2d3dc8e024 | |||
| 530759fc81 | |||
| 0f29850d7f | |||
| e060c39790 | |||
| 612d7ad529 | |||
| a310e88905 | |||
| bda959e8d6 | |||
| bd9d12c185 | |||
| adf509dc81 | |||
| 22f5fc43dd | |||
| 49b10b2d4a | |||
| 2f218b7567 | |||
| 1bb0894894 | |||
| 39da15fec2 | |||
| 9ff66b9650 | |||
| c5353b46b1 | |||
| 25aa214ffa | |||
| 28279b7ec2 | |||
| 126170bee7 | |||
| 8ce7935d49 | |||
| f756c9a7f9 | |||
| a59905b98f | |||
| f0d74185a1 | |||
| 06937e026f | |||
| 65acaf7058 | |||
| 7fc574daf3 | |||
| a93eb8d2d1 | |||
| 9fbd15f700 | |||
| cec96d866c | |||
| 80031306a4 | |||
| 9783781bfb | |||
| aba7302833 | |||
| 61ce10c52e |
@@ -0,0 +1,46 @@
|
||||
# Git files
|
||||
.git
|
||||
.gitignore
|
||||
.github/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Development files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Docker files (исключаем из копирования в образ)
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
docker-compose*.yml
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
@@ -12,24 +12,57 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
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
|
||||
# DATABASE_URL=postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
# Обязательная подписка на канал
|
||||
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=
|
||||
# Для панелей установленных скриптом 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
|
||||
|
||||
# ========= ПОДПИСКИ =========
|
||||
# ===== ТРИАЛ ПОДПИСКА =====
|
||||
@@ -108,7 +141,6 @@ TELEGRAM_STARS_RATE_RUB=1.3
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
@@ -120,6 +152,9 @@ YOOKASSA_SECRET_KEY=
|
||||
YOOKASSA_RETURN_URL=
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
|
||||
|
||||
# Включить оплату через СБП (Систему быстрых платежей)
|
||||
YOOKASSA_SBP_ENABLED=false
|
||||
|
||||
# Настройки чеков для налоговой
|
||||
YOOKASSA_VAT_CODE=1
|
||||
# Коды НДС:
|
||||
@@ -161,6 +196,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
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
# чтобы избежать блокировок платежных систем
|
||||
@@ -170,17 +212,42 @@ 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)
|
||||
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
|
||||
@@ -202,6 +269,34 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
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
|
||||
@@ -210,9 +305,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
|
||||
|
||||
@@ -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"
|
||||
@@ -1,28 +1,111 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем версию и теги
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.5-$(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.3.5-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.3.5-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "should_push=${{ github.event_name != 'pull_request' }}" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Информация о сборке ==="
|
||||
echo "Версия: $VERSION"
|
||||
echo "Коммит: $(git rev-parse --short HEAD)"
|
||||
echo "Теги: $TAGS"
|
||||
echo "Push: ${{ github.event_name != 'pull_request' }}"
|
||||
echo "==========================="
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.version.outputs.tags }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.created=${{ steps.version.outputs.build_date }}
|
||||
org.opencontainers.image.revision=${{ steps.version.outputs.short_sha }}
|
||||
org.opencontainers.image.title=Bedolaga RemnaWave Bot
|
||||
org.opencontainers.image.description=Telegram bot for RemnaWave VPN service
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build Summary
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "## 🚀 Docker Hub Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Параметр | Значение |" >> $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 "| **Registry** | Docker Hub |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📋 Опубликованные теги:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.version.outputs.tags }}" | tr ',' '\n' >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🔗 Ссылки:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Docker Hub Repository](https://hub.docker.com/r/fr1ngg/remnawave-bedolaga-telegram-bot)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,15 +24,51 @@ 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
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version info
|
||||
id: version
|
||||
run: |
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.5-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.5-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.5-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Определяем, нужно ли пушить образ
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "should_push=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ PR - only build without push"
|
||||
else
|
||||
echo "should_push=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Push/Tag - will push"
|
||||
fi
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -45,17 +82,24 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }}
|
||||
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,linux/arm64
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
push: ${{ steps.version.outputs.should_push }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
BUILD_DATE=${{ steps.version.outputs.build_date }}
|
||||
VCS_REF=${{ steps.version.outputs.short_sha }}
|
||||
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'
|
||||
@@ -67,3 +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 "| Parameter | Value |" >> $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 "| **Registry** | \`${{ env.REGISTRY }}\` |" >> $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 "| Parameter | Value |" >> $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 "⚠️ **Note:** Image built but not published as this is a pull request." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
*
|
||||
|
||||
# Исключения: разрешаем только нужные файлы
|
||||
!.dockerignore
|
||||
!.env.example
|
||||
!Dockerfile
|
||||
!app-config.json
|
||||
|
||||
@@ -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**
|
||||
@@ -1,43 +1,62 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.5"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Create non-root user with specific UID/GID for consistency
|
||||
RUN groupadd -g 1000 app && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash app
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
# Create necessary directories and set permissions
|
||||
RUN mkdir -p logs data && \
|
||||
chown -R app:app /app && \
|
||||
chmod -R 755 /app && \
|
||||
chmod -R 777 logs
|
||||
chown -R app:app /app logs data
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
VERSION=${VERSION} \
|
||||
BUILD_DATE=${BUILD_DATE} \
|
||||
VCS_REF=${VCS_REF}
|
||||
|
||||
# Expose webhook ports
|
||||
EXPOSE 8081
|
||||
EXPOSE 8082
|
||||
EXPOSE 8081 8082 8083
|
||||
|
||||
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
|
||||
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
|
||||
org.opencontainers.image.vendor="fr1ngg"
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
|
||||
### ⚡ **Полная автоматизация VPN бизнеса**
|
||||
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + ЮKassa
|
||||
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
|
||||
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
|
||||
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
|
||||
- 💬 **Уведомления в топики** - Уведомление в топик канала об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса
|
||||
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
|
||||
|
||||
### 🎛️ **Гибкость конфигурации**
|
||||
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор при нескольких
|
||||
@@ -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
|
||||
@@ -218,26 +233,55 @@ 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 +294,7 @@ TRIAL_SQUAD_UUID=
|
||||
# Сколько устройств доступно по дефолту при покупке платной подписки
|
||||
DEFAULT_DEVICE_LIMIT=3
|
||||
|
||||
# Максимум устройств доступных к покупке (0 = Нет лимита)
|
||||
# Максимум устройств достопных к покупке (0 = Нет лимита)
|
||||
MAX_DEVICES_LIMIT=15
|
||||
|
||||
# Дефолт параметры для подписок выданных через админку
|
||||
@@ -316,7 +360,6 @@ TELEGRAM_STARS_RATE_RUB=1.3
|
||||
# Tribute (https://tribute.app)
|
||||
TRIBUTE_ENABLED=false
|
||||
TRIBUTE_API_KEY=
|
||||
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
|
||||
TRIBUTE_DONATE_LINK=
|
||||
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
|
||||
TRIBUTE_WEBHOOK_PORT=8081
|
||||
@@ -328,10 +371,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
|
||||
@@ -339,23 +416,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)
|
||||
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
|
||||
@@ -377,6 +481,34 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
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
|
||||
@@ -386,11 +518,7 @@ DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -415,11 +543,13 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
- Настраиваемый триал-период
|
||||
- Уведомления об истечении
|
||||
- Плавный переход на платную версию
|
||||
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
|
||||
|
||||
💰 **Удобные платежи**
|
||||
- ⭐ Telegram Stars
|
||||
- 💳 Tribute
|
||||
- 💳 YooKassa
|
||||
- 💰 CryptoBot
|
||||
- 🎁 Реферальные бонусы
|
||||
- Детальная история транзакций
|
||||
|
||||
@@ -450,11 +580,12 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
👥 **Управление пользователями**
|
||||
- 🔍 Поиск и редактирование профилей
|
||||
- 💰 Управление балансами
|
||||
- 📱 **НОВОЕ**: Изменение лимита устройств (1-X)
|
||||
- 📊 **НОВОЕ**: Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 **НОВОЕ**: Мультивыбор серверов
|
||||
- 🔄 **НОВОЕ**: Сброс HWID устройств
|
||||
- 👥 **НОВОЕ**: Детальная реферальная статистика в профиле пользователя
|
||||
- 📱 Изменение лимита устройств (1-X)
|
||||
- 📊 Настройка лимитов трафика (0-10000 ГБ)
|
||||
- 🌍 Мультивыбор серверов
|
||||
- 🔄 Сброс HWID устройств
|
||||
- 👥 Детальная реферальная статистика в профиле пользователя
|
||||
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
|
||||
- 🚫 Блокировка/разблокировка/удаление
|
||||
|
||||
🎫 **Промо-система**
|
||||
@@ -474,6 +605,7 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
- 🔔 Автоуведомления о продлении
|
||||
- 💬 Система поддержки с HTML разметкой
|
||||
- 📝 Настройка правил сервиса
|
||||
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
|
||||
|
||||
📨 **Уведомления в закрытый канал**
|
||||
- 🎯 Активация триала
|
||||
@@ -481,6 +613,17 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
- 🔄 Конверсия из триала в платную
|
||||
- ⏰ Продление подписки
|
||||
- 💰 Пополнение баланса
|
||||
- ♻️ Выход обновлений бота
|
||||
- 🚧 Потеря соелинения с апи Remnawave
|
||||
- 🗄️ **Бекапы/Восстановление бд**
|
||||
- 🗄️ Отправка бд файлов в отдельный чат/топики
|
||||
- ⚙️ Изменение параметров подписки юзером (Уменьшение/Увеличение трафика/серверов/устройств на подписке)
|
||||
|
||||
🗄️ **Бекапы/Восстановление**
|
||||
- Ручной запуск бекапа
|
||||
- Восстановление бд
|
||||
- Включение/Отключение автобекапов
|
||||
- Отправка копии бд файла в отдеьный чат/топик
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -488,60 +631,6 @@ APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Недавние обновления (v2.2.1)
|
||||
|
||||
### 🛡️ **Защищенная реферальная система**
|
||||
- **Защита от абуза**: Бонусы выдаются только при реальном пополнении от 100₽
|
||||
- **Новая логика наград**: При первом пополнении ≥100₽ - новый пользователь и пригласивший получают по 100₽
|
||||
- **Комиссионная система**: Пригласивший получает 25% с последующих пополнений
|
||||
- **Автоматическая миграция**: Обновление статуса существующих пользователей с историей пополнений
|
||||
|
||||
### 🔔 **Умные уведомления**
|
||||
- **Реферальные уведомления**: Автоматические сообщения при переходе по ссылке, первом пополнении и получении комиссий
|
||||
- **Контроль повторов**: Защита от дублирования уведомлений и повторного получения бонусов
|
||||
- **Настраиваемые попытки**: Конфигурируемое количество попыток отправки уведомлений
|
||||
|
||||
### 📊 **Расширенная статистика рефералов**
|
||||
- **Детальная аналитика**: Количество приглашенных, активных рефералов, конверсия и доходы
|
||||
- **Список рефералов**: Просмотр всех приглашенных пользователей с пагинацией
|
||||
- **Топ-рефералы**: Самые прибыльные рефералы с индикацией активности
|
||||
- **Доходы по периодам**: Разбивка заработка по типам начислений
|
||||
|
||||
### 🔧 **Исправления админ-панели**
|
||||
- **Статистика**: Исправлена неработающая кнопка "Обновить" в статистике
|
||||
- **Промокоды**: Исправлена кнопка "Статистика" в разделе управления промокодами
|
||||
- **Навигация**: Синхронизированы callback_data между клавиатурами и обработчиками
|
||||
- **Реферальная статистика**: Добавлена детальная реферальная информация в профилях пользователей
|
||||
|
||||
### 📱 **Улучшения пользовательского интерфейса**
|
||||
- **User-friendly**: Улучшен интерфейс страницы подписки для лучшего UX
|
||||
- **Производительность**: Оптимизированы SQL-запросы для работы с реферальной статистикой
|
||||
- **Обработка ошибок**: Улучшена обработка ошибок при обновлении данных
|
||||
|
||||
---
|
||||
|
||||
## 🆕 Недавние обновления
|
||||
|
||||
### 🛠️ **Управление серверами**
|
||||
- **Мультивыбор серверов**: Админы могут назначать пользователям несколько серверов через удобный переключатель
|
||||
- **Умная логика отображения**: Показывает активные серверы + уже назначенные пользователю неактивные серверы
|
||||
- **Автоматический пропуск**: При наличии только одного сервера шаг выбора автоматически пропускается
|
||||
- **Синхронизация в реальном времени**: Все изменения серверов автоматически синхронизируются с Remnawave панелью
|
||||
|
||||
### 📱 **Управление устройствами**
|
||||
- **Изменение лимита устройств**: Админы могут менять количество разрешенных устройств (1-X)
|
||||
- **Быстрые кнопки**: Предустановленные значения 1, 2, 3, 5, 10 устройств
|
||||
- **Сброс HWID**: Функция сброса привязанных устройств пользователя
|
||||
- **Гибкая настройка**: Отдельные лимиты для триал и платных подписок
|
||||
|
||||
### 📊 **Управление трафиком**
|
||||
- **Настройка лимитов трафика**: Возможность установки лимита от 0 ГБ до 10000 ГБ
|
||||
- **Безлимитный режим**: Опция установки безлимитного трафика (0 ГБ)
|
||||
- **Быстрые настройки**: Предустановленные значения 50, 100, 500, 1000 ГБ
|
||||
- **Два режима продажи**: Фиксированный трафик или выбираемые пакеты
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
### 📄 docker-compose.yml
|
||||
@@ -556,7 +645,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:
|
||||
@@ -572,7 +661,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:
|
||||
@@ -595,28 +684,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
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
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:
|
||||
@@ -624,6 +731,7 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
```
|
||||
|
||||
### 🚀 Команды управления
|
||||
@@ -752,17 +860,20 @@ 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 # Правила
|
||||
│ │ ├── 🌎 servers.py # Сервера
|
||||
│ │ ├── 📱 subscriptions.py # Подписки
|
||||
│ │ ├── 🔍 monitoring.py # Мониторинг
|
||||
│ │ └── 🔗 remnawave.py # Система RemnaWave
|
||||
│ │ └── 🔗 remnawave.py # Система Remnawave
|
||||
│ │
|
||||
│ ├── 🗄️ database/ # База данных
|
||||
│ │ ├── 📊 models.py # Модели SQLAlchemy
|
||||
@@ -775,21 +886,26 @@ bedolaga_bot/
|
||||
│ │ ├── 📜 rules.py # Правила сервиса
|
||||
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
|
||||
│ │ ├── 💳 yookassa.py # YooKassa операции
|
||||
│ │ ├── 📨 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 # Проверка версий бота
|
||||
│ │ ├── 🎖️ tribute_service.py # Tribute платежи
|
||||
│ │ ├── 💳 yookassa_service.py # YooKassa платежи
|
||||
│ │ └── 🌐 remnawave_service.py # Интеграция с RemnaWave
|
||||
│ │ └── 🌐 remnawave_service.py # Интеграция с Remnawave
|
||||
│ │
|
||||
│ ├── 🛠️ utils/ # Утилиты
|
||||
│ │ ├── 🎨 decorators.py # Декораторы
|
||||
@@ -798,7 +914,11 @@ 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 # Кеширование
|
||||
│ │
|
||||
│ ├── 🛡️ middlewares/ # Middleware
|
||||
@@ -806,6 +926,7 @@ bedolaga_bot/
|
||||
│ │ ├── 📋 logging.py # Логирование
|
||||
│ │ ├── 🚧 maintenance.py # тех работы
|
||||
│ │ ├── 🔐 subscription_checker.py # тех работы
|
||||
│ │ ├── 🔐 channel_checker.py
|
||||
│ │ └── ⏱️ throttling.py # Ограничение запросов
|
||||
│ │
|
||||
│ ├── 🌐 localization/ # Локализация
|
||||
@@ -818,10 +939,11 @@ bedolaga_bot/
|
||||
│ │ └── 👑 admin.py # Админские клавиатуры
|
||||
│ │
|
||||
│ └── 🔌 external/ # Внешние API
|
||||
│ ├── 🌐 remnawave_api.py # RemnaWave API
|
||||
│ ├── 🌐 remnawave_api.py # Remnawave API
|
||||
│ ├── ⭐ telegram_stars.py # Telegram Stars
|
||||
│ ├── 💳 yookassa_webhook.py # YooKassa webhook
|
||||
│ ├── 🌐 webhook_server.py # Webhook сервер
|
||||
│ ├── 💳 cryptobot.py # CryptoBot Api
|
||||
│ └── 🎖️ tribute.py # Tribute платежи
|
||||
│
|
||||
├── 🔄 migrations/ # Миграции БД
|
||||
@@ -834,8 +956,6 @@ bedolaga_bot/
|
||||
|
||||
### 🏥 Health Checks
|
||||
- **Основной**: `http://localhost:8081/health`
|
||||
- **YooKassa**: `http://localhost:8082/health`
|
||||
- **Tribute**: `http://localhost:8081/tribute-webhook`
|
||||
|
||||
### 🔧 Полезные команды
|
||||
```bash
|
||||
@@ -888,6 +1008,14 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# CryptoBot webhook endpoint
|
||||
handle /cryptobot-webhook* {
|
||||
reverse_proxy localhost:8081 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# Для YooKassa
|
||||
location /yookassa-webhook {
|
||||
@@ -909,6 +1037,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
|
||||
@@ -931,7 +1063,7 @@ your-domain.com {
|
||||
3. **💰 Баланс** → "💰 Баланс" → пополни через Stars/Tribute/YooKassa
|
||||
4. **🛒 Подписка** → "🛒 Купить подписку" → выбор тарифа → оплата
|
||||
5. **📱 Управление** → "📋 Мои подписки" → конфигурация → получение ссылки
|
||||
6. **👥 Рефералы** → "👥 Рефералы" → поделись ссылкой
|
||||
6. **👥 Партнерка** → "👥 Партнерка" → поделись ссылкой
|
||||
|
||||
### ⚙️ **Для администраторов**
|
||||
|
||||
@@ -948,7 +1080,7 @@ your-domain.com {
|
||||
|
||||
## 🛡️ Безопасность
|
||||
|
||||
### 🔐 Защита панели RemnaWave
|
||||
### 🔐 Защита панели Remnawave
|
||||
|
||||
Бот поддерживает интеграцию с системой защиты панели через куки-аутентификацию:
|
||||
|
||||
@@ -1013,22 +1145,22 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
|
||||
<tr>
|
||||
<td>🥇</td>
|
||||
<td><strong>Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td><strong>@pilot_737800</strong></td>
|
||||
<td>₽4,750</td>
|
||||
<td>За веру в проект с самого начала</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥈</td>
|
||||
<td><strong>@Legacyyy777</strong></td>
|
||||
<td>₽2,400</td>
|
||||
<td>₽2,600</td>
|
||||
<td>За ценные предложения по улучшению</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>🥉</td>
|
||||
<td><strong>@pilot_737800</strong></td>
|
||||
<td>₽2,250</td>
|
||||
<td><strong>Илья (@ispanec_nn)</strong></td>
|
||||
<td>$30</td>
|
||||
<td>За активное тестирование и фидбек</td>
|
||||
</tr>
|
||||
|
||||
@@ -1046,15 +1178,29 @@ 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>
|
||||
<td>₽800</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>@SmartSystemCompany</strong></td>
|
||||
<td>₽600</td>
|
||||
<td>За поддержку и доверие</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td>8</td>
|
||||
<td><strong>@PhiLin58</strong></td>
|
||||
<td>₽300</td>
|
||||
<td>За участие в развитии</td>
|
||||
|
||||
@@ -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 при необходимости
|
||||
- Сотрудничество с исследователями безопасности
|
||||
|
||||
---
|
||||
|
||||
**Помните**: Безопасность - это постоянный процесс, а не одноразовая настройка. Регулярно обновляйте зависимости, следите за новостями безопасности и не стесняйтесь сообщать о подозрительной активности.
|
||||
@@ -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
|
||||
@@ -23,10 +24,17 @@ from app.handlers.admin import (
|
||||
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
|
||||
maintenance as admin_maintenance,
|
||||
user_messages as admin_user_messages,
|
||||
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__)
|
||||
|
||||
|
||||
@@ -66,16 +74,31 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
logger.info("Используется MemoryStorage для FSM")
|
||||
storage = MemoryStorage()
|
||||
|
||||
|
||||
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)
|
||||
@@ -97,11 +120,13 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.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 платежей")
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
try:
|
||||
await maintenance_service.start_monitoring()
|
||||
@@ -109,6 +134,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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from typing import List, Optional, Union, Dict
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator, Field
|
||||
@@ -14,13 +16,34 @@ 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 = ""
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str = "remnawave_bot"
|
||||
POSTGRES_USER: str = "remnawave_user"
|
||||
POSTGRES_PASSWORD: str = "secure_password_123"
|
||||
|
||||
SQLITE_PATH: str = "./data/bot.db"
|
||||
|
||||
DATABASE_MODE: str = "auto"
|
||||
|
||||
DATABASE_URL: str
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
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
|
||||
@@ -39,7 +62,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
|
||||
@@ -93,7 +115,6 @@ class Settings(BaseSettings):
|
||||
|
||||
TRIBUTE_ENABLED: bool = False
|
||||
TRIBUTE_API_KEY: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
|
||||
TRIBUTE_DONATE_LINK: Optional[str] = None
|
||||
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
|
||||
TRIBUTE_WEBHOOK_PORT: int = 8081
|
||||
@@ -103,22 +124,41 @@ 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 = "Интернет-сервис"
|
||||
PAYMENT_BALANCE_TEMPLATE: str = "{service_name} - {description}"
|
||||
PAYMENT_SUBSCRIPTION_TEMPLATE: str = "{service_name} - {description}"
|
||||
|
||||
CRYPTOBOT_ENABLED: bool = False
|
||||
CRYPTOBOT_API_TOKEN: Optional[str] = None
|
||||
CRYPTOBOT_WEBHOOK_SECRET: Optional[str] = None
|
||||
CRYPTOBOT_BASE_URL: str = "https://pay.crypt.bot"
|
||||
CRYPTOBOT_TESTNET: bool = False
|
||||
CRYPTOBOT_WEBHOOK_PATH: str = "/cryptobot-webhook"
|
||||
CRYPTOBOT_WEBHOOK_PORT: int = 8083
|
||||
CRYPTOBOT_DEFAULT_ASSET: str = "USDT"
|
||||
CRYPTOBOT_ASSETS: str = "USDT,TON,BTC,ETH"
|
||||
CRYPTOBOT_INVOICE_EXPIRES_HOURS: int = 24
|
||||
|
||||
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"
|
||||
|
||||
@@ -132,6 +172,21 @@ class Settings(BaseSettings):
|
||||
APP_CONFIG_PATH: str = "app-config.json"
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
APP_CONFIG_CACHE_TTL: int = 3600
|
||||
|
||||
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
|
||||
@@ -140,6 +195,48 @@ class Settings(BaseSettings):
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return str(log_path)
|
||||
|
||||
def get_database_url(self) -> str:
|
||||
if self.DATABASE_URL and self.DATABASE_URL.strip():
|
||||
return self.DATABASE_URL
|
||||
|
||||
mode = self.DATABASE_MODE.lower()
|
||||
|
||||
if mode == "sqlite":
|
||||
return self._get_sqlite_url()
|
||||
elif mode == "postgresql":
|
||||
return self._get_postgresql_url()
|
||||
elif mode == "auto":
|
||||
if os.getenv("DOCKER_ENV") == "true" or os.path.exists("/.dockerenv"):
|
||||
return self._get_postgresql_url()
|
||||
else:
|
||||
return self._get_sqlite_url()
|
||||
else:
|
||||
return self._get_auto_database_url()
|
||||
|
||||
def _get_sqlite_url(self) -> str:
|
||||
sqlite_path = Path(self.SQLITE_PATH)
|
||||
sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return f"sqlite+aiosqlite:///{sqlite_path.absolute()}"
|
||||
|
||||
def _get_postgresql_url(self) -> str:
|
||||
return (f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
|
||||
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}")
|
||||
|
||||
def _get_auto_database_url(self) -> str:
|
||||
if (os.getenv("DOCKER_ENV") == "true" or
|
||||
os.path.exists("/.dockerenv")):
|
||||
return self._get_postgresql_url()
|
||||
else:
|
||||
return self._get_sqlite_url()
|
||||
|
||||
def is_postgresql(self) -> bool:
|
||||
"""Проверяет, используется ли PostgreSQL"""
|
||||
return "postgresql" in self.get_database_url()
|
||||
|
||||
def is_sqlite(self) -> bool:
|
||||
"""Проверяет, используется ли SQLite"""
|
||||
return "sqlite" in self.get_database_url()
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
return user_id in self.get_admin_ids()
|
||||
|
||||
@@ -156,6 +253,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:
|
||||
@@ -180,8 +319,8 @@ class Settings(BaseSettings):
|
||||
return ["ru", "en"]
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
rubles = price_kopeks / 100
|
||||
return f"{rubles:.2f} ₽"
|
||||
rubles = price_kopeks // 100
|
||||
return f"{rubles} ₽"
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
return kopeks / 100
|
||||
@@ -229,6 +368,27 @@ class Settings(BaseSettings):
|
||||
return f"{self.WEBHOOK_URL}/payment-success"
|
||||
return "https://t.me/"
|
||||
|
||||
def is_cryptobot_enabled(self) -> bool:
|
||||
return (self.CRYPTOBOT_ENABLED and
|
||||
self.CRYPTOBOT_API_TOKEN is not None)
|
||||
|
||||
def get_cryptobot_base_url(self) -> str:
|
||||
if self.CRYPTOBOT_TESTNET:
|
||||
return "https://testnet-pay.crypt.bot"
|
||||
return self.CRYPTOBOT_BASE_URL
|
||||
|
||||
def get_cryptobot_assets(self) -> List[str]:
|
||||
try:
|
||||
assets = self.CRYPTOBOT_ASSETS.strip()
|
||||
if not assets:
|
||||
return ["USDT", "TON"]
|
||||
return [asset.strip() for asset in assets.split(',') if asset.strip()]
|
||||
except (ValueError, AttributeError):
|
||||
return ["USDT", "TON"]
|
||||
|
||||
def get_cryptobot_invoice_expires_seconds(self) -> int:
|
||||
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
|
||||
|
||||
def is_maintenance_mode(self) -> bool:
|
||||
return self.MAINTENANCE_MODE
|
||||
|
||||
@@ -252,7 +412,7 @@ class Settings(BaseSettings):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
if hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
@@ -271,7 +431,7 @@ class Settings(BaseSettings):
|
||||
period_str = period_str.strip()
|
||||
if period_str:
|
||||
period = int(period_str)
|
||||
if period in PERIOD_PRICES:
|
||||
if hasattr(self, f'PRICE_{period}_DAYS'):
|
||||
periods.append(period)
|
||||
|
||||
return periods if periods else [30, 90, 180]
|
||||
@@ -316,9 +476,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,
|
||||
@@ -376,6 +549,15 @@ class Settings(BaseSettings):
|
||||
except Exception as e:
|
||||
logger.info(f"ERROR PARSING CONFIG: {e}")
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def is_version_check_enabled(self) -> bool:
|
||||
return self.VERSION_CHECK_ENABLED
|
||||
|
||||
def get_version_check_repo(self) -> str:
|
||||
return self.VERSION_CHECK_REPO
|
||||
|
||||
def get_version_check_interval(self) -> int:
|
||||
return self.VERSION_CHECK_INTERVAL_HOURS
|
||||
|
||||
def _get_fallback_traffic_packages(self) -> List[Dict]:
|
||||
try:
|
||||
@@ -456,13 +638,13 @@ class Settings(BaseSettings):
|
||||
|
||||
model_config = {
|
||||
"env_file": ".env",
|
||||
"env_file_encoding": "utf-8"
|
||||
"env_file_encoding": "utf-8",
|
||||
"extra": "ignore"
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
PERIOD_PRICES = {
|
||||
14: settings.PRICE_14_DAYS,
|
||||
30: settings.PRICE_30_DAYS,
|
||||
@@ -483,3 +665,6 @@ def refresh_traffic_prices():
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
refresh_traffic_prices()
|
||||
|
||||
settings._original_database_url = settings.DATABASE_URL
|
||||
settings.DATABASE_URL = settings.get_database_url()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
invoice_id: str,
|
||||
amount: str,
|
||||
asset: str,
|
||||
status: str = "active",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
bot_invoice_url: Optional[str] = None,
|
||||
mini_app_invoice_url: Optional[str] = None,
|
||||
web_app_invoice_url: Optional[str] = None
|
||||
) -> CryptoBotPayment:
|
||||
|
||||
payment = CryptoBotPayment(
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
status=status,
|
||||
description=description,
|
||||
payload=payload,
|
||||
bot_invoice_url=bot_invoice_url,
|
||||
mini_app_invoice_url=mini_app_invoice_url,
|
||||
web_app_invoice_url=web_app_invoice_url
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_invoice_id(
|
||||
db: AsyncSession,
|
||||
invoice_id: str
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.invoice_id == invoice_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_cryptobot_payment_by_id(
|
||||
db: AsyncSession,
|
||||
payment_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(CryptoBotPayment.id == payment_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_cryptobot_payment_status(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
status: str,
|
||||
paid_at: Optional[datetime] = None
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
if status == "paid" and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
|
||||
return payment
|
||||
|
||||
|
||||
async def link_cryptobot_payment_to_transaction(
|
||||
db: AsyncSession,
|
||||
invoice_id: str,
|
||||
transaction_id: int
|
||||
) -> Optional[CryptoBotPayment]:
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
|
||||
return payment
|
||||
|
||||
|
||||
async def get_user_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.where(CryptoBotPayment.user_id == user_id)
|
||||
.order_by(CryptoBotPayment.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(
|
||||
db: AsyncSession,
|
||||
older_than_hours: int = 24
|
||||
) -> List[CryptoBotPayment]:
|
||||
|
||||
from datetime import timedelta
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
.options(selectinload(CryptoBotPayment.user))
|
||||
.where(
|
||||
and_(
|
||||
CryptoBotPayment.status == "active",
|
||||
CryptoBotPayment.created_at < cutoff_time
|
||||
)
|
||||
)
|
||||
.order_by(CryptoBotPayment.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> None:
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -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
|
||||
|
||||
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
@@ -121,10 +122,11 @@ async def extend_subscription(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -440,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 = []
|
||||
@@ -456,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
|
||||
)
|
||||
@@ -465,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(
|
||||
@@ -531,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
|
||||
@@ -644,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
|
||||
|
||||
@@ -277,12 +277,12 @@ async def get_revenue_by_period(
|
||||
|
||||
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
|
||||
|
||||
|
||||
async def find_tribute_transactions_by_payment_id(
|
||||
db: AsyncSession,
|
||||
payment_id: str,
|
||||
user_telegram_id: Optional[int] = None
|
||||
) -> List[Transaction]:
|
||||
"""Найти все Tribute транзакции по payment_id"""
|
||||
|
||||
query = select(Transaction).options(selectinload(Transaction.user))
|
||||
|
||||
@@ -313,18 +313,27 @@ async def check_tribute_payment_duplicate(
|
||||
amount_kopeks: int,
|
||||
user_telegram_id: int
|
||||
) -> Optional[Transaction]:
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=24)
|
||||
|
||||
transactions = await find_tribute_transactions_by_payment_id(
|
||||
db, payment_id, user_telegram_id
|
||||
)
|
||||
exact_external_id = f"donation_{payment_id}"
|
||||
|
||||
for transaction in transactions:
|
||||
if (transaction.amount_kopeks == amount_kopeks and
|
||||
transaction.is_completed and
|
||||
transaction.user.telegram_id == user_telegram_id):
|
||||
return transaction
|
||||
query = select(Transaction).options(selectinload(Transaction.user)).where(
|
||||
and_(
|
||||
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
|
||||
Transaction.external_id == exact_external_id,
|
||||
Transaction.amount_kopeks == amount_kopeks,
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at >= cutoff_time
|
||||
)
|
||||
).join(User).where(User.telegram_id == user_telegram_id)
|
||||
|
||||
return None
|
||||
result = await db.execute(query)
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if transaction:
|
||||
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
|
||||
|
||||
return transaction
|
||||
|
||||
|
||||
async def create_unique_tribute_transaction(
|
||||
@@ -334,7 +343,6 @@ async def create_unique_tribute_transaction(
|
||||
amount_kopeks: int,
|
||||
description: str
|
||||
) -> Transaction:
|
||||
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
|
||||
|
||||
external_id = f"donation_{payment_id}"
|
||||
|
||||
@@ -356,16 +364,3 @@ async def create_unique_tribute_transaction(
|
||||
external_id=external_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
async def get_transaction_by_id(
|
||||
db: AsyncSession,
|
||||
transaction_id: int
|
||||
) -> Optional[Transaction]:
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Transaction).where(Transaction.id == transaction_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения транзакции {transaction_id}: {e}")
|
||||
return None
|
||||
|
||||
@@ -48,7 +48,6 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
)
|
||||
@@ -92,7 +91,8 @@ async def create_user(
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
@@ -149,18 +149,6 @@ async def add_user_balance(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
topup_keywords = ["пополнение", "stars", "yookassa", "topup"]
|
||||
exclude_keywords = ["комиссия", "бонус", "реферальн", "выплата", "вознаграждение"]
|
||||
|
||||
has_topup_keywords = any(word in description.lower() for word in topup_keywords)
|
||||
has_exclude_keywords = any(word in description.lower() for word in exclude_keywords)
|
||||
|
||||
if has_topup_keywords and not has_exclude_keywords:
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения: {e}")
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
return True
|
||||
@@ -169,7 +157,8 @@ async def add_user_balance(
|
||||
logger.error(f"Ошибка изменения баланса пользователя {user.id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
|
||||
async def add_user_balance_by_id(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
@@ -187,11 +176,7 @@ async def add_user_balance_by_id(
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
async def subtract_user_balance(
|
||||
db: AsyncSession,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import UserMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_user_message(
|
||||
db: AsyncSession,
|
||||
message_text: str,
|
||||
created_by: int,
|
||||
is_active: bool = True,
|
||||
sort_order: int = 0
|
||||
) -> UserMessage:
|
||||
message = UserMessage(
|
||||
message_text=message_text,
|
||||
is_active=is_active,
|
||||
sort_order=sort_order,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
|
||||
return message
|
||||
|
||||
|
||||
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage).where(UserMessage.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.where(UserMessage.is_active == True)
|
||||
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
|
||||
active_messages = await get_active_user_messages(db)
|
||||
|
||||
if not active_messages:
|
||||
return None
|
||||
|
||||
random_message = random.choice(active_messages)
|
||||
return random_message.message_text
|
||||
|
||||
|
||||
async def get_all_user_messages(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 50
|
||||
) -> List[UserMessage]:
|
||||
result = await db.execute(
|
||||
select(UserMessage)
|
||||
.order_by(UserMessage.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_messages_count(db: AsyncSession) -> int:
|
||||
result = await db.execute(select(func.count(UserMessage.id)))
|
||||
return result.scalar()
|
||||
|
||||
|
||||
async def update_user_message(
|
||||
db: AsyncSession,
|
||||
message_id: int,
|
||||
message_text: Optional[str] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
sort_order: Optional[int] = None
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
if message_text is not None:
|
||||
message.message_text = message_text
|
||||
|
||||
if is_active is not None:
|
||||
message.is_active = is_active
|
||||
|
||||
if sort_order is not None:
|
||||
message.sort_order = sort_order
|
||||
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f"📝 Обновлено сообщение ID {message_id}")
|
||||
return message
|
||||
|
||||
|
||||
async def toggle_user_message_status(
|
||||
db: AsyncSession,
|
||||
message_id: int
|
||||
) -> Optional[UserMessage]:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return None
|
||||
|
||||
message.is_active = not message.is_active
|
||||
message.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
return False
|
||||
|
||||
await db.delete(message)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
|
||||
return True
|
||||
|
||||
|
||||
async def get_user_messages_stats(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(UserMessage.id)))
|
||||
total_messages = total_result.scalar()
|
||||
|
||||
active_result = await db.execute(
|
||||
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
|
||||
)
|
||||
active_messages = active_result.scalar()
|
||||
|
||||
return {
|
||||
"total_messages": total_messages,
|
||||
"active_messages": active_messages,
|
||||
"inactive_messages": total_messages - active_messages
|
||||
}
|
||||
@@ -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 не указан)'
|
||||
}
|
||||
@@ -45,6 +45,7 @@ class PaymentMethod(Enum):
|
||||
TELEGRAM_STARS = "telegram_stars"
|
||||
TRIBUTE = "tribute"
|
||||
YOOKASSA = "yookassa"
|
||||
CRYPTOBOT = "cryptobot"
|
||||
MANUAL = "manual"
|
||||
|
||||
class YooKassaPayment(Base):
|
||||
@@ -95,6 +96,55 @@ class YooKassaPayment(Base):
|
||||
def __repr__(self):
|
||||
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
|
||||
|
||||
class CryptoBotPayment(Base):
|
||||
__tablename__ = "cryptobot_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
invoice_id = Column(String(255), unique=True, nullable=False, index=True)
|
||||
amount = Column(String(50), nullable=False)
|
||||
asset = Column(String(10), nullable=False)
|
||||
|
||||
status = Column(String(50), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
payload = Column(Text, nullable=True)
|
||||
|
||||
bot_invoice_url = Column(Text, nullable=True)
|
||||
mini_app_invoice_url = Column(Text, nullable=True)
|
||||
web_app_invoice_url = Column(Text, nullable=True)
|
||||
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="cryptobot_payments")
|
||||
transaction = relationship("Transaction", backref="cryptobot_payment")
|
||||
|
||||
@property
|
||||
def amount_float(self) -> float:
|
||||
try:
|
||||
return float(self.amount)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.status == "paid"
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "active"
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.status == "expired"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@@ -505,12 +555,30 @@ class MonitoringLog(Base):
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class SentNotification(Base):
|
||||
__tablename__ = "sent_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
days_before = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
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)
|
||||
@@ -519,7 +587,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):
|
||||
@@ -582,3 +649,30 @@ class SubscriptionServer(Base):
|
||||
|
||||
subscription = relationship("Subscription", backref="subscription_servers")
|
||||
server_squad = relationship("ServerSquad", backref="subscription_servers")
|
||||
|
||||
class UserMessage(Base):
|
||||
__tablename__ = "user_messages"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
creator = relationship("User", backref="created_messages")
|
||||
|
||||
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")
|
||||
|
||||
@@ -74,11 +74,10 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
|
||||
return False
|
||||
|
||||
async def create_yookassa_payments_table():
|
||||
|
||||
table_exists = await check_table_exists('yookassa_payments')
|
||||
async def create_cryptobot_payments_table():
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
logger.info("Таблица yookassa_payments уже существует")
|
||||
logger.info("Таблица cryptobot_payments уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -87,159 +86,366 @@ async def create_yookassa_payments_table():
|
||||
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT 0,
|
||||
is_captured BOOLEAN DEFAULT 0,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at DATETIME NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT 0,
|
||||
test_mode BOOLEAN DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSONB NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at TIMESTAMP NULL,
|
||||
transaction_id INTEGER NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
yookassa_created_at TIMESTAMP NULL,
|
||||
captured_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE yookassa_payments (
|
||||
CREATE TABLE cryptobot_payments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount_kopeks INT NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
|
||||
description TEXT NULL,
|
||||
invoice_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
amount VARCHAR(50) NOT NULL,
|
||||
asset VARCHAR(10) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
is_paid BOOLEAN DEFAULT FALSE,
|
||||
is_captured BOOLEAN DEFAULT FALSE,
|
||||
confirmation_url TEXT NULL,
|
||||
metadata_json JSON NULL,
|
||||
description TEXT NULL,
|
||||
payload TEXT NULL,
|
||||
bot_invoice_url TEXT NULL,
|
||||
mini_app_invoice_url TEXT NULL,
|
||||
web_app_invoice_url TEXT NULL,
|
||||
paid_at DATETIME NULL,
|
||||
transaction_id INT NULL,
|
||||
payment_method_type VARCHAR(50) NULL,
|
||||
refundable BOOLEAN DEFAULT FALSE,
|
||||
test_mode BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
yookassa_created_at DATETIME NULL,
|
||||
captured_at DATETIME NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
|
||||
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
|
||||
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
|
||||
CREATE INDEX idx_cryptobot_payments_user_id ON cryptobot_payments(user_id);
|
||||
CREATE INDEX idx_cryptobot_payments_invoice_id ON cryptobot_payments(invoice_id);
|
||||
CREATE INDEX idx_cryptobot_payments_status ON cryptobot_payments(status);
|
||||
"""
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица yookassa_payments успешно создана")
|
||||
logger.info("Таблица cryptobot_payments успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы yookassa_payments: {e}")
|
||||
logger.error(f"Ошибка создания таблицы cryptobot_payments: {e}")
|
||||
return False
|
||||
|
||||
async def add_remnawave_v2_columns():
|
||||
|
||||
columns_to_add = {
|
||||
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
|
||||
'last_remnawave_sync': 'TIMESTAMP NULL',
|
||||
'trojan_password': 'VARCHAR(255) NULL',
|
||||
'vless_uuid': 'VARCHAR(255) NULL',
|
||||
'ss_password': 'VARCHAR(255) NULL'
|
||||
}
|
||||
|
||||
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
|
||||
async def create_user_messages_table():
|
||||
table_exists = await check_table_exists('user_messages')
|
||||
if table_exists:
|
||||
logger.info("Таблица user_messages уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
columns_added = 0
|
||||
|
||||
for column_name, column_def in columns_to_add.items():
|
||||
exists = await check_column_exists('users', column_name)
|
||||
if db_type == 'sqlite':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
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
|
||||
);
|
||||
|
||||
if not exists:
|
||||
logger.info(f"Добавление колонки {column_name} в таблицу users")
|
||||
|
||||
if db_type == 'sqlite':
|
||||
if column_def.startswith('BIGINT'):
|
||||
column_def = column_def.replace('BIGINT', 'INTEGER')
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
elif db_type == 'mysql':
|
||||
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
|
||||
|
||||
try:
|
||||
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
|
||||
columns_added += 1
|
||||
logger.info(f"Колонка {column_name} успешно добавлена")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
|
||||
continue
|
||||
|
||||
else:
|
||||
logger.debug(f"Колонка {column_name} уже существует")
|
||||
|
||||
if columns_added > 0:
|
||||
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
|
||||
CREATE INDEX idx_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'postgresql':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
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_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
|
||||
elif db_type == 'mysql':
|
||||
create_sql = """
|
||||
CREATE TABLE user_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
message_text TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
sort_order INT DEFAULT 0,
|
||||
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_user_messages_active ON user_messages(is_active);
|
||||
CREATE INDEX idx_user_messages_sort ON user_messages(sort_order, created_at);
|
||||
"""
|
||||
else:
|
||||
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
|
||||
|
||||
return columns_added
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
logger.info("Таблица user_messages успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
|
||||
return 0
|
||||
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:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'postgresql':
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_messages
|
||||
DROP CONSTRAINT IF EXISTS user_messages_created_by_fkey;
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_messages
|
||||
ADD CONSTRAINT user_messages_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
logger.info("Обновлен внешний ключ user_messages.created_by")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка обновления FK user_messages: {e}")
|
||||
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE promocodes
|
||||
DROP CONSTRAINT IF EXISTS promocodes_created_by_fkey;
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE promocodes
|
||||
ADD CONSTRAINT promocodes_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
"""))
|
||||
logger.info("Обновлен внешний ключ promocodes.created_by")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка обновления FK promocodes: {e}")
|
||||
|
||||
logger.info("Внешние ключи обновлены для безопасного удаления пользователей")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления внешних ключей: {e}")
|
||||
return False
|
||||
|
||||
async def add_referral_system_columns():
|
||||
logger.info("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
|
||||
@@ -292,7 +498,6 @@ async def add_referral_system_columns():
|
||||
return False
|
||||
|
||||
async def create_subscription_conversions_table():
|
||||
|
||||
table_exists = await check_table_exists('subscription_conversions')
|
||||
if table_exists:
|
||||
logger.info("Таблица subscription_conversions уже существует")
|
||||
@@ -368,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}")
|
||||
@@ -453,18 +657,44 @@ async def run_universal_migration():
|
||||
db_type = await get_database_type()
|
||||
logger.info(f"Тип базы данных: {db_type}")
|
||||
|
||||
await add_remnawave_v2_columns()
|
||||
|
||||
referral_migration_success = await add_referral_system_columns()
|
||||
if not referral_migration_success:
|
||||
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
|
||||
yookassa_created = await create_yookassa_payments_table()
|
||||
if yookassa_created:
|
||||
logger.info("✅ Таблица YooKassa payments готова")
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
|
||||
cryptobot_created = await create_cryptobot_payments_table()
|
||||
if cryptobot_created:
|
||||
logger.info("✅ Таблица CryptoBot payments готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
|
||||
logger.warning("⚠️ Проблемы с таблицей CryptoBot payments")
|
||||
|
||||
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:
|
||||
logger.info("✅ Внешние ключи обновлены")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с обновлением внешних ключей")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ КОНВЕРСИЙ ПОДПИСОК ===")
|
||||
conversions_created = await create_subscription_conversions_table()
|
||||
@@ -506,9 +736,10 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
|
||||
logger.info("✅ Реферальная система обновлена")
|
||||
logger.info("✅ RemnaWave v2.1.5 колонки добавлены")
|
||||
logger.info("✅ YooKassa таблица готова")
|
||||
logger.info("✅ CryptoBot таблица готова")
|
||||
logger.info("✅ Таблица конверсий подписок создана")
|
||||
logger.info("✅ Таблица welcome_texts с полем is_enabled готова")
|
||||
logger.info("✅ Медиа поля в broadcast_history добавлены")
|
||||
logger.info("✅ Дубликаты подписок исправлены")
|
||||
return True
|
||||
|
||||
@@ -522,24 +753,31 @@ async def check_migration_status():
|
||||
try:
|
||||
status = {
|
||||
"has_made_first_topup_column": False,
|
||||
"yookassa_table": False,
|
||||
"remnawave_v2_columns": 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')
|
||||
|
||||
status["yookassa_table"] = await check_table_exists('yookassa_payments')
|
||||
|
||||
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')
|
||||
|
||||
remnawave_columns = ['lifetime_used_traffic_bytes', 'last_remnawave_sync', 'trojan_password', 'vless_uuid', 'ss_password']
|
||||
remnawave_status = []
|
||||
for col in remnawave_columns:
|
||||
exists = await check_column_exists('users', col)
|
||||
remnawave_status.append(exists)
|
||||
status["remnawave_v2_columns"] = all(remnawave_status)
|
||||
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("""
|
||||
@@ -555,9 +793,12 @@ async def check_migration_status():
|
||||
|
||||
check_names = {
|
||||
"has_made_first_topup_column": "Колонка реферальной системы",
|
||||
"yookassa_table": "Таблица YooKassa payments",
|
||||
"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": "Таблица конверсий подписок",
|
||||
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
}
|
||||
|
||||
@@ -574,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}")
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import aiohttp
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CryptoBotService:
|
||||
|
||||
def __init__(self):
|
||||
self.api_token = settings.CRYPTOBOT_API_TOKEN
|
||||
self.base_url = settings.get_cryptobot_base_url()
|
||||
self.webhook_secret = settings.CRYPTOBOT_WEBHOOK_SECRET
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.api_token:
|
||||
logger.error("CryptoBot API token не настроен")
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/{endpoint}"
|
||||
headers = {
|
||||
'Crypto-Pay-API-Token': self.api_token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json=data if data else None
|
||||
) as response:
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
if response.status == 200 and response_data.get('ok'):
|
||||
return response_data.get('result')
|
||||
else:
|
||||
logger.error(f"CryptoBot API ошибка: {response_data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса к CryptoBot API: {e}")
|
||||
return None
|
||||
|
||||
async def get_me(self) -> Optional[Dict[str, Any]]:
|
||||
return await self._make_request('GET', 'getMe')
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: str,
|
||||
asset: str = "USDT",
|
||||
description: Optional[str] = None,
|
||||
payload: Optional[str] = None,
|
||||
expires_in: Optional[int] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
data = {
|
||||
'currency_type': 'crypto',
|
||||
'asset': asset,
|
||||
'amount': amount
|
||||
}
|
||||
|
||||
if description:
|
||||
data['description'] = description
|
||||
|
||||
if payload:
|
||||
data['payload'] = payload
|
||||
|
||||
if expires_in:
|
||||
data['expires_in'] = expires_in
|
||||
|
||||
result = await self._make_request('POST', 'createInvoice', data)
|
||||
|
||||
if result:
|
||||
logger.info(f"Создан CryptoBot invoice {result.get('invoice_id')} на {amount} {asset}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_invoices(
|
||||
self,
|
||||
asset: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
offset: int = 0,
|
||||
count: int = 100
|
||||
) -> Optional[list]:
|
||||
|
||||
data = {
|
||||
'offset': offset,
|
||||
'count': count
|
||||
}
|
||||
|
||||
if asset:
|
||||
data['asset'] = asset
|
||||
|
||||
if status:
|
||||
data['status'] = status
|
||||
|
||||
return await self._make_request('GET', 'getInvoices', data)
|
||||
|
||||
async def get_balance(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getBalance')
|
||||
|
||||
async def get_exchange_rates(self) -> Optional[list]:
|
||||
return await self._make_request('GET', 'getExchangeRates')
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("CryptoBot webhook secret не настроен")
|
||||
return True
|
||||
|
||||
try:
|
||||
secret_hash = hashlib.sha256(self.webhook_secret.encode()).digest()
|
||||
expected_signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ CryptoBot webhook подпись валидна")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись CryptoBot webhook")
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи CryptoBot webhook: {e}")
|
||||
return False
|
||||
|
||||
async def process_webhook(self, webhook_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
try:
|
||||
update_type = webhook_data.get('update_type')
|
||||
|
||||
if update_type == 'invoice_paid':
|
||||
invoice_data = webhook_data.get('payload', {})
|
||||
|
||||
return {
|
||||
'event_type': 'payment',
|
||||
'payment_id': str(invoice_data.get('invoice_id')),
|
||||
'amount': invoice_data.get('amount'),
|
||||
'asset': invoice_data.get('asset'),
|
||||
'status': 'paid',
|
||||
'user_payload': invoice_data.get('payload'),
|
||||
'paid_at': invoice_data.get('paid_at'),
|
||||
'payment_system': 'cryptobot'
|
||||
}
|
||||
|
||||
logger.warning(f"Неизвестный тип CryptoBot webhook: {update_type}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки CryptoBot webhook: {e}")
|
||||
return None
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
@@ -45,8 +46,8 @@ class TelegramStarsService:
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽) "
|
||||
f"для {chat_id}, курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
|
||||
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"(~{amount_rubles:.2f}₽), курс: {settings.get_stars_rate():.2f}₽/⭐"
|
||||
f"(~{int(amount_rubles)}₽), курс: {stars_rate}₽/⭐"
|
||||
)
|
||||
return {
|
||||
"message_id": message.message_id,
|
||||
|
||||
@@ -10,10 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TributeService:
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = settings.TRIBUTE_API_KEY
|
||||
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
|
||||
self.donate_link = settings.TRIBUTE_DONATE_LINK
|
||||
|
||||
async def create_payment_link(
|
||||
@@ -38,27 +37,27 @@ class TributeService:
|
||||
return None
|
||||
|
||||
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
|
||||
|
||||
if not self.webhook_secret:
|
||||
logger.warning("Webhook secret не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("API key не настроен, пропускаем проверку")
|
||||
return True
|
||||
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
self.webhook_secret.encode(),
|
||||
self.api_key.encode(),
|
||||
payload.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
is_valid = hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
|
||||
if is_valid:
|
||||
logger.info("✅ Подпись Tribute webhook проверена успешно")
|
||||
else:
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
|
||||
|
||||
return is_valid
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки подписи webhook: {e}")
|
||||
return False
|
||||
|
||||
@@ -25,12 +25,20 @@ class WebhookServer:
|
||||
self.app = web.Application()
|
||||
|
||||
self.app.router.add_post(settings.TRIBUTE_WEBHOOK_PATH, self._tribute_webhook_handler)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
|
||||
|
||||
self.app.router.add_get('/health', self._health_check)
|
||||
|
||||
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
|
||||
if settings.is_cryptobot_enabled():
|
||||
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
|
||||
|
||||
logger.info(f"Webhook сервер настроен:")
|
||||
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
logger.info(f" - Health check: GET /health")
|
||||
|
||||
return self.app
|
||||
@@ -52,11 +60,13 @@ class WebhookServer:
|
||||
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"✅ Tribute webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"🎯 Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
logger.info(f"Webhook сервер запущен на порту {settings.TRIBUTE_WEBHOOK_PORT}")
|
||||
logger.info(f"Tribute webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_WEBHOOK_PATH}")
|
||||
if settings.is_cryptobot_enabled():
|
||||
logger.info(f"CryptoBot webhook URL: http://0.0.0.0:{settings.TRIBUTE_WEBHOOK_PORT}{settings.CRYPTOBOT_WEBHOOK_PATH}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка запуска Tribute webhook сервера: {e}")
|
||||
logger.error(f"Ошибка запуска webhook сервера: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
@@ -64,14 +74,14 @@ class WebhookServer:
|
||||
try:
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
logger.info("Tribute webhook сайт остановлен")
|
||||
logger.info("Webhook сайт остановлен")
|
||||
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("Tribute webhook runner очищен")
|
||||
logger.info("Webhook runner очищен")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки Tribute webhook сервера: {e}")
|
||||
logger.error(f"Ошибка остановки webhook сервера: {e}")
|
||||
|
||||
async def _options_handler(self, request: web.Request) -> web.Response:
|
||||
return web.Response(
|
||||
@@ -79,65 +89,138 @@ class WebhookServer:
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
|
||||
}
|
||||
)
|
||||
|
||||
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"📋 Headers: {dict(request.headers)}")
|
||||
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("⚠️ Получен пустой webhook от Tribute")
|
||||
logger.warning("Получен пустой webhook от Tribute")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"📄 Payload: {payload}")
|
||||
logger.info(f"Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"📊 Распарсенные данные: {webhook_data}")
|
||||
logger.info(f"Распарсенные данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ Ошибка парсинга JSON: {e}")
|
||||
logger.error(f"Ошибка парсинга JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('X-Tribute-Signature')
|
||||
logger.info(f"🔐 Signature: {signature}")
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
signature = request.headers.get('trbt-signature')
|
||||
logger.info(f"Signature: {signature}")
|
||||
|
||||
if not signature:
|
||||
logger.error("Отсутствует заголовок подписи Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "missing_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
tribute_api = TributeAPI()
|
||||
if not tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.error("❌ Неверная подпись Tribute webhook")
|
||||
logger.error("Неверная подпись Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload, signature)
|
||||
|
||||
result = await self.tribute_service.process_webhook(payload)
|
||||
|
||||
if result:
|
||||
logger.info(f"✅ Tribute webhook обработан успешно: {result}")
|
||||
logger.info(f"Tribute webhook обработан успешно: {result}")
|
||||
return web.json_response({"status": "ok", "result": result}, status=200)
|
||||
else:
|
||||
logger.error("❌ Ошибка обработки Tribute webhook")
|
||||
logger.error("Ошибка обработки Tribute webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
|
||||
|
||||
try:
|
||||
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
|
||||
logger.info(f"Headers: {dict(request.headers)}")
|
||||
|
||||
raw_body = await request.read()
|
||||
|
||||
if not raw_body:
|
||||
logger.warning("Получен пустой CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "empty_body"},
|
||||
status=400
|
||||
)
|
||||
|
||||
payload = raw_body.decode('utf-8')
|
||||
logger.info(f"CryptoBot Payload: {payload}")
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
logger.info(f"CryptoBot данные: {webhook_data}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_json"},
|
||||
status=400
|
||||
)
|
||||
|
||||
signature = request.headers.get('Crypto-Pay-API-Signature')
|
||||
logger.info(f"CryptoBot Signature: {signature}")
|
||||
|
||||
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
cryptobot_service = CryptoBotService()
|
||||
if not cryptobot_service.verify_webhook_signature(payload, signature):
|
||||
logger.error("Неверная подпись CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "invalid_signature"},
|
||||
status=401
|
||||
)
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
payment_service = PaymentService(self.bot)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
|
||||
|
||||
if result:
|
||||
logger.info(f"CryptoBot webhook обработан успешно")
|
||||
return web.json_response({"status": "ok"}, status=200)
|
||||
else:
|
||||
logger.error("Ошибка обработки CryptoBot webhook")
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "processing_failed"},
|
||||
status=400
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"status": "error", "reason": "internal_error", "message": str(e)},
|
||||
status=500
|
||||
@@ -147,8 +230,10 @@ class WebhookServer:
|
||||
|
||||
return web.json_response({
|
||||
"status": "ok",
|
||||
"service": "tribute-webhooks",
|
||||
"service": "payment-webhooks",
|
||||
"tribute_enabled": settings.TRIBUTE_ENABLED,
|
||||
"cryptobot_enabled": settings.is_cryptobot_enabled(),
|
||||
"port": settings.TRIBUTE_WEBHOOK_PORT,
|
||||
"path": settings.TRIBUTE_WEBHOOK_PATH
|
||||
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
|
||||
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
)
|
||||
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class MaintenanceStates(StatesGroup):
|
||||
waiting_for_reason = State()
|
||||
waiting_for_notification_message = State()
|
||||
|
||||
|
||||
@admin_required
|
||||
@@ -30,6 +31,14 @@ async def show_maintenance_panel(
|
||||
|
||||
status_info = maintenance_service.get_status_info()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
panel_status = await rw_service.get_panel_status_summary()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса панели: {e}")
|
||||
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
|
||||
|
||||
status_emoji = "🔧" if status_info["is_active"] else "✅"
|
||||
status_text = "Включен" if status_info["is_active"] else "Выключен"
|
||||
|
||||
@@ -55,14 +64,19 @@ async def show_maintenance_panel(
|
||||
if status_info["consecutive_failures"] > 0:
|
||||
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
|
||||
|
||||
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
|
||||
if panel_status.get("response_time"):
|
||||
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
|
||||
|
||||
message_text = f"""
|
||||
🔧 <b>Режим технических работ</b>
|
||||
🔧 <b>Управление техническими работами</b>
|
||||
|
||||
{status_emoji} <b>Статус:</b> {status_text}
|
||||
{api_emoji} <b>API RemnaWave:</b> {api_text}
|
||||
{status_emoji} <b>Режим техработ:</b> {status_text}
|
||||
{api_emoji} <b>API Remnawave:</b> {api_text}
|
||||
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
|
||||
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
|
||||
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
|
||||
{panel_info}
|
||||
{enabled_info}
|
||||
{last_check_info}
|
||||
{failures_info}
|
||||
@@ -72,7 +86,12 @@ async def show_maintenance_panel(
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
|
||||
reply_markup=get_maintenance_keyboard(
|
||||
db_user.language,
|
||||
status_info["is_active"],
|
||||
status_info["monitoring_active"],
|
||||
panel_status.get("has_issues", False)
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -186,6 +205,166 @@ async def force_api_check(
|
||||
await show_maintenance_panel(callback, db_user, db, None)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_panel_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("Проверка статуса панели...", show_alert=False)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
status_data = await rw_service.check_panel_health()
|
||||
|
||||
status_text = {
|
||||
"online": "🟢 Панель работает нормально",
|
||||
"offline": "🔴 Панель недоступна",
|
||||
"degraded": "🟡 Панель работает со сбоями"
|
||||
}.get(status_data["status"], "❓ Статус неизвестен")
|
||||
|
||||
message_parts = [
|
||||
f"🌐 <b>Статус панели Remnawave</b>\n",
|
||||
f"{status_text}",
|
||||
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
|
||||
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
|
||||
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
|
||||
]
|
||||
|
||||
if status_data.get("api_error"):
|
||||
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
await callback.message.answer(message, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
await state.set_state(MaintenanceStates.waiting_for_notification_message)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
|
||||
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
|
||||
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
|
||||
],
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_manual_notification(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
status_map = {
|
||||
"manual_notify_online": "online",
|
||||
"manual_notify_offline": "offline",
|
||||
"manual_notify_degraded": "degraded",
|
||||
"manual_notify_maintenance": "maintenance"
|
||||
}
|
||||
|
||||
status = status_map.get(callback.data)
|
||||
if not status:
|
||||
await callback.answer("Неизвестный статус")
|
||||
return
|
||||
|
||||
await state.update_data(notification_status=status)
|
||||
|
||||
status_names = {
|
||||
"online": "🟢 Онлайн",
|
||||
"offline": "🔴 Офлайн",
|
||||
"degraded": "🟡 Проблемы",
|
||||
"maintenance": "🔧 Обслуживание"
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
|
||||
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_notification_message(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
current_state = await state.get_state()
|
||||
|
||||
if current_state != MaintenanceStates.waiting_for_notification_message:
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
status = data.get("notification_status")
|
||||
|
||||
if not status:
|
||||
await message.answer("Ошибка: статус не выбран")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
notification_message = ""
|
||||
if message.text and message.text != "/skip":
|
||||
notification_message = message.text[:300]
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
rw_service = RemnaWaveService()
|
||||
|
||||
success = await rw_service.send_manual_status_notification(
|
||||
message.bot,
|
||||
status,
|
||||
notification_message
|
||||
)
|
||||
|
||||
if success:
|
||||
await message.answer("✅ Уведомление отправлено")
|
||||
else:
|
||||
await message.answer("❌ Ошибка отправки уведомления")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки ручного уведомления: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
"Вернуться к панели техработ:",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def back_to_admin_panel(
|
||||
@@ -224,6 +403,21 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "maintenance_check_api"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_panel_status,
|
||||
F.data == "maintenance_check_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
send_manual_notification,
|
||||
F.data == "maintenance_manual_notify"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_manual_notification,
|
||||
F.data.startswith("manual_notify_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
back_to_admin_panel,
|
||||
F.data == "admin_panel"
|
||||
@@ -233,3 +427,8 @@ def register_handlers(dp: Dispatcher):
|
||||
process_maintenance_reason,
|
||||
MaintenanceStates.waiting_for_reason
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_notification_message,
|
||||
MaintenanceStates.waiting_for_notification_message
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -34,7 +34,7 @@ async def show_remnawave_menu(
|
||||
status_emoji = "✅" if connection_test["status"] == "connected" else "❌"
|
||||
|
||||
text = f"""
|
||||
🖥️ <b>Управление RemnaWave</b>
|
||||
🖥️ <b>Управление Remnawave</b>
|
||||
|
||||
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
|
||||
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
|
||||
@@ -116,7 +116,7 @@ async def show_system_stats(
|
||||
return f" (🔺 {difference_str})"
|
||||
|
||||
text = f"""
|
||||
📊 <b>Детальная статистика RemnaWave</b>
|
||||
📊 <b>Детальная статистика Remnawave</b>
|
||||
|
||||
🖥️ <b>Сервер:</b>
|
||||
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
|
||||
@@ -238,7 +238,7 @@ async def show_traffic_stats(
|
||||
return f" 🔺 {diff_str}"
|
||||
|
||||
text = f"""
|
||||
📊 <b>Статистика трафика RemnaWave</b>
|
||||
📊 <b>Статистика трафика Remnawave</b>
|
||||
|
||||
⚡ <b>Реалтайм данные:</b>
|
||||
- Скачивание: {format_bytes(total_realtime_download)}
|
||||
@@ -1247,7 +1247,7 @@ async def finish_squad_creation(
|
||||
f"Сквад готов к использованию!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📋 Список сквадов", callback_data="admin_rw_squads")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К панели RemnaWave", callback_data="admin_remnawave")]
|
||||
[types.InlineKeyboardButton(text="⬅️ К панели Remnawave", callback_data="admin_remnawave")]
|
||||
])
|
||||
)
|
||||
await callback.answer("✅ Сквад создан!")
|
||||
@@ -1320,7 +1320,7 @@ async def show_sync_options(
|
||||
db: AsyncSession
|
||||
):
|
||||
text = """
|
||||
🔄 <b>Синхронизация с RemnaWave</b>
|
||||
🔄 <b>Синхронизация с Remnawave</b>
|
||||
|
||||
🔄 <b>Полная синхронизация выполняет:</b>
|
||||
• Создание новых пользователей из панели в боте
|
||||
@@ -1360,7 +1360,7 @@ async def sync_all_users(
|
||||
🔄 <b>Выполняется полная синхронизация...</b>
|
||||
|
||||
📋 Этапы:
|
||||
• Загрузка ВСЕХ пользователей из панели RemnaWave
|
||||
• Загрузка ВСЕХ пользователей из панели Remnawave
|
||||
• Создание новых пользователей в боте
|
||||
• Обновление существующих пользователей
|
||||
• Деактивация подписок отсутствующих пользователей
|
||||
@@ -1401,7 +1401,7 @@ async def sync_all_users(
|
||||
|
||||
🗑️ <b>Деактивированные подписки:</b>
|
||||
Деактивированы подписки пользователей, которые
|
||||
отсутствуют в панели RemnaWave.
|
||||
отсутствуют в панели Remnawave.
|
||||
💰 Балансы пользователей сохранены.
|
||||
"""
|
||||
|
||||
@@ -1546,7 +1546,7 @@ async def validate_subscriptions(
|
||||
if stats['fixed'] > 0:
|
||||
text += "\n✅ <b>Исправленные проблемы:</b>\n"
|
||||
text += "• Статусы просроченных подписок\n"
|
||||
text += "• Отсутствующие данные RemnaWave\n"
|
||||
text += "• Отсутствующие данные Remnawave\n"
|
||||
text += "• Некорректные лимиты трафика\n"
|
||||
text += "• Настройки устройств\n"
|
||||
|
||||
@@ -1600,7 +1600,7 @@ async def cleanup_subscriptions(
|
||||
if stats['deactivated'] > 0:
|
||||
text += f"\n🗑️ <b>Деактивированные подписки:</b>\n"
|
||||
text += f"Отключены подписки пользователей, которые\n"
|
||||
text += f"отсутствуют в панели RemnaWave.\n"
|
||||
text += f"отсутствуют в панели Remnawave.\n"
|
||||
else:
|
||||
text += f"\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено."
|
||||
|
||||
@@ -1664,8 +1664,8 @@ async def force_cleanup_all_orphaned_users(
|
||||
• Удалены использования промокодов
|
||||
• Сброшены балансы к нулю
|
||||
• Удалены подключенные серверы
|
||||
• Сброшены HWID устройства в RemnaWave
|
||||
• Очищены RemnaWave UUID
|
||||
• Сброшены HWID устройства в Remnawave
|
||||
• Очищены Remnawave UUID
|
||||
"""
|
||||
else:
|
||||
text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью."
|
||||
@@ -1703,8 +1703,8 @@ async def confirm_force_cleanup(
|
||||
• ВСЕ использования промокодов
|
||||
• ВСЕ подключенные серверы подписок
|
||||
• ВСЕ балансы (сброс к нулю)
|
||||
• ВСЕ HWID устройства в RemnaWave
|
||||
• ВСЕ RemnaWave UUID и ссылки
|
||||
• ВСЕ HWID устройства в Remnawave
|
||||
• ВСЕ Remnawave UUID и ссылки
|
||||
|
||||
⚡ <b>Это действие НЕОБРАТИМО!</b>
|
||||
|
||||
@@ -1813,7 +1813,7 @@ async def sync_users(
|
||||
if sync_type == "all_users" and 'deleted' in stats and stats['deleted'] > 0:
|
||||
text += f"\n🗑️ <b>Удаленные подписки:</b>\n"
|
||||
text += f"Деактивированы подписки пользователей,\n"
|
||||
text += f"которые отсутствуют в панели RemnaWave."
|
||||
text += f"которые отсутствуют в панели Remnawave."
|
||||
|
||||
text += f"\n\n💡 <b>Рекомендации:</b>\n"
|
||||
if sync_type == "all_users":
|
||||
|
||||
@@ -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)
|
||||
@@ -37,7 +37,7 @@ async def show_servers_menu(
|
||||
• С подключениями: {stats['servers_with_connections']}
|
||||
|
||||
💰 <b>Выручка от серверов:</b>
|
||||
• Общая: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Общая: {int(stats['total_revenue_rubles'])} ₽
|
||||
|
||||
Выберите действие:
|
||||
"""
|
||||
@@ -83,7 +83,7 @@ async def show_servers_list(
|
||||
|
||||
for i, server in enumerate(servers, 1 + (page - 1) * 10):
|
||||
status_emoji = "✅" if server.is_available else "❌"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text += f"{i}. {status_emoji} {server.display_name}\n"
|
||||
text += f" 💰 Цена: {price_text}"
|
||||
@@ -147,7 +147,7 @@ async def sync_servers_with_remnawave(
|
||||
):
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
|
||||
"🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.",
|
||||
reply_markup=None
|
||||
)
|
||||
|
||||
@@ -157,7 +157,7 @@ async def sync_servers_with_remnawave(
|
||||
|
||||
if not squads:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
|
||||
"❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
|
||||
])
|
||||
@@ -222,7 +222,7 @@ async def show_server_edit_menu(
|
||||
return
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
@@ -304,7 +304,7 @@ async def toggle_server_availability(
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
|
||||
status_emoji = "✅ Доступен" if server.is_available else "❌ Недоступен"
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
text = f"""
|
||||
🌐 <b>Редактирование сервера</b>
|
||||
@@ -378,7 +378,7 @@ async def start_server_edit_price(
|
||||
await state.set_data({'server_id': server_id})
|
||||
await state.set_state(AdminStates.editing_server_price)
|
||||
|
||||
current_price = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
current_price = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"💰 <b>Редактирование цены</b>\n\n"
|
||||
@@ -424,7 +424,7 @@ async def process_server_price_edit(
|
||||
|
||||
await cache.delete("available_countries")
|
||||
|
||||
price_text = f"{price_rubles:.2f} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(price_rubles)} ₽" if price_kopeks > 0 else "Бесплатно"
|
||||
await message.answer(
|
||||
f"✅ Цена сервера изменена на: <b>{price_text}</b>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -613,8 +613,8 @@ async def show_server_detailed_stats(
|
||||
• С активными подключениями: {stats['servers_with_connections']}
|
||||
|
||||
<b>💰 Финансовая статистика:</b>
|
||||
• Общая выручка: {stats['total_revenue_rubles']:.2f} ₽
|
||||
• Средняя цена за сервер: {(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1)):.2f} ₽
|
||||
• Общая выручка: {int(stats['total_revenue_rubles'])} ₽
|
||||
• Средняя цена за сервер: {int(stats['total_revenue_rubles'] / max(stats['servers_with_connections'], 1))} ₽
|
||||
|
||||
<b>🔥 Топ серверов по цене:</b>
|
||||
"""
|
||||
@@ -622,7 +622,7 @@ async def show_server_detailed_stats(
|
||||
sorted_servers = sorted(available_servers, key=lambda x: x.price_kopeks, reverse=True)
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f"{server.price_rubles:.2f} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
price_text = f"{int(server.price_rubles)} ₽" if server.price_kopeks > 0 else "Бесплатно"
|
||||
text += f"{i}. {server.display_name} - {price_text}\n"
|
||||
|
||||
if not sorted_servers:
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
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.version_service import version_service
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_updates_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Проверить обновления",
|
||||
callback_data="admin_updates_check"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Информация о версии",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Открыть репозиторий",
|
||||
url=f"https://github.com/{version_service.repo}/releases"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ Назад",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_version_info_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
buttons = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="◀️ К обновлениям",
|
||||
callback_data="admin_updates"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_updates_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
has_updates = version_info['has_updates']
|
||||
total_newer = version_info['total_newer']
|
||||
last_check = version_info['last_check']
|
||||
|
||||
status_icon = "🆕" if has_updates else "✅"
|
||||
status_text = f"Доступно {total_newer} обновлений" if has_updates else "Актуальная версия"
|
||||
|
||||
last_check_text = ""
|
||||
if last_check:
|
||||
last_check_text = f"\n🕐 Последняя проверка: {last_check.strftime('%d.%m.%Y %H:%M')}"
|
||||
|
||||
message = f"""🔄 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
{status_icon} <b>Статус:</b> {status_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}{last_check_text}
|
||||
|
||||
ℹ️ Система автоматически проверяет обновления каждый час и отправляет уведомления о новых версиях."""
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка показа меню обновлений: {e}")
|
||||
await callback.answer("❌ Ошибка загрузки меню обновлений", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def check_updates(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("🔄 Проверяю обновления...")
|
||||
|
||||
try:
|
||||
has_updates, newer_releases = await version_service.check_for_updates(force=True)
|
||||
|
||||
if not has_updates:
|
||||
message = f"""✅ <b>ОБНОВЛЕНИЯ НЕ НАЙДЕНЫ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Статус:</b> У вас установлена последняя версия
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
else:
|
||||
updates_list = []
|
||||
for i, release in enumerate(newer_releases[:5]):
|
||||
icon = version_service.format_version_display(release).split()[0]
|
||||
updates_list.append(
|
||||
f"{i+1}. {icon} <code>{release.tag_name}</code> • {release.formatted_date}"
|
||||
)
|
||||
|
||||
updates_text = "\n".join(updates_list)
|
||||
more_text = f"\n\n📋 И еще {len(newer_releases) - 5} обновлений..." if len(newer_releases) > 5 else ""
|
||||
|
||||
message = f"""🆕 <b>НАЙДЕНЫ ОБНОВЛЕНИЯ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>
|
||||
🎯 <b>Доступно обновлений:</b> {len(newer_releases)}
|
||||
|
||||
📋 <b>Последние версии:</b>
|
||||
{updates_text}{more_text}
|
||||
|
||||
🔗 <b>Репозиторий:</b> {version_service.repo}"""
|
||||
|
||||
keyboard = get_updates_keyboard(db_user.language)
|
||||
|
||||
if has_updates:
|
||||
keyboard.inline_keyboard.insert(-2, [
|
||||
InlineKeyboardButton(
|
||||
text="📋 Подробнее о версиях",
|
||||
callback_data="admin_updates_info"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки обновлений: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n"
|
||||
f"Не удалось связаться с сервером GitHub.\n"
|
||||
f"Попробуйте позже.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_updates_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_version_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
await callback.answer("📋 Загружаю информацию о версиях...")
|
||||
|
||||
try:
|
||||
version_info = await version_service.get_version_info()
|
||||
|
||||
current_version = version_info['current_version']
|
||||
current_release = version_info['current_release']
|
||||
newer_releases = version_info['newer_releases']
|
||||
has_updates = version_info['has_updates']
|
||||
last_check = version_info['last_check']
|
||||
repo_url = version_info['repo_url']
|
||||
|
||||
current_info = f"📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n"
|
||||
|
||||
if current_release:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_release.tag_name}</code>\n"
|
||||
current_info += f"📅 <b>Дата релиза:</b> {current_release.formatted_date}\n"
|
||||
if current_release.short_description:
|
||||
current_info += f"📝 <b>Описание:</b>\n{current_release.short_description}\n"
|
||||
else:
|
||||
current_info += f"🏷️ <b>Версия:</b> <code>{current_version}</code>\n"
|
||||
current_info += f"ℹ️ <b>Статус:</b> Информация о релизе недоступна\n"
|
||||
|
||||
message_parts = [current_info]
|
||||
|
||||
if has_updates and newer_releases:
|
||||
updates_info = f"\n🆕 <b>ДОСТУПНЫЕ ОБНОВЛЕНИЯ</b>\n\n"
|
||||
|
||||
for i, release in enumerate(newer_releases):
|
||||
icon = "🔥" if i == 0 else "📦"
|
||||
if release.prerelease:
|
||||
icon = "🧪"
|
||||
elif release.is_dev:
|
||||
icon = "🔧"
|
||||
|
||||
updates_info += f"{icon} <b>{release.tag_name}</b>\n"
|
||||
updates_info += f" 📅 {release.formatted_date}\n"
|
||||
if release.short_description:
|
||||
updates_info += f" 📝 {release.short_description}\n"
|
||||
updates_info += "\n"
|
||||
|
||||
message_parts.append(updates_info.rstrip())
|
||||
|
||||
system_info = f"\n🔧 <b>СИСТЕМА ОБНОВЛЕНИЙ</b>\n\n"
|
||||
system_info += f"🔗 <b>Репозиторий:</b> {version_service.repo}\n"
|
||||
system_info += f"⚡ <b>Автопроверка:</b> {'Включена' if version_service.enabled else 'Отключена'}\n"
|
||||
system_info += f"🕐 <b>Интервал:</b> Каждый час\n"
|
||||
|
||||
if last_check:
|
||||
system_info += f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
message_parts.append(system_info.rstrip())
|
||||
|
||||
final_message = "\n".join(message_parts)
|
||||
|
||||
if len(final_message) > 4000:
|
||||
final_message = final_message[:3900] + "\n\n... (информация обрезана)"
|
||||
|
||||
await callback.message.edit_text(
|
||||
final_message,
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о версиях: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n"
|
||||
f"Не удалось получить информацию о версиях.\n\n"
|
||||
f"📦 <b>Текущая версия:</b> <code>{version_service.current_version}</code>",
|
||||
reply_markup=get_version_info_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_updates_menu,
|
||||
F.data == "admin_updates"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
check_updates,
|
||||
F.data == "admin_updates_check"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_version_info,
|
||||
F.data == "admin_updates_info"
|
||||
)
|
||||
@@ -0,0 +1,584 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.filters import StateFilter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.user_message import (
|
||||
create_user_message, get_all_user_messages, get_user_message_by_id,
|
||||
update_user_message, delete_user_message, toggle_user_message_status,
|
||||
get_user_messages_stats
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.keyboards.admin import get_admin_main_keyboard
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserMessageStates(StatesGroup):
|
||||
waiting_for_message_text = State()
|
||||
waiting_for_edit_text = State()
|
||||
|
||||
|
||||
def get_user_messages_keyboard(language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📝 Добавить сообщение",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📋 Список сообщений",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data="user_messages_stats"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад в админку",
|
||||
callback_data="admin_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✏️ Редактировать",
|
||||
callback_data=f"edit_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=status_text,
|
||||
callback_data=f"toggle_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🗑️ Удалить",
|
||||
callback_data=f"delete_user_message:{message_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 К списку",
|
||||
callback_data="list_user_messages:0"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_messages_panel(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
text = (
|
||||
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
|
||||
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
|
||||
"в главном меню между информацией о подписке и кнопками действий.\n\n"
|
||||
"• Сообщения поддерживают HTML теги\n"
|
||||
"• Можно создать несколько сообщений\n"
|
||||
"• Активные сообщения показываются случайно\n"
|
||||
"• Неактивные сообщения не показываются"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def add_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.utils.validators import get_html_help_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📝 <b>Добавление нового сообщения</b>\n\n"
|
||||
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
|
||||
f"{get_html_help_text()}\n\n"
|
||||
f"Отправьте /cancel для отмены.",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(UserMessageStates.waiting_for_message_text)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_new_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Добавление сообщения отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
message_text = message.text.strip()
|
||||
|
||||
if len(message_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
from app.utils.validators import validate_html_tags, get_html_help_text
|
||||
|
||||
is_valid, error_msg = validate_html_tags(message_text)
|
||||
if not is_valid:
|
||||
await message.answer(
|
||||
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
|
||||
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
|
||||
parse_mode=None
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
new_message = await create_user_message(
|
||||
db=db,
|
||||
message_text=message_text,
|
||||
created_by=db_user.id,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение добавлено!</b>\n\n"
|
||||
f"<b>ID:</b> {new_message.id}\n"
|
||||
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
|
||||
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Предварительный просмотр:</b>\n"
|
||||
f"<blockquote>{message_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def list_user_messages(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
page = 0
|
||||
if ":" in callback.data:
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
page = 0
|
||||
|
||||
limit = 5
|
||||
offset = page * limit
|
||||
|
||||
messages = await get_all_user_messages(db, offset=offset, limit=limit)
|
||||
|
||||
if not messages:
|
||||
await callback.message.edit_text(
|
||||
"📋 <b>Список сообщений</b>\n\n"
|
||||
"Сообщений пока нет. Добавьте первое сообщение!",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
text = "📋 <b>Список сообщений</b>\n\n"
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
|
||||
preview = preview.replace('<', '<').replace('>', '>')
|
||||
|
||||
text += (
|
||||
f"{status_emoji} <b>ID {msg.id}</b>\n"
|
||||
f"{preview}\n"
|
||||
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = []
|
||||
|
||||
for msg in messages:
|
||||
status_emoji = "🟢" if msg.is_active else "🔴"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} ID {msg.id}",
|
||||
callback_data=f"view_user_message:{msg.id}"
|
||||
)
|
||||
])
|
||||
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"list_user_messages:{page-1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➕ Добавить",
|
||||
callback_data="add_user_message"
|
||||
)
|
||||
)
|
||||
|
||||
if len(messages) == limit:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперед ➡️",
|
||||
callback_data=f"list_user_messages:{page+1}"
|
||||
)
|
||||
)
|
||||
|
||||
if nav_buttons:
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def view_user_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
|
||||
|
||||
text = (
|
||||
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
|
||||
f"<b>Статус:</b> {status_text}\n"
|
||||
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Содержимое:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_message_actions_keyboard(
|
||||
message_id, message.is_active, db_user.language
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_message_status(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await toggle_user_message_status(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
status_text = "активировано" if message.is_active else "деактивировано"
|
||||
await callback.answer(f"✅ Сообщение {status_text}")
|
||||
|
||||
await view_user_message(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_message_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Подтвердить удаление сообщения"""
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
success = await delete_user_message(db, message_id)
|
||||
|
||||
if success:
|
||||
await callback.answer("✅ Сообщение удалено")
|
||||
await list_user_messages(
|
||||
types.CallbackQuery(
|
||||
id=callback.id,
|
||||
from_user=callback.from_user,
|
||||
chat_instance=callback.chat_instance,
|
||||
data="list_user_messages:0",
|
||||
message=callback.message
|
||||
),
|
||||
db_user,
|
||||
db
|
||||
)
|
||||
else:
|
||||
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_messages_stats(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
stats = await get_user_messages_stats(db)
|
||||
|
||||
text = (
|
||||
"📊 <b>Статистика сообщений</b>\n\n"
|
||||
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
|
||||
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
|
||||
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
|
||||
"Активные сообщения показываются пользователям случайным образом "
|
||||
"в главном меню между информацией о подписке и кнопками действий."
|
||||
)
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔙 Назад",
|
||||
callback_data="user_messages_panel"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def edit_user_message_start(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
message_id = int(callback.data.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
|
||||
return
|
||||
|
||||
message = await get_user_message_by_id(db, message_id)
|
||||
|
||||
if not message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
|
||||
f"<b>Текущий текст:</b>\n"
|
||||
f"<blockquote>{message.message_text}</blockquote>\n\n"
|
||||
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_data({"editing_message_id": message_id})
|
||||
await state.set_state(UserMessageStates.waiting_for_edit_text)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_message_text(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if message.text == "/cancel":
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Редактирование отменено.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
message_id = data.get("editing_message_id")
|
||||
|
||||
if not message_id:
|
||||
await state.clear()
|
||||
await message.answer("❌ Ошибка: ID сообщения не найден")
|
||||
return
|
||||
|
||||
new_text = message.text.strip()
|
||||
|
||||
if len(new_text) > 4000:
|
||||
await message.answer(
|
||||
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
|
||||
"Попробуйте еще раз или отправьте /cancel для отмены."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
updated_message = await update_user_message(
|
||||
db=db,
|
||||
message_id=message_id,
|
||||
message_text=new_text
|
||||
)
|
||||
|
||||
if updated_message:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
f"✅ <b>Сообщение обновлено!</b>\n\n"
|
||||
f"<b>ID:</b> {updated_message.id}\n"
|
||||
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"<b>Новый текст:</b>\n"
|
||||
f"<blockquote>{new_text}</blockquote>",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Сообщение не найдено или ошибка обновления.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления сообщения: {e}")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"❌ Произошла ошибка при обновлении сообщения.",
|
||||
reply_markup=get_user_messages_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_messages_panel,
|
||||
F.data == "user_messages_panel"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
add_user_message_start,
|
||||
F.data == "add_user_message"
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_new_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_message_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
edit_user_message_start,
|
||||
F.data.startswith("edit_user_message:")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_edit_message_text,
|
||||
StateFilter(UserMessageStates.waiting_for_edit_text)
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
list_user_messages,
|
||||
F.data.startswith("list_user_messages")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
view_user_message,
|
||||
F.data.startswith("view_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_message_status,
|
||||
F.data.startswith("toggle_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
delete_message_confirm,
|
||||
F.data.startswith("delete_user_message:")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_messages_stats,
|
||||
F.data == "user_messages_stats"
|
||||
)
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.states import AdminStates
|
||||
from app.database.models import User, UserStatus, Subscription
|
||||
from app.database.models import User, UserStatus, Subscription, SubscriptionStatus, TransactionType
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
@@ -17,6 +18,7 @@ from app.services.user_service import UserService
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.external.remnawave_api import TrafficLimitStrategy
|
||||
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -77,26 +79,52 @@ async def show_users_list(
|
||||
return
|
||||
|
||||
text = f"👥 <b>Список пользователей</b> (стр. {page}/{users_data['total_pages']})\n\n"
|
||||
|
||||
for user in users_data["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n"
|
||||
text += f"📅 {format_time_ago(user.created_at)}\n\n"
|
||||
text += "Нажмите на пользователя для управления:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in users_data["users"]:
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
subscription_emoji = ""
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_emoji = "⏰"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
button_text += f" | 📅 {format_time_ago(user.created_at)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 20:
|
||||
short_name = short_name[:17] + "..."
|
||||
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name}"
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
|
||||
if users_data["total_pages"] > 1:
|
||||
pagination_row = get_admin_pagination_keyboard(
|
||||
users_data["current_page"],
|
||||
@@ -292,6 +320,16 @@ async def show_user_subscription(
|
||||
text="📊 Трафик",
|
||||
callback_data=f"admin_sub_traffic_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Тип подписки",
|
||||
callback_data=f"admin_sub_change_type_{user_id}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="💳 Купить подписку",
|
||||
callback_data=f"admin_sub_buy_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
@@ -467,27 +505,45 @@ async def process_user_search(
|
||||
return
|
||||
|
||||
text = f"🔍 <b>Результаты поиска:</b> '{query}'\n\n"
|
||||
text += "Выберите пользователя:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
for user in search_results["users"]:
|
||||
status_emoji = "✅" if user.status == UserStatus.ACTIVE.value else "❌"
|
||||
subscription_info = ""
|
||||
if user.status == UserStatus.ACTIVE.value:
|
||||
status_emoji = "✅"
|
||||
elif user.status == UserStatus.BLOCKED.value:
|
||||
status_emoji = "🚫"
|
||||
else:
|
||||
status_emoji = "🗑️"
|
||||
|
||||
subscription_emoji = ""
|
||||
if user.subscription:
|
||||
if user.subscription.is_trial:
|
||||
subscription_info = "🎁"
|
||||
subscription_emoji = "🎁"
|
||||
elif user.subscription.is_active:
|
||||
subscription_info = "💎"
|
||||
subscription_emoji = "💎"
|
||||
else:
|
||||
subscription_info = "⏰"
|
||||
subscription_emoji = "⏰"
|
||||
else:
|
||||
subscription_emoji = "❌"
|
||||
|
||||
text += f"{status_emoji} {subscription_info} <b>{user.full_name}</b>\n"
|
||||
text += f"🆔 <code>{user.telegram_id}</code>\n"
|
||||
text += f"💰 {settings.format_price(user.balance_kopeks)}\n\n"
|
||||
button_text = f"{status_emoji} {subscription_emoji} {user.full_name}"
|
||||
|
||||
button_text += f" | 🆔 {user.telegram_id}"
|
||||
|
||||
if user.balance_kopeks > 0:
|
||||
button_text += f" | 💰 {settings.format_price(user.balance_kopeks)}"
|
||||
|
||||
if len(button_text) > 60:
|
||||
short_name = user.full_name
|
||||
if len(short_name) > 15:
|
||||
short_name = short_name[:12] + "..."
|
||||
button_text = f"{status_emoji} {subscription_emoji} {short_name} | 🆔 {user.telegram_id}"
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"👤 {user.full_name}",
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_manage_{user.id}"
|
||||
)
|
||||
])
|
||||
@@ -629,12 +685,13 @@ async def process_balance_edit(
|
||||
|
||||
description = f"Изменение баланса администратором {db_user.full_name}"
|
||||
if amount_kopeks > 0:
|
||||
description = f"Пополнение администратором: +{amount_rubles} ₽"
|
||||
description = f"Пополнение администратором: +{int(amount_rubles)} ₽"
|
||||
else:
|
||||
description = f"Списание администратором: {amount_rubles} ₽"
|
||||
description = f"Списание администратором: {int(amount_rubles)} ₽"
|
||||
|
||||
success = await user_service.update_user_balance(
|
||||
db, user_id, amount_kopeks, description, db_user.id
|
||||
db, user_id, amount_kopeks, description, db_user.id,
|
||||
bot=message.bot, admin_name=db_user.full_name
|
||||
)
|
||||
|
||||
if success:
|
||||
@@ -1593,7 +1650,12 @@ async def toggle_user_server(
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
active_internal_squads=current_squads
|
||||
active_internal_squads=current_squads,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлены серверы в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -1972,7 +2034,12 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
|
||||
async with remnawave_service.api as api:
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
hwid_device_limit=devices
|
||||
hwid_device_limit=devices,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит устройств в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2010,7 +2077,12 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
|
||||
await api.update_user(
|
||||
uuid=user.remnawave_uuid,
|
||||
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Обновлен лимит трафика в RemnaWave для пользователя {user.telegram_id}")
|
||||
except Exception as rw_error:
|
||||
@@ -2206,6 +2278,416 @@ async def cleanup_inactive_users(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def change_subscription_type(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile or not profile["subscription"]:
|
||||
await callback.answer("❌ Пользователь или подписка не найдены", show_alert=True)
|
||||
return
|
||||
|
||||
subscription = profile["subscription"]
|
||||
current_type = "🎁 Триал" if subscription.is_trial else "💎 Платная"
|
||||
|
||||
text = f"🔄 <b>Смена типа подписки</b>\n\n"
|
||||
text += f"👤 {profile['user'].full_name}\n"
|
||||
text += f"📱 Текущий тип: {current_type}\n\n"
|
||||
text += f"Выберите новый тип подписки:"
|
||||
|
||||
keyboard = []
|
||||
|
||||
if subscription.is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💎 Сделать платной",
|
||||
callback_data=f"admin_sub_type_paid_{user_id}"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🎁 Сделать триальной",
|
||||
callback_data=f"admin_sub_type_trial_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_buy_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
target_user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
|
||||
return
|
||||
|
||||
available_periods = settings.get_available_subscription_periods()
|
||||
|
||||
period_buttons = []
|
||||
for period in available_periods:
|
||||
price_attr = f"PRICE_{period}_DAYS"
|
||||
if hasattr(settings, price_attr):
|
||||
price_kopeks = getattr(settings, price_attr)
|
||||
price_rubles = price_kopeks // 100
|
||||
period_buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{period} дней ({price_rubles} ₽)",
|
||||
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
|
||||
)
|
||||
])
|
||||
|
||||
period_buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
text += "Выберите период подписки:\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=period_buttons)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_buy_subscription_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
period_days = int(parts[5])
|
||||
price_kopeks = int(parts[6])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
target_user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
missing_kopeks = price_kopeks - target_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
f"❌ Недостаточно средств на балансе пользователя\n\n"
|
||||
f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n"
|
||||
f"💳 Стоимость подписки: {settings.format_price(price_kopeks)}\n"
|
||||
f"📉 Не хватает: {settings.format_price(missing_kopeks)}\n\n"
|
||||
f"Пополните баланс пользователя перед покупкой.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
price_rubles = price_kopeks // 100
|
||||
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
|
||||
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
text += f"📅 Период подписки: {period_days} дней\n"
|
||||
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
|
||||
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
|
||||
text += "Вы уверены, что хотите купить подписку для этого пользователя?"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="✅ Подтвердить",
|
||||
callback_data=f"admin_buy_sub_execute_{user_id}_{period_days}_{price_kopeks}"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"admin_sub_buy_{user_id}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def admin_buy_subscription_execute(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[4])
|
||||
period_days = int(parts[5])
|
||||
price_kopeks = int(parts[6])
|
||||
|
||||
user_service = UserService()
|
||||
profile = await user_service.get_user_profile(db, user_id)
|
||||
|
||||
if not profile:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
target_user = profile["user"]
|
||||
subscription = profile["subscription"]
|
||||
|
||||
if target_user.balance_kopeks < price_kopeks:
|
||||
await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
success = await subtract_user_balance(
|
||||
db, target_user, price_kopeks,
|
||||
f"Покупка подписки на {period_days} дней (администратор)"
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer("❌ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
if subscription:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
if subscription.end_date <= current_time:
|
||||
subscription.start_date = current_time
|
||||
|
||||
subscription.end_date = current_time + timedelta(days=period_days)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
if subscription.is_trial or not subscription.is_active:
|
||||
subscription.is_trial = False
|
||||
if subscription.traffic_limit_gb != 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
if subscription.is_trial:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=target_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price_kopeks,
|
||||
description=f"Продление подписки на {period_days} дней (администратор)"
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.external.remnawave_api import UserStatus, TrafficLimitStrategy
|
||||
remnawave_service = RemnaWaveService()
|
||||
|
||||
if target_user.remnawave_uuid:
|
||||
async with remnawave_service.api as api:
|
||||
remnawave_user = await api.update_user(
|
||||
uuid=target_user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
else:
|
||||
username = f"user_{target_user.telegram_id}"
|
||||
async with remnawave_service.api as api:
|
||||
remnawave_user = await api.create_user(
|
||||
username=username,
|
||||
expire_at=subscription.end_date,
|
||||
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
|
||||
traffic_limit_bytes=subscription.traffic_limit_gb * (1024**3) if subscription.traffic_limit_gb > 0 else 0,
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
telegram_id=target_user.telegram_id,
|
||||
hwid_device_limit=subscription.device_limit,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=target_user.full_name,
|
||||
username=target_user.username,
|
||||
telegram_id=target_user.telegram_id
|
||||
),
|
||||
active_internal_squads=subscription.connected_squads
|
||||
)
|
||||
|
||||
if remnawave_user and hasattr(remnawave_user, 'uuid'):
|
||||
target_user.remnawave_uuid = remnawave_user.uuid
|
||||
await db.commit()
|
||||
|
||||
if remnawave_user:
|
||||
logger.info(f"Пользователь {target_user.telegram_id} успешно обновлен в RemnaWave")
|
||||
else:
|
||||
logger.error(f"Ошибка обновления пользователя {target_user.telegram_id} в RemnaWave")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка работы с RemnaWave для пользователя {target_user.telegram_id}: {e}")
|
||||
|
||||
message = f"✅ Подписка пользователя продлена на {period_days} дней"
|
||||
else:
|
||||
message = "❌ Ошибка: у пользователя нет существующей подписки"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{message}\n\n"
|
||||
f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
|
||||
f"💰 Списано: {settings.format_price(price_kopeks)}\n"
|
||||
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="⬅️ Назад к подписке",
|
||||
callback_data=f"admin_user_subscription_{user_id}"
|
||||
)]
|
||||
])
|
||||
)
|
||||
|
||||
try:
|
||||
if callback.bot:
|
||||
await callback.bot.send_message(
|
||||
chat_id=target_user.telegram_id,
|
||||
text=f"💳 <b>Администратор продлил вашу подписку</b>\n\n"
|
||||
f"📅 Подписка продлена на {period_days} дней\n"
|
||||
f"💰 Списано с баланса: {settings.format_price(price_kopeks)}\n"
|
||||
f"📅 Подписка действительна до: {format_datetime(subscription.end_date)}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления пользователю {target_user.telegram_id}: {e}")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка покупки подписки администратором: {e}")
|
||||
await callback.answer("❌ Ошибка при покупке подписки", show_alert=True)
|
||||
|
||||
await db.rollback()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def change_subscription_type_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
parts = callback.data.split('_')
|
||||
new_type = parts[-2] # 'paid' или 'trial'
|
||||
user_id = int(parts[-1])
|
||||
|
||||
success = await _change_subscription_type(db, user_id, new_type, db_user.id)
|
||||
|
||||
if success:
|
||||
type_text = "платной" if new_type == "paid" else "триальной"
|
||||
await callback.message.edit_text(
|
||||
f"✅ Тип подписки успешно изменен на {type_text}",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
"❌ Ошибка изменения типа подписки",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📱 К подписке", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
])
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _change_subscription_type(db: AsyncSession, user_id: int, new_type: str, admin_id: int) -> bool:
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
logger.error(f"Подписка не найдена для пользователя {user_id}")
|
||||
return False
|
||||
|
||||
new_is_trial = (new_type == "trial")
|
||||
|
||||
if subscription.is_trial == new_is_trial:
|
||||
logger.info(f"Тип подписки уже установлен корректно для пользователя {user_id}")
|
||||
return True
|
||||
|
||||
old_type = "триальной" if subscription.is_trial else "платной"
|
||||
new_type_text = "триальной" if new_is_trial else "платной"
|
||||
|
||||
subscription.is_trial = new_is_trial
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
if not new_is_trial and subscription.is_trial:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user:
|
||||
user.has_had_paid_subscription = True
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
logger.info(f"Админ {admin_id} изменил тип подписки пользователя {user_id}: {old_type} -> {new_type_text}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения типа подписки: {e}")
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -2429,3 +2911,33 @@ def register_handlers(dp: Dispatcher):
|
||||
reset_user_devices,
|
||||
F.data.startswith("admin_user_reset_devices_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
change_subscription_type,
|
||||
F.data.startswith("admin_sub_change_type_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
change_subscription_type_confirm,
|
||||
F.data.startswith("admin_sub_type_")
|
||||
)
|
||||
|
||||
# Регистрация обработчика покупки подписки администратором
|
||||
dp.callback_query.register(
|
||||
admin_buy_subscription,
|
||||
F.data.startswith("admin_sub_buy_")
|
||||
)
|
||||
|
||||
# Регистрация дополнительных обработчиков для покупки подписки
|
||||
dp.callback_query.register(
|
||||
admin_buy_subscription_confirm,
|
||||
F.data.startswith("admin_buy_sub_confirm_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
admin_buy_subscription_execute,
|
||||
F.data.startswith("admin_buy_sub_execute_")
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -25,6 +25,49 @@ logger = logging.getLogger(__name__)
|
||||
TRANSACTIONS_PER_PAGE = 10
|
||||
|
||||
|
||||
def get_quick_amount_buttons(language: str) -> list:
|
||||
"""
|
||||
Генерирует кнопки быстрого выбора суммы пополнения на основе
|
||||
AVAILABLE_SUBSCRIPTION_PERIODS и PRICE_*_DAYS
|
||||
"""
|
||||
if not settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED:
|
||||
return []
|
||||
|
||||
buttons = []
|
||||
periods = settings.get_available_subscription_periods()
|
||||
|
||||
# Ограничиваем до 6 кнопок (3 ряда по 2 кнопки)
|
||||
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 для каждой кнопки
|
||||
callback_data = f"quick_amount_{price_kopeks}"
|
||||
|
||||
# Добавляем кнопку
|
||||
buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{price_rubles} ₽ ({period} дней)",
|
||||
callback_data=callback_data
|
||||
)
|
||||
)
|
||||
|
||||
# Разбиваем кнопки на ряды (по 2 в ряд)
|
||||
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 +192,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()
|
||||
|
||||
await callback.message.edit_text(
|
||||
payment_text,
|
||||
@@ -171,6 +205,20 @@ 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(
|
||||
"⚠️ В данный момент автоматические способы оплаты временно недоступны. "
|
||||
"Для пополнения баланса обратитесь в техподдержку.",
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_stars_payment(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -205,10 +253,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 +290,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,
|
||||
@@ -331,12 +453,31 @@ 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:
|
||||
await process_cryptobot_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
@@ -356,7 +497,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:
|
||||
@@ -364,6 +505,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(
|
||||
@@ -381,7 +523,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"📊 Курс: {settings.get_stars_rate():.2f}₽ за звезду\n\n"
|
||||
f"📊 Курс: {stars_rate}₽ за звезду\n\n"
|
||||
f"Нажмите кнопку ниже для оплаты:",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
@@ -391,7 +533,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
|
||||
@@ -408,8 +551,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:
|
||||
@@ -465,7 +614,7 @@ async def process_yookassa_payment_amount(
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
f"{amount_kopeks//100}₽, ID: {payment_result['yookassa_payment_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания YooKassa платежа: {e}")
|
||||
@@ -473,6 +622,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.SUPPORT_USERNAME}",
|
||||
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,
|
||||
@@ -526,6 +760,283 @@ async def check_yookassa_payment_status(
|
||||
logger.error(f"Ошибка проверки статуса платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
@error_handler
|
||||
async def start_cryptobot_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
from app.utils.currency_converter import currency_converter
|
||||
try:
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
rate_text = f"💱 Текущий курс: 1 USD = {current_rate:.2f} ₽"
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить курс валют: {e}")
|
||||
current_rate = 95.0
|
||||
rate_text = f"💱 Курс: 1 USD ≈ {current_rate:.0f} ₽"
|
||||
|
||||
available_assets = settings.get_cryptobot_assets()
|
||||
assets_text = ", ".join(available_assets)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🪙 <b>Пополнение криптовалютой</b>\n\n"
|
||||
f"Введите сумму для пополнения в рублях от 100 до 100,000 ₽:\n\n"
|
||||
f"💰 Доступные активы: {assets_text}\n"
|
||||
f"⚡ Мгновенное зачисление на баланс\n"
|
||||
f"🔒 Безопасная оплата через CryptoBot\n\n"
|
||||
f"{rate_text}\n"
|
||||
f"Сумма будет автоматически конвертирована в USD для оплаты.",
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="cryptobot", current_rate=current_rate)
|
||||
await callback.answer()
|
||||
|
||||
@error_handler
|
||||
async def process_cryptobot_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_cryptobot_enabled():
|
||||
await message.answer("❌ Оплата криптовалютой временно недоступна")
|
||||
return
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
if amount_rubles < 100:
|
||||
await message.answer("Минимальная сумма пополнения: 100 ₽")
|
||||
return
|
||||
|
||||
if amount_rubles > 100000:
|
||||
await message.answer("Максимальная сумма пополнения: 100,000 ₽")
|
||||
return
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
current_rate = data.get('current_rate')
|
||||
|
||||
if not current_rate:
|
||||
from app.utils.currency_converter import currency_converter
|
||||
current_rate = await currency_converter.get_usd_to_rub_rate()
|
||||
|
||||
amount_usd = amount_rubles / current_rate
|
||||
|
||||
amount_usd = round(amount_usd, 2)
|
||||
|
||||
if amount_usd < 1:
|
||||
await message.answer("❌ Минимальная сумма для оплаты в USD: 1.00 USD")
|
||||
return
|
||||
|
||||
if amount_usd > 1000:
|
||||
await message.answer("❌ Максимальная сумма для оплаты в USD: 1,000 USD")
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=f"Пополнение баланса на {amount_rubles:.0f} ₽ ({amount_usd:.2f} USD)",
|
||||
payload=f"balance_{db_user.id}_{amount_kopeks}"
|
||||
)
|
||||
|
||||
if not payment_result:
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
bot_invoice_url = payment_result.get("bot_invoice_url")
|
||||
mini_app_invoice_url = payment_result.get("mini_app_invoice_url")
|
||||
|
||||
payment_url = bot_invoice_url or mini_app_invoice_url
|
||||
|
||||
if not payment_url:
|
||||
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="🪙 Оплатить", url=payment_url)],
|
||||
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_cryptobot_{payment_result['local_payment_id']}")],
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
f"💰 Сумма к зачислению: {amount_rubles:.0f} ₽\n"
|
||||
f"💵 К оплате: {amount_usd:.2f} USD\n"
|
||||
f"🪙 Актив: {payment_result['asset']}\n"
|
||||
f"💱 Курс: 1 USD = {current_rate:.2f} ₽\n"
|
||||
f"🆔 ID платежа: {payment_result['invoice_id'][:8]}...\n\n"
|
||||
f"📱 <b>Инструкция:</b>\n"
|
||||
f"1. Нажмите кнопку 'Оплатить'\n"
|
||||
f"2. Выберите удобный актив\n"
|
||||
f"3. Переведите указанную сумму\n"
|
||||
f"4. Деньги поступят на баланс автоматически\n\n"
|
||||
f"🔒 Оплата проходит через защищенную систему CryptoBot\n"
|
||||
f"⚡ Поддерживаемые активы: USDT, TON, BTC, ETH\n\n"
|
||||
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж для пользователя {db_user.telegram_id}: "
|
||||
f"{amount_rubles:.0f} ₽ ({amount_usd:.2f} USD), ID: {payment_result['invoice_id']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
|
||||
await state.clear()
|
||||
|
||||
@error_handler
|
||||
async def check_cryptobot_payment_status(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession
|
||||
):
|
||||
try:
|
||||
local_payment_id = int(callback.data.split('_')[-1])
|
||||
|
||||
from app.database.crud.cryptobot import get_cryptobot_payment_by_id
|
||||
payment = await get_cryptobot_payment_by_id(db, local_payment_id)
|
||||
|
||||
if not payment:
|
||||
await callback.answer("❌ Платеж не найден", show_alert=True)
|
||||
return
|
||||
|
||||
status_emoji = {
|
||||
"active": "⏳",
|
||||
"paid": "✅",
|
||||
"expired": "❌"
|
||||
}
|
||||
|
||||
status_text = {
|
||||
"active": "Ожидает оплаты",
|
||||
"paid": "Оплачен",
|
||||
"expired": "Истек"
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(payment.status, "❓")
|
||||
status = status_text.get(payment.status, "Неизвестно")
|
||||
|
||||
message_text = (f"🪙 Статус платежа:\n\n"
|
||||
f"🆔 ID: {payment.invoice_id[:8]}...\n"
|
||||
f"💰 Сумма: {payment.amount} {payment.asset}\n"
|
||||
f"📊 Статус: {emoji} {status}\n"
|
||||
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
|
||||
|
||||
if payment.is_paid:
|
||||
message_text += "\n✅ Платеж успешно завершен!\n\nСредства зачислены на баланс."
|
||||
elif payment.is_pending:
|
||||
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
|
||||
elif payment.is_expired:
|
||||
message_text += f"\n❌ Платеж истек. Обратитесь в {settings.SUPPORT_USERNAME}"
|
||||
|
||||
await callback.answer(message_text, show_alert=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки статуса CryptoBot платежа: {e}")
|
||||
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
|
||||
|
||||
|
||||
|
||||
@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):
|
||||
@@ -560,6 +1071,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"
|
||||
@@ -579,3 +1100,24 @@ def register_handlers(dp: Dispatcher):
|
||||
process_topup_amount,
|
||||
BalanceStates.waiting_for_amount
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_cryptobot_payment,
|
||||
F.data == "topup_cryptobot"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
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_")
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.keyboards.inline import get_main_menu_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,10 +32,7 @@ async def show_main_menu(
|
||||
if db_user.subscription:
|
||||
subscription_is_active = db_user.subscription.is_active
|
||||
|
||||
menu_text = texts.MAIN_MENU.format(
|
||||
user_name=db_user.full_name,
|
||||
subscription_status=_get_subscription_status(db_user, texts)
|
||||
)
|
||||
menu_text = await get_main_menu_text(db_user, texts, db)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
@@ -44,8 +42,10 @@ 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,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -100,10 +100,32 @@ async def handle_back_to_menu(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
await state.clear()
|
||||
|
||||
await show_main_menu(callback, db_user, db)
|
||||
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)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=db_user.balance_kopeks,
|
||||
subscription=db_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _get_subscription_status(user: User, texts) -> str:
|
||||
@@ -136,6 +158,31 @@ def _get_subscription_status(user: User, texts) -> str:
|
||||
else:
|
||||
return f"💎 Активна\n⚠️ истекает сегодня!"
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from pathlib import Path
|
||||
|
||||
import qrcode
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_referral_keyboard, get_back_keyboard
|
||||
from app.keyboards.inline import get_referral_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.user_utils import get_user_referral_summary, get_detailed_referral_list, get_referral_analytics
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.utils.user_utils import (
|
||||
get_detailed_referral_list,
|
||||
get_referral_analytics,
|
||||
get_user_referral_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,15 +91,56 @@ async def show_referral_info(
|
||||
referral_text += "\n"
|
||||
|
||||
referral_text += "📢 Приглашайте друзей и зарабатывайте!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
referral_text,
|
||||
reply_markup=get_referral_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
get_referral_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
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
|
||||
referral_link = f"https://t.me/{bot_username}?start={db_user.referral_code}"
|
||||
|
||||
qr_dir = Path("data") / "referral_qr"
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = qr_dir / f"{db_user.id}.png"
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img.save(file_path)
|
||||
|
||||
photo = FSInputFile(file_path)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
types.InputMediaPhoto(
|
||||
media=photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
photo,
|
||||
caption=f"🔗 Ваша реферальная ссылка:\n{referral_link}",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
async def show_detailed_referral_list(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -97,15 +148,17 @@ 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(
|
||||
await edit_or_answer_photo(
|
||||
callback,
|
||||
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
|
||||
])
|
||||
types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
|
||||
),
|
||||
parse_mode=None,
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
@@ -148,14 +201,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()
|
||||
|
||||
@@ -166,31 +219,31 @@ 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"
|
||||
|
||||
|
||||
if analytics['top_referrals']:
|
||||
text += f"🏆 <b>Топ-{len(analytics['top_referrals'])} рефералов:</b>\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 += "\n"
|
||||
|
||||
|
||||
text += "📈 Продолжайте развивать свою реферальную сеть!"
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
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()
|
||||
|
||||
@@ -200,34 +253,36 @@ 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}"
|
||||
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text="📤 Поделиться",
|
||||
switch_inline_query=invite_text
|
||||
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,
|
||||
(
|
||||
f"📝 <b>Приглашение создано!</b>\n\n"
|
||||
f"Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:\n\n"
|
||||
f"<code>{invite_text}</code>"
|
||||
),
|
||||
keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -243,6 +298,11 @@ def register_handlers(dp: Dispatcher):
|
||||
create_invite_message,
|
||||
F.data == "referral_create_invite"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_referral_qr,
|
||||
F.data == "referral_show_qr"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
show_detailed_referral_list,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,19 +90,48 @@ async def handle_successful_payment(
|
||||
|
||||
if success:
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
|
||||
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
|
||||
f"⭐ Потрачено звезд: {payment.total_amount}\n"
|
||||
f"💰 Зачислено на баланс: {rubles_amount:.2f} ₽\n"
|
||||
f"💰 Зачислено на баланс: {int(rubles_amount)} ₽\n"
|
||||
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
|
||||
f"Спасибо за пополнение! 🚀",
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"✅ Stars платеж успешно обработан: "
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {rubles_amount:.2f}₽"
|
||||
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}₽"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,11 +13,14 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.models import UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard, get_main_menu_keyboard
|
||||
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,19 +140,20 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
),
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks
|
||||
)
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
@@ -223,13 +228,36 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
logger.info(f"💾 Установлен русский язык по умолчанию")
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
|
||||
if data.get('referral_code'):
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
|
||||
await complete_registration(message, state, db)
|
||||
else:
|
||||
try:
|
||||
await message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info("🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration(message, state, db)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
logger.info(f"📋 Правила отправлены")
|
||||
|
||||
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
current_state = await state.get_state()
|
||||
logger.info(f"📊 Установлено состояние: {current_state}")
|
||||
@@ -273,25 +301,29 @@ async def process_rules_accept(
|
||||
|
||||
if data.get('referral_code'):
|
||||
logger.info(f"🎫 Найден реферальный код из deep link: {data['referral_code']}")
|
||||
|
||||
|
||||
referrer = await get_user_by_referral_code(db, data['referral_code'])
|
||||
if referrer:
|
||||
data['referrer_id'] = referrer.id
|
||||
await state.set_data(data)
|
||||
logger.info(f"✅ Реферер найден: {referrer.id}")
|
||||
|
||||
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
logger.info("⚙️ SKIP_REFERRAL_CODE включен - пропускаем запрос реферального кода")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
else:
|
||||
try:
|
||||
await callback.message.answer(
|
||||
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
logger.info(f"🔍 Ожидание ввода реферального кода")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
|
||||
await complete_registration_from_callback(callback, state, db)
|
||||
|
||||
else:
|
||||
logger.info(f"❌ Правила отклонены пользователем {callback.from_user.id}")
|
||||
@@ -387,7 +419,9 @@ async def complete_registration_from_callback(
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
|
||||
|
||||
@@ -399,36 +433,33 @@ async def complete_registration_from_callback(
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await callback.message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}!")
|
||||
await callback.message.answer(f"Добро пожаловать, {existing_user.full_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
@@ -460,7 +491,7 @@ async def complete_registration_from_callback(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {callback.from_user.id} восстановлен")
|
||||
@@ -480,6 +511,7 @@ async def complete_registration_from_callback(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {callback.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -492,7 +524,7 @@ async def complete_registration_from_callback(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
@@ -503,65 +535,58 @@ async def complete_registration_from_callback(
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, callback.from_user)
|
||||
|
||||
if offer_text:
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await callback.message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
try:
|
||||
await callback.message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as final_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await callback.message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
|
||||
async def complete_registration(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db: AsyncSession
|
||||
):
|
||||
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}")
|
||||
|
||||
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
|
||||
|
||||
@@ -573,36 +598,33 @@ async def complete_registration(
|
||||
if data.get('referral_code') and not existing_user.referred_by_id:
|
||||
await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
|
||||
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
has_active_subscription = existing_user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if existing_user.subscription:
|
||||
subscription_is_active = existing_user.subscription.is_active
|
||||
|
||||
user_name = existing_user.full_name
|
||||
user_telegram_id = existing_user.telegram_id
|
||||
user_language = existing_user.language
|
||||
has_had_paid_subscription = existing_user.has_had_paid_subscription
|
||||
balance_kopeks = existing_user.balance_kopeks
|
||||
menu_text = await get_main_menu_text(existing_user, texts, db)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status(existing_user, texts)
|
||||
),
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
language=existing_user.language,
|
||||
is_admin=settings.is_admin(existing_user.telegram_id),
|
||||
has_had_paid_subscription=existing_user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
balance_kopeks=existing_user.balance_kopeks,
|
||||
subscription=existing_user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user_name}!")
|
||||
await message.answer(f"Добро пожаловать, {existing_user.full_name}!")
|
||||
|
||||
await state.clear()
|
||||
return
|
||||
@@ -634,7 +656,7 @@ async def complete_registration(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
|
||||
user = existing_user
|
||||
logger.info(f"✅ Пользователь {message.from_user.id} восстановлен")
|
||||
@@ -654,6 +676,7 @@ async def complete_registration(
|
||||
referred_by_id=referrer_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
await db.refresh(user, ['subscription'])
|
||||
else:
|
||||
logger.info(f"🔄 Обновляем существующего пользователя {message.from_user.id}")
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
@@ -666,7 +689,7 @@ async def complete_registration(
|
||||
existing_user.last_activity = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_user)
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
@@ -677,58 +700,50 @@ async def complete_registration(
|
||||
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
|
||||
|
||||
await state.clear()
|
||||
|
||||
has_active_subscription = False
|
||||
subscription_is_active = False
|
||||
user_name = user.full_name
|
||||
balance_kopeks = user.balance_kopeks
|
||||
user_telegram_id = user.telegram_id
|
||||
user_language = user.language
|
||||
has_had_paid_subscription = user.has_had_paid_subscription
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
),
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
)
|
||||
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке главного меню: {e}")
|
||||
|
||||
from app.database.crud.welcome_text import get_welcome_text_for_user
|
||||
offer_text = await get_welcome_text_for_user(db, message.from_user)
|
||||
|
||||
if offer_text:
|
||||
try:
|
||||
balance_rubles = balance_kopeks / 100
|
||||
await message.answer(
|
||||
f"Добро пожаловать, {user_name}!\n"
|
||||
f"Баланс: {balance_rubles:.2f} ₽\n"
|
||||
f"Подписка: Нет активной подписки",
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
try:
|
||||
await message.answer(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user_language,
|
||||
is_admin=settings.is_admin(user_telegram_id),
|
||||
has_had_paid_subscription=has_had_paid_subscription,
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=balance_kopeks
|
||||
)
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
|
||||
try:
|
||||
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
|
||||
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await message.answer(f"Добро пожаловать, {user.full_name}!")
|
||||
|
||||
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
|
||||
|
||||
|
||||
def _get_subscription_status(user, texts):
|
||||
@@ -787,6 +802,193 @@ def get_referral_code_keyboard(language: str):
|
||||
)]
|
||||
])
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
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}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
|
||||
base_text = texts.MAIN_MENU.format(
|
||||
user_name=user_name,
|
||||
subscription_status=_get_subscription_status_simple(texts)
|
||||
)
|
||||
|
||||
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}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения случайного сообщения: {e}")
|
||||
|
||||
return base_text
|
||||
|
||||
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery,
|
||||
bot: Bot,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
db_user=None
|
||||
):
|
||||
try:
|
||||
chat_member = await bot.get_chat_member(
|
||||
chat_id=settings.CHANNEL_SUB_ID,
|
||||
user_id=query.from_user.id
|
||||
)
|
||||
|
||||
if chat_member.status not in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR]:
|
||||
return await query.answer("❌ Вы не подписались на канал!", show_alert=True)
|
||||
|
||||
await query.answer("✅ Спасибо за подписку", show_alert=True)
|
||||
|
||||
try:
|
||||
await query.message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить сообщение: {e}")
|
||||
|
||||
user = await get_user_by_telegram_id(db, query.from_user.id)
|
||||
|
||||
if user and user.status != UserStatus.DELETED.value:
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.start import get_main_menu_text
|
||||
from app.keyboards.inline import get_main_menu_keyboard
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
has_active_subscription = user.subscription is not None
|
||||
subscription_is_active = False
|
||||
|
||||
if user.subscription:
|
||||
subscription_is_active = user.subscription.is_active
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
language=user.language,
|
||||
is_admin=settings.is_admin(user.telegram_id),
|
||||
has_had_paid_subscription=user.has_had_paid_subscription,
|
||||
has_active_subscription=has_active_subscription,
|
||||
subscription_is_active=subscription_is_active,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
subscription=user.subscription
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
from app.localization.texts import get_texts
|
||||
from app.keyboards.inline import get_rules_keyboard
|
||||
|
||||
language = 'ru'
|
||||
texts = get_texts(language)
|
||||
|
||||
data = await state.get_data() or {}
|
||||
data['language'] = language
|
||||
await state.set_data(data)
|
||||
|
||||
if settings.SKIP_RULES_ACCEPT:
|
||||
if settings.SKIP_REFERRAL_CODE:
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
|
||||
referral_code = await generate_unique_referral_code(db, query.from_user.id)
|
||||
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=query.from_user.id,
|
||||
username=query.from_user.username,
|
||||
first_name=query.from_user.first_name,
|
||||
last_name=query.from_user.last_name,
|
||||
language=language,
|
||||
referral_code=referral_code
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=f"Добро пожаловать, {user.full_name}!",
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text="У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
|
||||
reply_markup=get_referral_code_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
caption=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
text=texts.RULES_TEXT,
|
||||
reply_markup=get_rules_keyboard(language)
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в required_sub_channel_check: {e}")
|
||||
await query.answer("❌ Произошла ошибка!", show_alert=True)
|
||||
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -826,5 +1028,12 @@ def register_handlers(dp: Dispatcher):
|
||||
)
|
||||
)
|
||||
logger.info("✅ Зарегистрирован handle_potential_referral_code")
|
||||
|
||||
dp.callback_query.register(
|
||||
required_sub_channel_check,
|
||||
F.data.in_(["sub_channel_check"])
|
||||
)
|
||||
logger.info("✅ Зарегистрирован required_sub_channel_check")
|
||||
|
||||
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
async def tribute_webhook(request):
|
||||
try:
|
||||
signature = request.headers.get('X-Signature', '')
|
||||
signature = request.headers.get('trbt-signature', '')
|
||||
payload = await request.text()
|
||||
|
||||
tribute_service = TributeService()
|
||||
|
||||
@@ -7,29 +7,92 @@ 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="⬅️ Назад", 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=texts.BACK, callback_data="back_to_menu")
|
||||
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="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -45,7 +108,7 @@ 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_submenu_users")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -64,7 +127,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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -79,7 +142,7 @@ 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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -111,7 +174,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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -130,7 +193,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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -143,13 +206,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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -162,13 +225,13 @@ 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")
|
||||
]
|
||||
])
|
||||
|
||||
@@ -311,8 +374,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"),
|
||||
@@ -477,7 +540,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"),
|
||||
@@ -499,6 +562,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=[
|
||||
@@ -580,25 +807,72 @@ def get_admin_pagination_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
|
||||
def get_maintenance_keyboard(
|
||||
language: str,
|
||||
is_maintenance_active: bool,
|
||||
is_monitoring_active: bool,
|
||||
panel_has_issues: bool = False
|
||||
) -> InlineKeyboardMarkup:
|
||||
keyboard = []
|
||||
|
||||
if language == "en":
|
||||
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
|
||||
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
|
||||
check_api_text = "🔍 Check API"
|
||||
back_text = "⬅️ Back to admin"
|
||||
if is_maintenance_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🟢 Выключить техработы",
|
||||
callback_data="maintenance_toggle"
|
||||
)
|
||||
])
|
||||
else:
|
||||
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
|
||||
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
|
||||
check_api_text = "🔍 Проверить API"
|
||||
back_text = "⬅️ Назад в админку"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Включить техработы",
|
||||
callback_data="maintenance_toggle"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard = [
|
||||
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
|
||||
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
|
||||
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
|
||||
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
|
||||
]
|
||||
if is_monitoring_active:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⏹️ Остановить мониторинг",
|
||||
callback_data="maintenance_monitoring"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="▶️ Запустить мониторинг",
|
||||
callback_data="maintenance_monitoring"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔍 Проверить API",
|
||||
callback_data="maintenance_check_api"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""),
|
||||
callback_data="maintenance_check_panel"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="📢 Отправить уведомление",
|
||||
callback_data="maintenance_manual_notify"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="maintenance_panel"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад",
|
||||
callback_data="admin_submenu_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
@@ -609,3 +883,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)
|
||||
|
||||
@@ -2,9 +2,12 @@ from typing import List, Optional
|
||||
from aiogram import types
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from datetime import datetime
|
||||
from app.database.models import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,14 +21,42 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
]
|
||||
])
|
||||
|
||||
def get_channel_sub_keyboard(channel_link: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подписаться", url=channel_link
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Я подписался", callback_data="sub_channel_check"
|
||||
)
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🚀 Подключиться бесплатно 🚀", callback_data="trial_activate"
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
|
||||
])
|
||||
|
||||
|
||||
def get_main_menu_keyboard(
|
||||
language: str = "ru",
|
||||
language: str = "ru",
|
||||
is_admin: bool = False,
|
||||
has_had_paid_subscription: bool = False,
|
||||
has_active_subscription: bool = False,
|
||||
subscription_is_active: bool = False,
|
||||
balance_kopeks: int = 0
|
||||
balance_kopeks: int = 0,
|
||||
subscription=None
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -37,16 +68,47 @@ def get_main_menu_keyboard(
|
||||
else:
|
||||
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
]
|
||||
]
|
||||
|
||||
keyboard = []
|
||||
|
||||
if has_active_subscription and subscription_is_active:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
if connect_mode == "miniapp_subscription":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=subscription.subscription_url)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
|
||||
)
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
callback_data="subscription_connect"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
|
||||
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
|
||||
])
|
||||
|
||||
show_trial = not has_had_paid_subscription and not has_active_subscription
|
||||
|
||||
@@ -104,6 +166,19 @@ def get_back_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
])
|
||||
|
||||
|
||||
def get_insufficient_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.GO_TO_BALANCE_TOP_UP,
|
||||
callback_data="balance_topup",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")],
|
||||
])
|
||||
|
||||
|
||||
def get_subscription_keyboard(
|
||||
language: str = "ru",
|
||||
has_subscription: bool = False,
|
||||
@@ -114,7 +189,7 @@ def get_subscription_keyboard(
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if has_subscription:
|
||||
if subscription and subscription.subscription_url:
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
@@ -138,21 +213,23 @@ def get_subscription_keyboard(
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
|
||||
if not is_trial and subscription and subscription.days_left <= 3:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
|
||||
])
|
||||
|
||||
|
||||
if not is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
|
||||
])
|
||||
|
||||
|
||||
if is_trial:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
|
||||
@@ -168,40 +245,6 @@ def get_subscription_keyboard(
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌍 Добавить страны", callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Добавить устройства", callback_data="subscription_add_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Сбросить устройства", callback_data="subscription_reset_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="📈 Добавить трафик", callback_data="subscription_add_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_trial_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
@@ -416,6 +459,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -431,15 +475,31 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
callback_data="topup_yookassa"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🏦 Оплатить по СБП (YooKassa)",
|
||||
callback_data="topup_yookassa_sbp"
|
||||
)
|
||||
])
|
||||
|
||||
if settings.TRIBUTE_ENABLED:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="💎 Банковская карта (Tribute)",
|
||||
text="💳 Банковская карта (Tribute)",
|
||||
callback_data="topup_tribute"
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🪙 Криптовалюта (CryptoBot)",
|
||||
callback_data="topup_cryptobot"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🛠️ Через поддержку",
|
||||
@@ -447,6 +507,14 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
|
||||
)
|
||||
])
|
||||
|
||||
if len(keyboard) == 1:
|
||||
keyboard.insert(0, [
|
||||
InlineKeyboardButton(
|
||||
text="⚠️ Способы оплаты временно недоступны",
|
||||
callback_data="payment_methods_unavailable"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")
|
||||
])
|
||||
@@ -505,7 +573,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⏰ Продлить подписку",
|
||||
text=texts.MENU_EXTEND_SUBSCRIPTION,
|
||||
callback_data="subscription_extend"
|
||||
)
|
||||
],
|
||||
@@ -533,6 +601,12 @@ def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="referral_create_invite"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📱 Показать QR код",
|
||||
callback_data="referral_show_qr"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="👥 Список рефералов",
|
||||
@@ -715,14 +789,14 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti
|
||||
|
||||
if gb == 0:
|
||||
if language == "ru":
|
||||
text = f"♾️ Безлимитный трафик - {total_price/100:.2f} ₽{period_text}"
|
||||
text = f"♾️ Безлимитный трафик - {total_price//100} ₽{period_text}"
|
||||
else:
|
||||
text = f"♾️ Unlimited traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
text = f"♾️ Unlimited traffic - {total_price//100} ₽{period_text}"
|
||||
else:
|
||||
if language == "ru":
|
||||
text = f"📊 +{gb} ГБ трафика - {total_price/100:.2f} ₽{period_text}"
|
||||
text = f"📊 +{gb} ГБ трафика - {total_price//100} ₽{period_text}"
|
||||
else:
|
||||
text = f"📊 +{gb} GB traffic - {total_price/100:.2f} ₽{period_text}"
|
||||
text = f"📊 +{gb} GB traffic - {total_price//100} ₽{period_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
|
||||
@@ -737,7 +811,7 @@ def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: dateti
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
def get_change_devices_keyboard(current_devices: int, language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
@@ -752,32 +826,77 @@ def get_add_devices_keyboard(current_devices: int, language: str = "ru", subscri
|
||||
|
||||
buttons = []
|
||||
|
||||
for count in [1, 2, 3, 4, 5]:
|
||||
new_total = current_devices + count
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total > settings.MAX_DEVICES_LIMIT:
|
||||
continue
|
||||
|
||||
price_per_month = count * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
|
||||
if language == "ru":
|
||||
text = f"📱 +{count} устройство(а) (итого: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
min_devices = 1
|
||||
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
|
||||
|
||||
start_range = max(1, min(current_devices - 3, max_devices - 6))
|
||||
end_range = min(max_devices + 1, max(current_devices + 4, 7))
|
||||
|
||||
for devices_count in range(start_range, end_range):
|
||||
if devices_count == current_devices:
|
||||
emoji = "✅"
|
||||
action_text = " (текущее)"
|
||||
price_text = ""
|
||||
elif devices_count > current_devices:
|
||||
emoji = "➕"
|
||||
additional_devices = devices_count - current_devices
|
||||
|
||||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
new_chargeable = max(0, devices_count - settings.DEFAULT_DEVICE_LIMIT)
|
||||
chargeable_devices = new_chargeable - current_chargeable
|
||||
|
||||
if chargeable_devices > 0:
|
||||
price_per_month = chargeable_devices * device_price_per_month
|
||||
total_price = price_per_month * months_multiplier
|
||||
price_text = f" (+{total_price//100}₽{period_text})"
|
||||
action_text = ""
|
||||
else:
|
||||
price_text = " (бесплатно)"
|
||||
action_text = ""
|
||||
else:
|
||||
text = f"📱 +{count} device(s) (total: {new_total}) - {total_price/100:.2f} ₽{period_text}"
|
||||
emoji = "➖"
|
||||
action_text = ""
|
||||
price_text = " (без возврата)"
|
||||
|
||||
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text=text, callback_data=f"add_devices_{count}")
|
||||
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
|
||||
])
|
||||
|
||||
if current_devices < start_range or current_devices >= end_range:
|
||||
current_button = f"✅ {current_devices} устр. (текущее)"
|
||||
buttons.insert(0, [
|
||||
InlineKeyboardButton(text=current_button, callback_data=f"change_devices_{current_devices}")
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="menu_subscription"
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Подтвердить изменение",
|
||||
callback_data=f"confirm_change_devices_{new_devices_count}_{price}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
@@ -835,10 +954,10 @@ def get_manage_countries_keyboard(
|
||||
if uuid not in current_subscription_countries and uuid in selected:
|
||||
total_price = price_per_month * months_multiplier
|
||||
if months_multiplier > 1:
|
||||
price_text = f" ({price_per_month/100:.2f}₽/мес × {months_multiplier} = {total_price/100:.2f}₽)"
|
||||
price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)"
|
||||
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}₽")
|
||||
else:
|
||||
price_text = f" ({total_price/100:.2f}₽)"
|
||||
price_text = f" ({total_price//100}₽)"
|
||||
display_name = f"{icon} {name}{price_text}"
|
||||
else:
|
||||
display_name = f"{icon} {name}"
|
||||
@@ -851,7 +970,7 @@ def get_manage_countries_keyboard(
|
||||
])
|
||||
|
||||
if total_cost > 0:
|
||||
apply_text = f"✅ Применить изменения ({total_cost/100:.2f} ₽)"
|
||||
apply_text = f"✅ Применить изменения ({total_cost//100} ₽)"
|
||||
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}₽")
|
||||
else:
|
||||
apply_text = "✅ Применить изменения"
|
||||
@@ -1020,29 +1139,195 @@ def get_specific_app_keyboard(
|
||||
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
|
||||
available_periods = settings.get_available_renewal_periods()
|
||||
|
||||
period_display = {
|
||||
14: "14 дней",
|
||||
30: "30 дней",
|
||||
60: "60 дней",
|
||||
90: "90 дней",
|
||||
180: "180 дней",
|
||||
360: "360 дней"
|
||||
}
|
||||
|
||||
|
||||
for days in available_periods:
|
||||
if days in prices and days in period_display:
|
||||
if days in prices:
|
||||
period_display = format_period_description(days, language)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
|
||||
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
|
||||
callback_data=f"extend_period_{days}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
def get_cryptobot_payment_keyboard(
|
||||
payment_id: str,
|
||||
local_payment_id: int,
|
||||
amount_usd: float,
|
||||
asset: str,
|
||||
bot_invoice_url: str,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🪙 Оплатить",
|
||||
url=bot_invoice_url
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="📊 Проверить статус",
|
||||
callback_data=f"check_cryptobot_{local_payment_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="💰 Мой баланс",
|
||||
callback_data="menu_balance"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
def get_devices_management_keyboard(
|
||||
devices: List[dict],
|
||||
pagination,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
|
||||
keyboard = []
|
||||
|
||||
for i, device in enumerate(devices):
|
||||
platform = device.get('platform', 'Unknown')
|
||||
device_model = device.get('deviceModel', 'Unknown')
|
||||
device_info = f"{platform} - {device_model}"
|
||||
|
||||
if len(device_info) > 25:
|
||||
device_info = device_info[:22] + "..."
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 {device_info}",
|
||||
callback_data=f"reset_device_{i}_{pagination.page}"
|
||||
)
|
||||
])
|
||||
|
||||
if pagination.total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if pagination.has_prev:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"devices_page_{pagination.prev_page}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{pagination.page}/{pagination.total_pages}",
|
||||
callback_data="current_page"
|
||||
)
|
||||
)
|
||||
|
||||
if pagination.has_next:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"devices_page_{pagination.next_page}"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Сбросить все устройства",
|
||||
callback_data="reset_all_devices"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_updated_subscription_settings_keyboard(language: str = "ru", show_countries_management: bool = True) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if show_countries_management:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🌐 Добавить страны", callback_data="subscription_add_countries")
|
||||
])
|
||||
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton(text="📱 Изменить устройства", callback_data="subscription_change_devices")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔧 Управление устройствами", callback_data="subscription_manage_devices")
|
||||
]
|
||||
])
|
||||
|
||||
if settings.is_traffic_selectable():
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Переключить трафик", callback_data="subscription_switch_traffic")
|
||||
])
|
||||
keyboard.insert(-2, [
|
||||
InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="subscription_reset_traffic")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_device_reset_confirm_keyboard(device_info: str, device_index: int, page: int, language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="✅ Да, сбросить это устройство",
|
||||
callback_data=f"confirm_reset_device_{device_index}_{page}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❌ Отмена",
|
||||
callback_data=f"devices_page_{page}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_device_management_help_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="❓ Как подключить устройство заново?",
|
||||
callback_data="device_connection_help"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔧 Управление устройствами",
|
||||
callback_data="subscription_manage_devices"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="⬅️ К подписке",
|
||||
callback_data="menu_subscription"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
@@ -57,7 +57,7 @@ class Texts:
|
||||
|
||||
@staticmethod
|
||||
def format_price(kopeks: int) -> str:
|
||||
return f"{kopeks / 100:.2f} ₽"
|
||||
return f"{int(kopeks / 100)} ₽"
|
||||
|
||||
@staticmethod
|
||||
def format_traffic(gb: float) -> str:
|
||||
@@ -119,13 +119,13 @@ class RussianTexts(Texts):
|
||||
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
|
||||
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
|
||||
MENU_PROMOCODE = "🎫 Промокод"
|
||||
MENU_REFERRALS = "🤝 Рефералы"
|
||||
MENU_REFERRALS = "🤝 Партнерка"
|
||||
MENU_SUPPORT = "🛠️ Техподдержка"
|
||||
MENU_RULES = "📋 Правила сервиса"
|
||||
MENU_LANGUAGE = "🌐 Язык"
|
||||
MENU_ADMIN = "⚙️ Админ-панель"
|
||||
BALANCE_BUTTON = "💰 Баланс: {balance}"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00 ₽"
|
||||
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
|
||||
|
||||
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
|
||||
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
|
||||
@@ -155,13 +155,57 @@ class RussianTexts(Texts):
|
||||
⏰ <b>Период:</b> {days} дней
|
||||
📈 <b>Трафик:</b> {traffic} ГБ
|
||||
📱 <b>Устройства:</b> {devices} шт.
|
||||
🌍 <b>Сервер:</b> 1 страна
|
||||
🌍 <b>Сервер:</b> {server_name}
|
||||
|
||||
Активировать тестовую подписку?
|
||||
"""
|
||||
|
||||
TRIAL_ACTIVATED = "🎉 Тестовая подписка активирована!"
|
||||
TRIAL_ALREADY_USED = "❌ Тестовая подписка уже была использована"
|
||||
|
||||
CHANGE_DEVICES_TITLE = "📱 Изменение количества устройств"
|
||||
CHANGE_DEVICES_INFO = """
|
||||
📱 <b>Изменение количества устройств</b>
|
||||
|
||||
Текущий лимит: {current_devices} устройств
|
||||
|
||||
Выберите новое количество устройств:
|
||||
|
||||
💡 <b>Важно:</b>
|
||||
• При увеличении - доплата пропорционально оставшемуся времени
|
||||
• При уменьшении - возврат средств не производится
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_CONFIRM = """
|
||||
📱 <b>Подтверждение изменения</b>
|
||||
|
||||
Текущее количество: {current_devices} устройств
|
||||
Новое количество: {new_devices} устройств
|
||||
|
||||
Действие: {action}
|
||||
💰 {cost}
|
||||
|
||||
Подтвердить изменение?
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_SUCCESS_INCREASE = """
|
||||
✅ Количество устройств увеличено!
|
||||
|
||||
📱 Было: {old_count} → Стало: {new_count}
|
||||
💰 Списано: {amount}
|
||||
"""
|
||||
|
||||
CHANGE_DEVICES_SUCCESS_DECREASE = """
|
||||
✅ Количество устройств уменьшено!
|
||||
|
||||
📱 Было: {old_count} → Стало: {new_count}
|
||||
ℹ️ Возврат средств не производится
|
||||
"""
|
||||
|
||||
DEVICES_NO_CHANGE = "ℹ️ Количество устройств не изменилось"
|
||||
DEVICES_MINIMUM_LIMIT = "⚠️ Минимальное количество устройств: {limit}"
|
||||
DEVICES_LIMIT_EXCEEDED = "⚠️ Превышен максимальный лимит устройств ({limit})"
|
||||
DEVICES_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
|
||||
|
||||
BUY_SUBSCRIPTION_START = """
|
||||
💎 <b>Настройка подписки</b>
|
||||
@@ -204,7 +248,11 @@ class RussianTexts(Texts):
|
||||
Подтвердить покупку?
|
||||
"""
|
||||
|
||||
INSUFFICIENT_BALANCE = "❌ Недостаточно средств на балансе. Пополните баланс и попробуйте снова."
|
||||
INSUFFICIENT_BALANCE = """❌ Недостаточно средств на балансе.
|
||||
|
||||
<b>Пополните баланс на {amount} и попробуйте снова.</b>
|
||||
"""
|
||||
GO_TO_BALANCE_TOP_UP = "💳 Перейти к пополнению баланса"
|
||||
SUBSCRIPTION_PURCHASED = "🎉 Подписка успешно приобретена!"
|
||||
|
||||
BALANCE_INFO = """
|
||||
@@ -277,7 +325,7 @@ class RussianTexts(Texts):
|
||||
• 30 дней всего за {price}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
@@ -373,7 +421,7 @@ class RussianTexts(Texts):
|
||||
ADMIN_PROMOCODES = "🎫 Промокоды"
|
||||
ADMIN_MESSAGES = "📨 Рассылки"
|
||||
ADMIN_MONITORING = "🔍 Мониторинг"
|
||||
ADMIN_REFERRALS = "🤝 Рефералы"
|
||||
ADMIN_REFERRALS = "🤝 Партнерка"
|
||||
ADMIN_RULES = "📋 Правила"
|
||||
ADMIN_REMNAWAVE = "🖥️ Remnawave"
|
||||
ADMIN_STATISTICS = "📊 Статистика"
|
||||
@@ -404,6 +452,48 @@ class RussianTexts(Texts):
|
||||
Ваша подписка автоматически продлена на {days} дней.
|
||||
Списано с баланса: {amount}
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_TITLE = "🔄 Переключение лимита трафика"
|
||||
SWITCH_TRAFFIC_INFO = """
|
||||
🔄 <b>Переключение лимита трафика</b>
|
||||
|
||||
Текущий лимит: {current_traffic}
|
||||
Выберите новый лимит трафика:
|
||||
|
||||
💡 <b>Важно:</b>
|
||||
• При увеличении - доплата за разницу пропорционально оставшемуся времени
|
||||
• При уменьшении - возврат средств не производится
|
||||
• Счетчик использованного трафика НЕ сбрасывается
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_CONFIRM = """
|
||||
🔄 <b>Подтверждение переключения трафика</b>
|
||||
|
||||
Текущий лимит: {current_traffic}
|
||||
Новый лимит: {new_traffic}
|
||||
|
||||
Действие: {action}
|
||||
💰 {cost}
|
||||
|
||||
Подтвердить переключение?
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_SUCCESS_INCREASE = """
|
||||
✅ Лимит трафика увеличен!
|
||||
|
||||
📊 Было: {old_traffic} → Стало: {new_traffic}
|
||||
💰 Списано: {amount}
|
||||
"""
|
||||
|
||||
SWITCH_TRAFFIC_SUCCESS_DECREASE = """
|
||||
✅ Лимит трафика уменьшен!
|
||||
|
||||
📊 Было: {old_traffic} → Стало: {new_traffic}
|
||||
ℹ️ Возврат средств не производится
|
||||
"""
|
||||
|
||||
TRAFFIC_NO_CHANGE = "ℹ️ Лимит трафика не изменился"
|
||||
TRAFFIC_INSUFFICIENT_BALANCE = "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}"
|
||||
|
||||
AUTOPAY_FAILED = """
|
||||
❌ <b>Ошибка автоплатежа</b>
|
||||
@@ -443,10 +533,14 @@ To get started, select interface language:
|
||||
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 = {
|
||||
|
||||
@@ -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,22 +63,23 @@ class AuthMiddleware(BaseMiddleware):
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
]
|
||||
is_channel_check = (isinstance(event, CallbackQuery)
|
||||
and event.data == "sub_channel_check")
|
||||
|
||||
is_registration_process = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
)
|
||||
is_start_command = (isinstance(event, Message)
|
||||
and event.text
|
||||
and event.text.startswith('/start'))
|
||||
|
||||
if is_registration_process:
|
||||
logger.info(f"🔍 Пропускаем пользователя {user.id} в процессе регистрации")
|
||||
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
|
||||
@@ -93,16 +115,18 @@ class AuthMiddleware(BaseMiddleware):
|
||||
current_state = await state.get_state()
|
||||
|
||||
registration_states = [
|
||||
RegistrationStates.waiting_for_rules_accept,
|
||||
RegistrationStates.waiting_for_referral_code
|
||||
RegistrationStates.waiting_for_rules_accept.state,
|
||||
RegistrationStates.waiting_for_referral_code.state
|
||||
]
|
||||
|
||||
|
||||
is_start_or_registration = (
|
||||
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
|
||||
or (isinstance(event, CallbackQuery) and current_state and
|
||||
any(str(state) in str(current_state) for state in registration_states))
|
||||
or (isinstance(event, CallbackQuery) and event.data and
|
||||
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
|
||||
or (current_state in registration_states)
|
||||
or (
|
||||
isinstance(event, CallbackQuery)
|
||||
and event.data
|
||||
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
|
||||
)
|
||||
)
|
||||
|
||||
if is_start_or_registration:
|
||||
@@ -131,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
|
||||
@@ -166,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,125 @@
|
||||
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.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("🚫 Отправляем сообщение о необходимости подписки")
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link)
|
||||
text = f"""🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!"""
|
||||
|
||||
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
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, Subscription, Transaction
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,8 +20,27 @@ class AdminNotificationService:
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
if not referred_by_id:
|
||||
return "Нет"
|
||||
|
||||
try:
|
||||
referrer = await get_user_by_id(db, referred_by_id)
|
||||
if not referrer:
|
||||
return f"ID {referred_by_id} (не найден)"
|
||||
|
||||
if referrer.username:
|
||||
return f"@{referrer.username} (ID: {referred_by_id})"
|
||||
else:
|
||||
return f"ID {referrer.telegram_id}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
|
||||
return f"ID {referred_by_id}"
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> bool:
|
||||
@@ -29,6 +49,7 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
|
||||
|
||||
@@ -44,7 +65,7 @@ class AdminNotificationService:
|
||||
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -56,6 +77,7 @@ class AdminNotificationService:
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
@@ -76,8 +98,8 @@ class AdminNotificationService:
|
||||
user_status = "🆕 Первая покупка"
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""💎 <b>{event_type}</b>
|
||||
|
||||
@@ -99,7 +121,7 @@ class AdminNotificationService:
|
||||
|
||||
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
|
||||
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
|
||||
@@ -108,9 +130,82 @@ class AdminNotificationService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
|
||||
return False
|
||||
|
||||
async def send_version_update_notification(
|
||||
self,
|
||||
current_version: str,
|
||||
latest_version,
|
||||
total_updates: int
|
||||
) -> bool:
|
||||
"""Отправляет уведомление о новых обновлениях"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
if latest_version.prerelease:
|
||||
update_type = "🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ"
|
||||
type_icon = "🧪"
|
||||
elif latest_version.is_dev:
|
||||
update_type = "🔧 DEV ВЕРСИЯ"
|
||||
type_icon = "🔧"
|
||||
else:
|
||||
update_type = "📦 НОВАЯ ВЕРСИЯ"
|
||||
type_icon = "📦"
|
||||
|
||||
description = latest_version.short_description
|
||||
if len(description) > 200:
|
||||
description = description[:197] + "..."
|
||||
|
||||
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
|
||||
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
|
||||
|
||||
📝 <b>Описание:</b>
|
||||
{description}
|
||||
|
||||
🔢 <b>Всего доступно обновлений:</b> {total_updates}
|
||||
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
|
||||
|
||||
ℹ️ Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
|
||||
|
||||
⚙️ <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 send_version_check_error_notification(
|
||||
self,
|
||||
error_message: str,
|
||||
current_version: str
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
message = f"""⚠️ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>
|
||||
|
||||
📦 <b>Текущая версия:</b> <code>{current_version}</code>
|
||||
❌ <b>Ошибка:</b> {error_message}
|
||||
|
||||
🔄 Следующая попытка через час.
|
||||
⚙️ Проверьте доступность GitHub API и настройки сети.
|
||||
|
||||
⚙️ <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 send_balance_topup_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
transaction: Transaction,
|
||||
old_balance: int
|
||||
@@ -120,10 +215,9 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
|
||||
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
|
||||
balance_change = user.balance_kopeks - old_balance
|
||||
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
|
||||
|
||||
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
|
||||
|
||||
@@ -142,7 +236,7 @@ class AdminNotificationService:
|
||||
📈 Стало: {settings.format_price(user.balance_kopeks)}
|
||||
➕ Изменение: +{settings.format_price(balance_change)}
|
||||
|
||||
🔗 <b>Реферер:</b> {f'ID {user.referred_by_id}' if user.referred_by_id else 'Нет'}
|
||||
🔗 <b>Реферер:</b> {referrer_info}
|
||||
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
|
||||
|
||||
⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
|
||||
@@ -155,6 +249,7 @@ class AdminNotificationService:
|
||||
|
||||
async def send_subscription_extension_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription,
|
||||
transaction: Transaction,
|
||||
@@ -166,7 +261,6 @@ class AdminNotificationService:
|
||||
|
||||
try:
|
||||
payment_method = self._get_payment_method_display(transaction.payment_method)
|
||||
|
||||
servers_info = await self._get_servers_info(subscription.connected_squads)
|
||||
|
||||
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
|
||||
@@ -275,3 +369,435 @@ class AdminNotificationService:
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось получить названия серверов: {e}")
|
||||
return f"{len(squad_uuids)} шт."
|
||||
|
||||
|
||||
async def send_maintenance_status_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
if event_type == "enable":
|
||||
if details.get("auto_enabled", False):
|
||||
icon = "⚠️"
|
||||
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "warning"
|
||||
else:
|
||||
icon = "🔧"
|
||||
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
elif event_type == "disable":
|
||||
icon = "✅"
|
||||
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
|
||||
alert_type = "success"
|
||||
|
||||
elif event_type == "api_status":
|
||||
if status == "online":
|
||||
icon = "🟢"
|
||||
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
|
||||
alert_type = "success"
|
||||
else:
|
||||
icon = "🔴"
|
||||
title = "API REMNAWAVE НЕДОСТУПНО"
|
||||
alert_type = "error"
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
icon = "🔍"
|
||||
title = "МОНИТОРИНГ ЗАПУЩЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "⏹️"
|
||||
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
|
||||
alert_type = "info"
|
||||
else:
|
||||
icon = "ℹ️"
|
||||
title = "СИСТЕМА ТЕХРАБОТ"
|
||||
alert_type = "info"
|
||||
|
||||
message_parts = [f"{icon} <b>{title}</b>", ""]
|
||||
|
||||
if event_type == "enable":
|
||||
if details.get("reason"):
|
||||
message_parts.append(f"📋 <b>Причина:</b> {details['reason']}")
|
||||
|
||||
if details.get("enabled_at"):
|
||||
enabled_at = details["enabled_at"]
|
||||
if isinstance(enabled_at, str):
|
||||
from datetime import datetime
|
||||
enabled_at = datetime.fromisoformat(enabled_at)
|
||||
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
message_parts.append("❗ Обычные пользователи временно не могут использовать бота.")
|
||||
|
||||
elif event_type == "disable":
|
||||
if details.get("disabled_at"):
|
||||
disabled_at = details["disabled_at"]
|
||||
if isinstance(disabled_at, str):
|
||||
from datetime import datetime
|
||||
disabled_at = datetime.fromisoformat(disabled_at)
|
||||
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
|
||||
|
||||
if details.get("duration"):
|
||||
duration = details["duration"]
|
||||
if isinstance(duration, (int, float)):
|
||||
hours = int(duration // 3600)
|
||||
minutes = int((duration % 3600) // 60)
|
||||
if hours > 0:
|
||||
duration_str = f"{hours}ч {minutes}мин"
|
||||
else:
|
||||
duration_str = f"{minutes}мин"
|
||||
message_parts.append(f"⏱️ <b>Длительность:</b> {duration_str}")
|
||||
|
||||
message_parts.append(f"🤖 <b>Было автоматическим:</b> {'Да' if details.get('was_auto', False) else 'Нет'}")
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Сервис снова доступен для пользователей.")
|
||||
|
||||
elif event_type == "api_status":
|
||||
message_parts.append(f"🔗 <b>API URL:</b> {details.get('api_url', 'неизвестно')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("consecutive_failures", 0) > 0:
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток было:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("API снова отвечает на запросы.")
|
||||
|
||||
else:
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Попытка №:</b> {details['consecutive_failures']}")
|
||||
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:100]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Началась серия неудачных проверок API.")
|
||||
|
||||
elif event_type == "monitoring":
|
||||
if status == "started":
|
||||
if details.get("check_interval"):
|
||||
message_parts.append(f"🔄 <b>Интервал проверки:</b> {details['check_interval']} сек")
|
||||
|
||||
if details.get("auto_enable_configured") is not None:
|
||||
auto_enable = "Включено" if details["auto_enable_configured"] else "Отключено"
|
||||
message_parts.append(f"🤖 <b>Автовключение:</b> {auto_enable}")
|
||||
|
||||
if details.get("max_failures"):
|
||||
message_parts.append(f"🎯 <b>Порог ошибок:</b> {details['max_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Система будет следить за доступностью API.")
|
||||
|
||||
else:
|
||||
message_parts.append("Автоматический мониторинг API остановлен.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о техработах: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
|
||||
return False
|
||||
|
||||
async def send_remnawave_panel_status_notification(
|
||||
self,
|
||||
status: str,
|
||||
details: Dict[str, Any] = None
|
||||
) -> bool:
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
|
||||
try:
|
||||
details = details or {}
|
||||
|
||||
status_config = {
|
||||
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
|
||||
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
|
||||
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
|
||||
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
|
||||
}
|
||||
|
||||
config = status_config.get(status, status_config["offline"])
|
||||
|
||||
message_parts = [
|
||||
f"{config['icon']} <b>{config['title']}</b>",
|
||||
""
|
||||
]
|
||||
|
||||
if details.get("api_url"):
|
||||
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
|
||||
|
||||
if details.get("response_time"):
|
||||
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
|
||||
|
||||
if details.get("last_check"):
|
||||
last_check = details["last_check"]
|
||||
if isinstance(last_check, str):
|
||||
from datetime import datetime
|
||||
last_check = datetime.fromisoformat(last_check)
|
||||
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
|
||||
|
||||
if status == "online":
|
||||
if details.get("uptime"):
|
||||
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
|
||||
|
||||
if details.get("users_online"):
|
||||
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("✅ Все системы работают нормально.")
|
||||
|
||||
elif status == "offline":
|
||||
if details.get("error"):
|
||||
error_msg = str(details["error"])[:150]
|
||||
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
|
||||
|
||||
if details.get("consecutive_failures"):
|
||||
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
|
||||
|
||||
elif status == "degraded":
|
||||
if details.get("issues"):
|
||||
issues = details["issues"]
|
||||
if isinstance(issues, list):
|
||||
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
|
||||
for issue in issues[:3]:
|
||||
message_parts.append(f" • {issue}")
|
||||
else:
|
||||
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель работает, но возможны задержки или сбои.")
|
||||
|
||||
elif status == "maintenance":
|
||||
if details.get("maintenance_reason"):
|
||||
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
|
||||
|
||||
if details.get("estimated_duration"):
|
||||
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
|
||||
|
||||
if details.get("manual_message"):
|
||||
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
|
||||
|
||||
message_parts.append("")
|
||||
message_parts.append("Панель временно недоступна для обслуживания.")
|
||||
|
||||
from datetime import datetime
|
||||
message_parts.append("")
|
||||
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
|
||||
|
||||
message = "\n".join(message_parts)
|
||||
|
||||
return await self._send_message(message)
|
||||
|
||||
except Exception as e:
|
||||
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()
|
||||
@@ -33,7 +33,6 @@ class MaintenanceService:
|
||||
self._last_notification_sent = None
|
||||
|
||||
def set_bot(self, bot):
|
||||
"""Устанавливает ссылку на бота для отправки уведомлений"""
|
||||
self._bot = bot
|
||||
logger.info("Бот установлен для maintenance_service")
|
||||
|
||||
@@ -58,12 +57,49 @@ class MaintenanceService:
|
||||
else:
|
||||
return settings.get_maintenance_message()
|
||||
|
||||
async def _send_admin_notification(self, message: str, alert_type: str = "info"):
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(self._bot)
|
||||
|
||||
if not notification_service._is_enabled():
|
||||
logger.debug("Уведомления администраторов отключены")
|
||||
return False
|
||||
|
||||
emoji_map = {
|
||||
"error": "🚨",
|
||||
"warning": "⚠️",
|
||||
"success": "✅",
|
||||
"info": "ℹ️"
|
||||
}
|
||||
emoji = emoji_map.get(alert_type, "ℹ️")
|
||||
|
||||
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
return await notification_service._send_message(formatted_message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
|
||||
return False
|
||||
|
||||
async def _notify_admins(self, message: str, alert_type: str = "info"):
|
||||
"""Отправка уведомлений администраторам"""
|
||||
if not self._bot:
|
||||
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
|
||||
return
|
||||
|
||||
notification_sent = await self._send_admin_notification(message, alert_type)
|
||||
|
||||
if notification_sent:
|
||||
logger.info("Уведомление успешно отправлено через AdminNotificationService")
|
||||
return
|
||||
|
||||
logger.info("Отправляем уведомление напрямую администраторам")
|
||||
|
||||
cache_key = f"maintenance_notification_{alert_type}"
|
||||
if await cache.get(cache_key):
|
||||
return
|
||||
@@ -116,15 +152,13 @@ class MaintenanceService:
|
||||
|
||||
await self._save_status_to_cache()
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВКЛЮЧЕН
|
||||
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
|
||||
|
||||
📋 <b>Причина:</b> {self._status.reason}
|
||||
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
|
||||
|
||||
Обычные пользователи временно не смогут использовать бота.
|
||||
"""
|
||||
Обычные пользователи временно не смогут использовать бота."""
|
||||
|
||||
await self._notify_admins(notification_msg, "warning" if auto else "info")
|
||||
|
||||
@@ -163,15 +197,13 @@ class MaintenanceService:
|
||||
else:
|
||||
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
|
||||
|
||||
notification_msg = f"""
|
||||
Режим технических работ ВЫКЛЮЧЕН
|
||||
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
|
||||
|
||||
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
|
||||
{duration_str}
|
||||
|
||||
Сервис снова доступен для пользователей.
|
||||
"""
|
||||
Сервис снова доступен для пользователей."""
|
||||
|
||||
await self._notify_admins(notification_msg, "success")
|
||||
|
||||
@@ -191,17 +223,15 @@ class MaintenanceService:
|
||||
await self._load_status_from_cache()
|
||||
|
||||
self._check_task = asyncio.create_task(self._monitoring_loop())
|
||||
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
|
||||
|
||||
await self._notify_admins(f"""
|
||||
Мониторинг технических работ запущен
|
||||
await self._notify_admins(f"""Мониторинг технических работ запущен
|
||||
|
||||
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
|
||||
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
|
||||
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
|
||||
|
||||
Система будет следить за доступностью API.
|
||||
""", "info")
|
||||
Система будет следить за доступностью API.""", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -234,23 +264,27 @@ 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)
|
||||
|
||||
if is_connected:
|
||||
# API восстановилось
|
||||
if not self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave восстановлено!
|
||||
await self._notify_admins(f"""API Remnawave восстановлено!
|
||||
|
||||
✅ <b>Статус:</b> Доступно
|
||||
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
|
||||
|
||||
API снова отвечает на запросы.
|
||||
""", "success")
|
||||
API снова отвечает на запросы.""", "success")
|
||||
|
||||
self._status.api_status = True
|
||||
self._status.consecutive_failures = 0
|
||||
@@ -266,15 +300,13 @@ API снова отвечает на запросы.
|
||||
self._status.consecutive_failures += 1
|
||||
|
||||
if was_available:
|
||||
await self._notify_admins(f"""
|
||||
API RemnaWave недоступно!
|
||||
await self._notify_admins(f"""API Remnawave недоступно!
|
||||
|
||||
❌ <b>Статус:</b> Недоступно
|
||||
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
|
||||
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
|
||||
|
||||
Началась серия неудачных проверок API.
|
||||
""", "error")
|
||||
Началась серия неудачных проверок API.""", "error")
|
||||
|
||||
if (self._status.consecutive_failures >= self._max_consecutive_failures and
|
||||
not self._status.is_active and
|
||||
@@ -291,14 +323,12 @@ API RemnaWave недоступно!
|
||||
logger.error(f"Ошибка проверки API: {e}")
|
||||
|
||||
if self._status.api_status:
|
||||
await self._notify_admins(f"""
|
||||
Ошибка при проверке API RemnaWave
|
||||
await self._notify_admins(f"""Ошибка при проверке API Remnawave
|
||||
|
||||
❌ <b>Ошибка:</b> {str(e)}
|
||||
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
|
||||
|
||||
Не удалось выполнить проверку доступности API.
|
||||
""", "error")
|
||||
Не удалось выполнить проверку доступности API.""", "error")
|
||||
|
||||
self._status.api_status = False
|
||||
self._status.consecutive_failures += 1
|
||||
@@ -401,6 +431,33 @@ API RemnaWave недоступно!
|
||||
"checked_at": end_time,
|
||||
"consecutive_failures": self._status.consecutive_failures
|
||||
}
|
||||
|
||||
async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool:
|
||||
try:
|
||||
status_emojis = {
|
||||
"online": "🟢",
|
||||
"offline": "🔴",
|
||||
"warning": "🟡",
|
||||
"error": "⚠️"
|
||||
}
|
||||
|
||||
emoji = status_emojis.get(status, "ℹ️")
|
||||
|
||||
message = f"""Статус панели Remnawave изменился
|
||||
|
||||
{emoji} <b>Статус:</b> {status.upper()}
|
||||
🔗 <b>URL:</b> {settings.REMNAWAVE_API_URL}
|
||||
{details}"""
|
||||
|
||||
alert_type = "error" if status in ["offline", "error"] else "info"
|
||||
await self._notify_admins(message, alert_type)
|
||||
|
||||
logger.info(f"Отправлено уведомление о статусе Remnawave: {status}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}")
|
||||
return False
|
||||
|
||||
|
||||
maintenance_service = MaintenanceService()
|
||||
|
||||
@@ -17,6 +17,10 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.crud.notification import (
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -154,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
|
||||
)
|
||||
|
||||
@@ -186,31 +195,30 @@ class MonitoringService:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
|
||||
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "expiring", days)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
@@ -249,15 +257,14 @@ class MonitoringService:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
|
||||
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "trial_2h")
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
@@ -453,13 +460,13 @@ class MonitoringService:
|
||||
Ваша тестовая подписка истекает через 2 часа.
|
||||
|
||||
💎 <b>Не хотите остаться без VPN?</b>
|
||||
Переходите на полную подписку со скидкой!
|
||||
Переходите на полную подписку!
|
||||
|
||||
🔥 <b>Специальное предложение:</b>
|
||||
• 30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
|
||||
• Безлимитный трафик
|
||||
• Все серверы доступны
|
||||
• Поддержка до 3 устройств
|
||||
• Скорость до 1ГБит/сек
|
||||
|
||||
⚡️ Успейте оформить до окончания тестового периода!
|
||||
"""
|
||||
@@ -691,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
|
||||
@@ -701,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()
|
||||
@@ -721,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
|
||||
|
||||
|
||||
|
||||
@@ -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,8 +12,16 @@ 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -24,6 +32,7 @@ class PaymentService:
|
||||
self.bot = bot
|
||||
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 create_stars_invoice(
|
||||
self,
|
||||
@@ -48,7 +57,7 @@ class PaymentService:
|
||||
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
|
||||
)
|
||||
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{amount_rubles:.2f}₽)")
|
||||
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽)")
|
||||
return invoice_link
|
||||
|
||||
except Exception as e:
|
||||
@@ -90,7 +99,7 @@ class PaymentService:
|
||||
|
||||
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})")
|
||||
|
||||
description_for_referral = f"Пополнение Stars: {rubles_amount:.2f}₽ ({stars_amount} ⭐)"
|
||||
description_for_referral = f"Пополнение Stars: {int(rubles_amount)}₽ ({stars_amount} ⭐)"
|
||||
logger.info(f"🔍 Проверка реферальной логики для описания: '{description_for_referral}'")
|
||||
|
||||
if any(word in description_for_referral.lower() for word in ["пополнение", "stars", "yookassa", "topup"]) and not any(word in description_for_referral.lower() for word in ["комиссия", "бонус"]):
|
||||
@@ -108,13 +117,41 @@ class PaymentService:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user, transaction, old_balance
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -123,15 +160,18 @@ 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)}₽"
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении Stars: {e}")
|
||||
|
||||
logger.info(
|
||||
f"✅ Обработан Stars платеж: пользователь {user_id}, "
|
||||
f"{stars_amount} звезд → {rubles_amount:.2f}₽"
|
||||
f"{stars_amount} звезд → {int(rubles_amount)}₽"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
@@ -221,6 +261,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:
|
||||
@@ -297,13 +425,41 @@ class PaymentService:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
user, transaction, old_balance
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении YooKassa: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
@@ -311,9 +467,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:.2f}₽")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
|
||||
else:
|
||||
@@ -352,7 +511,7 @@ class PaymentService:
|
||||
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
if user:
|
||||
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks/100:.2f}₽")
|
||||
await add_user_balance(db, user, payment.amount_kopeks, f"Пополнение YooKassa: {payment.amount_kopeks//100}₽")
|
||||
|
||||
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
|
||||
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}₽")
|
||||
@@ -377,20 +536,55 @@ 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
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -429,17 +623,17 @@ class PaymentService:
|
||||
signature: str
|
||||
) -> bool:
|
||||
|
||||
if not settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
if not settings.TRIBUTE_API_KEY:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
message = str(data).encode()
|
||||
expected_signature = hmac.new(
|
||||
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
|
||||
settings.TRIBUTE_API_KEY.encode(),
|
||||
message,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
|
||||
except Exception as e:
|
||||
@@ -461,3 +655,226 @@ class PaymentService:
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки платежа: {e}")
|
||||
return False
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_usd: float,
|
||||
asset: str = "USDT",
|
||||
description: str = "Пополнение баланса",
|
||||
payload: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
if not self.cryptobot_service:
|
||||
logger.error("CryptoBot сервис не инициализирован")
|
||||
return None
|
||||
|
||||
try:
|
||||
amount_str = f"{amount_usd:.2f}"
|
||||
|
||||
invoice_data = await self.cryptobot_service.create_invoice(
|
||||
amount=amount_str,
|
||||
asset=asset,
|
||||
description=description,
|
||||
payload=payload or f"balance_topup_{user_id}_{int(amount_usd * 100)}",
|
||||
expires_in=settings.get_cryptobot_invoice_expires_seconds()
|
||||
)
|
||||
|
||||
if not invoice_data:
|
||||
logger.error("Ошибка создания CryptoBot invoice")
|
||||
return None
|
||||
|
||||
from app.database.crud.cryptobot import create_cryptobot_payment
|
||||
|
||||
local_payment = await create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
invoice_id=str(invoice_data['invoice_id']),
|
||||
amount=amount_str,
|
||||
asset=asset,
|
||||
status="active",
|
||||
description=description,
|
||||
payload=payload,
|
||||
bot_invoice_url=invoice_data.get('bot_invoice_url'),
|
||||
mini_app_invoice_url=invoice_data.get('mini_app_invoice_url'),
|
||||
web_app_invoice_url=invoice_data.get('web_app_invoice_url')
|
||||
)
|
||||
|
||||
logger.info(f"Создан CryptoBot платеж {invoice_data['invoice_id']} на {amount_str} {asset} для пользователя {user_id}")
|
||||
|
||||
return {
|
||||
"local_payment_id": local_payment.id,
|
||||
"invoice_id": str(invoice_data['invoice_id']),
|
||||
"amount": amount_str,
|
||||
"asset": asset,
|
||||
"bot_invoice_url": invoice_data.get('bot_invoice_url'),
|
||||
"mini_app_invoice_url": invoice_data.get('mini_app_invoice_url'),
|
||||
"web_app_invoice_url": invoice_data.get('web_app_invoice_url'),
|
||||
"status": "active",
|
||||
"created_at": local_payment.created_at.isoformat() if local_payment.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания CryptoBot платежа: {e}")
|
||||
return None
|
||||
|
||||
async def process_cryptobot_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
|
||||
try:
|
||||
from app.database.crud.cryptobot import (
|
||||
get_cryptobot_payment_by_invoice_id,
|
||||
update_cryptobot_payment_status,
|
||||
link_cryptobot_payment_to_transaction
|
||||
)
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
|
||||
update_type = webhook_data.get("update_type")
|
||||
|
||||
if update_type != "invoice_paid":
|
||||
logger.info(f"Пропуск CryptoBot webhook с типом: {update_type}")
|
||||
return True
|
||||
|
||||
payload = webhook_data.get("payload", {})
|
||||
invoice_id = str(payload.get("invoice_id"))
|
||||
status = "paid"
|
||||
|
||||
if not invoice_id:
|
||||
logger.error("CryptoBot webhook без invoice_id")
|
||||
return False
|
||||
|
||||
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
|
||||
if not payment:
|
||||
logger.error(f"CryptoBot платеж не найден в БД: {invoice_id}")
|
||||
return False
|
||||
|
||||
if payment.status == "paid":
|
||||
logger.info(f"CryptoBot платеж {invoice_id} уже обработан")
|
||||
return True
|
||||
|
||||
paid_at_str = payload.get("paid_at")
|
||||
paid_at = None
|
||||
if paid_at_str:
|
||||
try:
|
||||
paid_at = datetime.fromisoformat(paid_at_str.replace('Z', '+00:00')).replace(tzinfo=None)
|
||||
except:
|
||||
paid_at = datetime.utcnow()
|
||||
else:
|
||||
paid_at = datetime.utcnow()
|
||||
|
||||
updated_payment = await update_cryptobot_payment_status(
|
||||
db, invoice_id, status, paid_at
|
||||
)
|
||||
|
||||
if not updated_payment.transaction_id:
|
||||
amount_usd = updated_payment.amount_float
|
||||
|
||||
try:
|
||||
amount_rubles = await currency_converter.usd_to_rub(amount_usd)
|
||||
amount_kopeks = int(amount_rubles * 100)
|
||||
conversion_rate = amount_rubles / amount_usd if amount_usd > 0 else 0
|
||||
logger.info(f"Конвертация USD->RUB: ${amount_usd} -> {amount_rubles}₽ (курс: {conversion_rate:.2f})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка конвертации валют для платежа {invoice_id}, используем курс 1:1: {e}")
|
||||
amount_rubles = amount_usd
|
||||
amount_kopeks = int(amount_usd * 100)
|
||||
conversion_rate = 1.0
|
||||
|
||||
if amount_kopeks <= 0:
|
||||
logger.error(f"Некорректная сумма после конвертации: {amount_kopeks} копеек для платежа {invoice_id}")
|
||||
return False
|
||||
|
||||
transaction = await create_transaction(
|
||||
db,
|
||||
user_id=updated_payment.user_id,
|
||||
type=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=f"Пополнение через CryptoBot ({updated_payment.amount} {updated_payment.asset} → {amount_rubles:.2f}₽)",
|
||||
payment_method=PaymentMethod.CRYPTOBOT,
|
||||
external_id=invoice_id,
|
||||
is_completed=True
|
||||
)
|
||||
|
||||
await link_cryptobot_payment_to_transaction(
|
||||
db, invoice_id, transaction.id
|
||||
)
|
||||
|
||||
user = await get_user_by_id(db, updated_payment.user_id)
|
||||
if user:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(db, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения CryptoBot: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
db, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
|
||||
if self.bot:
|
||||
try:
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
f"✅ <b>Пополнение успешно!</b>\n\n"
|
||||
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
|
||||
f"🪙 Платеж: {updated_payment.amount} {updated_payment.asset}\n"
|
||||
f"💱 Курс: 1 USD = {conversion_rate:.2f}₽\n"
|
||||
f"🆔 Транзакция: {invoice_id[:8]}...\n\n"
|
||||
f"Баланс пополнен автоматически!",
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {amount_rubles:.2f}₽ ({updated_payment.asset})")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о пополнении CryptoBot: {e}")
|
||||
else:
|
||||
logger.error(f"Пользователь с ID {updated_payment.user_id} не найден при пополнении баланса")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки CryptoBot webhook: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -55,7 +55,7 @@ async def process_referral_registration(
|
||||
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
|
||||
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
|
||||
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
|
||||
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
|
||||
)
|
||||
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
|
||||
|
||||
@@ -63,7 +63,8 @@ async def process_referral_registration(
|
||||
f"👥 <b>Новый реферал!</b>\n\n"
|
||||
f"По вашей ссылке зарегистрировался пользователь <b>{new_user.full_name}</b>!\n\n"
|
||||
f"💰 Когда он пополнит баланс от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}, "
|
||||
f"вы получите {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"вы получите минимум {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)} или "
|
||||
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
|
||||
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
|
||||
@@ -78,7 +79,7 @@ async def process_referral_registration(
|
||||
|
||||
async def process_referral_topup(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
user_id: int,
|
||||
topup_amount_kopeks: int,
|
||||
bot: Bot = None
|
||||
):
|
||||
@@ -105,12 +106,12 @@ async def process_referral_topup(
|
||||
await db.execute(
|
||||
delete(ReferralEarning).where(
|
||||
ReferralEarning.user_id == referrer.id,
|
||||
ReferralEarning.referral_id == user_id,
|
||||
ReferralEarning.referral_id == user.id,
|
||||
ReferralEarning.reason == "referral_registration_pending"
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user_id}")
|
||||
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления записи ожидания: {e}")
|
||||
|
||||
@@ -120,7 +121,7 @@ async def process_referral_topup(
|
||||
f"Бонус за первое пополнение по реферальной программе",
|
||||
bot=bot
|
||||
)
|
||||
logger.info(f"💰 Реферал {user_id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}₽")
|
||||
logger.info(f"💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}₽")
|
||||
|
||||
if bot:
|
||||
bonus_notification = (
|
||||
@@ -131,27 +132,30 @@ async def process_referral_topup(
|
||||
)
|
||||
await send_referral_notification(bot, user.telegram_id, bonus_notification)
|
||||
|
||||
if settings.REFERRAL_INVITER_BONUS_KOPEKS > 0:
|
||||
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
|
||||
inviter_bonus = max(settings.REFERRAL_INVITER_BONUS_KOPEKS, commission_amount)
|
||||
|
||||
if inviter_bonus > 0:
|
||||
await add_user_balance(
|
||||
db, referrer, settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
db, referrer, inviter_bonus,
|
||||
f"Бонус за первое пополнение реферала {user.full_name}",
|
||||
bot=bot
|
||||
)
|
||||
|
||||
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
amount_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=inviter_bonus,
|
||||
reason="referral_first_topup"
|
||||
)
|
||||
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {settings.REFERRAL_INVITER_BONUS_KOPEKS/100}₽")
|
||||
|
||||
logger.info(f"💰 Реферер {referrer.telegram_id} получил бонус {inviter_bonus/100}₽")
|
||||
|
||||
if bot:
|
||||
inviter_bonus_notification = (
|
||||
f"💰 <b>Реферальная награда!</b>\n\n"
|
||||
f"Ваш реферал <b>{user.full_name}</b> сделал первое пополнение!\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}\n\n"
|
||||
f"🎁 Вы получили награду: {settings.format_price(inviter_bonus)}\n\n"
|
||||
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
|
||||
)
|
||||
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
|
||||
@@ -170,7 +174,7 @@ async def process_referral_topup(
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission_topup"
|
||||
)
|
||||
@@ -230,7 +234,7 @@ async def process_referral_purchase(
|
||||
await create_referral_earning(
|
||||
db=db,
|
||||
user_id=referrer.id,
|
||||
referral_id=user_id,
|
||||
referral_id=user.id,
|
||||
amount_kopeks=commission_amount,
|
||||
reason="referral_commission",
|
||||
referral_transaction_id=transaction_id
|
||||
|
||||
@@ -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:
|
||||
@@ -200,7 +203,7 @@ class RemnaWaveService:
|
||||
return result
|
||||
|
||||
except RemnaWaveAPIError as e:
|
||||
logger.error(f"Ошибка RemnaWave API при получении статистики: {e}")
|
||||
logger.error(f"Ошибка Remnawave API при получении статистики: {e}")
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Общая ошибка получения системной статистики: {e}")
|
||||
@@ -271,11 +274,11 @@ class RemnaWaveService:
|
||||
'traffic_limit_bytes': node.traffic_limit_bytes
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} нод из RemnaWave")
|
||||
logger.info(f"✅ Получено {len(result)} нод из Remnawave")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения нод из RemnaWave: {e}")
|
||||
logger.error(f"Ошибка получения нод из Remnawave: {e}")
|
||||
return []
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
@@ -283,11 +286,11 @@ class RemnaWaveService:
|
||||
try:
|
||||
async with self.api as api:
|
||||
stats = await api.get_system_stats()
|
||||
logger.info("✅ Соединение с RemnaWave API работает")
|
||||
logger.info("✅ Соединение с Remnawave API работает")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка соединения с RemnaWave API: {e}")
|
||||
logger.error(f"❌ Ошибка соединения с Remnawave API: {e}")
|
||||
return False
|
||||
|
||||
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -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
|
||||
@@ -378,11 +381,11 @@ class RemnaWaveService:
|
||||
'inbounds': squad.inbounds
|
||||
})
|
||||
|
||||
logger.info(f"✅ Получено {len(result)} сквадов из RemnaWave")
|
||||
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сквадов из RemnaWave: {e}")
|
||||
logger.error(f"Ошибка получения сквадов из Remnawave: {e}")
|
||||
return []
|
||||
|
||||
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
|
||||
@@ -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:
|
||||
@@ -1119,9 +1131,6 @@ class RemnaWaveService:
|
||||
return False
|
||||
|
||||
async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
|
||||
"""
|
||||
Усиленная версия очистки с полным удалением данных
|
||||
"""
|
||||
try:
|
||||
stats = {"deactivated": 0, "errors": 0, "checked": 0}
|
||||
|
||||
@@ -1286,10 +1295,10 @@ class RemnaWaveService:
|
||||
if rw_user:
|
||||
subscription.remnawave_short_uuid = rw_user.short_uuid
|
||||
subscription.subscription_url = rw_user.subscription_url
|
||||
logger.info(f"🔧 Восстановлены данные RemnaWave для {user.telegram_id}")
|
||||
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
|
||||
issues_fixed += 1
|
||||
except Exception as rw_error:
|
||||
logger.warning(f"⚠️ Не удалось получить данные RemnaWave для {user.telegram_id}: {rw_error}")
|
||||
logger.warning(f"⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}")
|
||||
|
||||
if subscription.traffic_limit_gb < 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
@@ -1356,7 +1365,7 @@ class RemnaWaveService:
|
||||
recommendations["should_sync"] = True
|
||||
recommendations["sync_type"] = "all"
|
||||
recommendations["priority"] = "high"
|
||||
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с RemnaWave")
|
||||
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с Remnawave")
|
||||
recommendations["estimated_time"] = "3-5 минут"
|
||||
|
||||
if active_expired > 5:
|
||||
@@ -1382,3 +1391,211 @@ class RemnaWaveService:
|
||||
"priority": "medium",
|
||||
"estimated_time": "3-5 минут"
|
||||
}
|
||||
|
||||
async def monitor_panel_status(self, bot) -> Dict[str, Any]:
|
||||
try:
|
||||
from app.utils.cache import cache
|
||||
previous_status = await cache.get("remnawave_panel_status") or "unknown"
|
||||
|
||||
status_result = await self.check_panel_health()
|
||||
current_status = status_result.get("status", "offline")
|
||||
|
||||
if current_status != previous_status and previous_status != "unknown":
|
||||
await self._send_status_change_notification(
|
||||
bot,
|
||||
previous_status,
|
||||
current_status,
|
||||
status_result
|
||||
)
|
||||
|
||||
await cache.set("remnawave_panel_status", current_status, expire=300)
|
||||
|
||||
return status_result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка мониторинга статуса панели Remnawave: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
|
||||
async def _send_status_change_notification(
|
||||
self,
|
||||
bot,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
status_data: Dict[str, Any]
|
||||
):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
|
||||
details = {
|
||||
"api_url": status_data.get("api_url"),
|
||||
"response_time": status_data.get("response_time"),
|
||||
"last_check": status_data.get("last_check"),
|
||||
"users_online": status_data.get("users_online"),
|
||||
"nodes_online": status_data.get("nodes_online"),
|
||||
"total_nodes": status_data.get("total_nodes"),
|
||||
"old_status": old_status
|
||||
}
|
||||
|
||||
if new_status == "offline":
|
||||
details["error"] = status_data.get("api_error")
|
||||
elif new_status == "degraded":
|
||||
issues = []
|
||||
if status_data.get("response_time", 0) > 10:
|
||||
issues.append(f"Медленный отклик API ({status_data.get('response_time')}с)")
|
||||
if status_data.get("nodes_health") == "unhealthy":
|
||||
issues.append(f"Проблемы с нодами ({status_data.get('nodes_online')}/{status_data.get('total_nodes')} онлайн)")
|
||||
details["issues"] = issues
|
||||
|
||||
await notification_service.send_remnawave_panel_status_notification(
|
||||
new_status,
|
||||
details
|
||||
)
|
||||
|
||||
logger.info(f"Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об изменении статуса: {e}")
|
||||
|
||||
|
||||
|
||||
async def send_manual_status_notification(self, bot, status: str, message: str = ""):
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
notification_service = AdminNotificationService(bot)
|
||||
|
||||
details = {
|
||||
"api_url": settings.REMNAWAVE_API_URL,
|
||||
"last_check": datetime.utcnow(),
|
||||
"manual_message": message
|
||||
}
|
||||
|
||||
if status == "maintenance":
|
||||
details["maintenance_reason"] = message or "Плановое обслуживание"
|
||||
|
||||
await notification_service.send_remnawave_panel_status_notification(status, details)
|
||||
|
||||
logger.info(f"Отправлено ручное уведомление о статусе панели: {status}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки ручного уведомления: {e}")
|
||||
return False
|
||||
|
||||
async def get_panel_status_summary(self) -> Dict[str, Any]:
|
||||
try:
|
||||
status_data = await self.check_panel_health()
|
||||
|
||||
status_descriptions = {
|
||||
"online": "🟢 Панель работает нормально",
|
||||
"offline": "🔴 Панель недоступна",
|
||||
"degraded": "🟡 Панель работает со сбоями",
|
||||
"maintenance": "🔧 Панель на обслуживании"
|
||||
}
|
||||
|
||||
status = status_data.get("status", "offline")
|
||||
|
||||
summary = {
|
||||
"status": status,
|
||||
"description": status_descriptions.get(status, "❓ Статус неизвестен"),
|
||||
"response_time": status_data.get("response_time", 0),
|
||||
"api_available": status_data.get("api_available", False),
|
||||
"nodes_status": f"{status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)} нод онлайн",
|
||||
"users_online": status_data.get("users_online", 0),
|
||||
"last_check": status_data.get("last_check"),
|
||||
"has_issues": status in ["offline", "degraded"]
|
||||
}
|
||||
|
||||
if status == "offline":
|
||||
summary["recommendation"] = "Проверьте подключение к серверу и работоспособность панели"
|
||||
elif status == "degraded":
|
||||
summary["recommendation"] = "Рекомендуется проверить состояние нод и производительность сервера"
|
||||
else:
|
||||
summary["recommendation"] = "Все системы работают нормально"
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения сводки статуса панели: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"description": "❌ Ошибка проверки статуса",
|
||||
"response_time": 0,
|
||||
"api_available": False,
|
||||
"nodes_status": "неизвестно",
|
||||
"users_online": 0,
|
||||
"last_check": datetime.utcnow(),
|
||||
"has_issues": True,
|
||||
"recommendation": "Обратитесь к системному администратору",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
async def check_panel_health(self) -> Dict[str, Any]:
|
||||
try:
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with self.api as api:
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
api_available = True
|
||||
api_error = None
|
||||
except Exception as e:
|
||||
api_available = False
|
||||
api_error = str(e)
|
||||
system_stats = {}
|
||||
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online)
|
||||
total_nodes = len(nodes)
|
||||
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
|
||||
except Exception:
|
||||
nodes_online = 0
|
||||
total_nodes = 0
|
||||
nodes_health = "unknown"
|
||||
|
||||
end_time = datetime.utcnow()
|
||||
response_time = (end_time - start_time).total_seconds()
|
||||
|
||||
if not api_available:
|
||||
status = "offline"
|
||||
elif response_time > 10:
|
||||
status = "degraded"
|
||||
elif nodes_health == "unhealthy":
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "online"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"api_available": api_available,
|
||||
"api_error": api_error,
|
||||
"response_time": round(response_time, 2),
|
||||
"nodes_online": nodes_online,
|
||||
"total_nodes": total_nodes,
|
||||
"nodes_health": nodes_health,
|
||||
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
|
||||
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
|
||||
"last_check": end_time,
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки здоровья панели: {e}")
|
||||
return {
|
||||
"status": "offline",
|
||||
"api_available": False,
|
||||
"api_error": str(e),
|
||||
"response_time": 0,
|
||||
"nodes_online": 0,
|
||||
"total_nodes": 0,
|
||||
"nodes_health": "unknown",
|
||||
"last_check": datetime.utcnow(),
|
||||
"api_url": settings.REMNAWAVE_API_URL
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
@@ -17,6 +15,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.localization.texts import get_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,15 +55,9 @@ class TributeService:
|
||||
|
||||
async def process_webhook(
|
||||
self,
|
||||
payload: str,
|
||||
signature: Optional[str] = None
|
||||
payload: str
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
|
||||
if not self.tribute_api.verify_webhook_signature(payload, signature):
|
||||
logger.warning("Неверная подпись Tribute webhook")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
try:
|
||||
webhook_data = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
@@ -90,37 +83,38 @@ class TributeService:
|
||||
return {"status": "ok", "event": event_type}
|
||||
|
||||
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
user_telegram_id = payment_data["user_id"]
|
||||
amount_kopeks = payment_data["amount_kopeks"]
|
||||
payment_id = payment_data["payment_id"]
|
||||
|
||||
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||||
logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}")
|
||||
|
||||
async for session in get_db():
|
||||
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
|
||||
|
||||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||||
session, payment_id, amount_kopeks, user_id
|
||||
)
|
||||
|
||||
if duplicate_transaction:
|
||||
logger.warning(f"Найден дубликат платежа:")
|
||||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||||
logger.warning(f"Платеж игнорирован")
|
||||
return
|
||||
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
user = await get_user_by_telegram_id(session, user_telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь {user_id} не найден")
|
||||
logger.error(f"Пользователь {user_telegram_id} не найден")
|
||||
return
|
||||
|
||||
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
|
||||
|
||||
from app.database.crud.transaction import check_tribute_payment_duplicate
|
||||
|
||||
duplicate_transaction = await check_tribute_payment_duplicate(
|
||||
session, payment_id, amount_kopeks, user_telegram_id
|
||||
)
|
||||
|
||||
if duplicate_transaction:
|
||||
logger.warning(f"Найден дубликат платежа в течение 24ч:")
|
||||
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
|
||||
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
|
||||
logger.warning(f" Created: {duplicate_transaction.created_at}")
|
||||
logger.warning(f" External ID: {duplicate_transaction.external_id}")
|
||||
logger.warning(f"Платеж игнорирован - это дубликат свежего платежа")
|
||||
return
|
||||
|
||||
from app.database.crud.transaction import create_unique_tribute_transaction
|
||||
|
||||
transaction = await create_unique_tribute_transaction(
|
||||
db=session,
|
||||
user_id=user.id,
|
||||
@@ -135,18 +129,38 @@ class TributeService:
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
logger.info(f"✅ Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
|
||||
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
|
||||
|
||||
await self._send_success_notification(user_id, amount_kopeks)
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
|
||||
|
||||
if not user.has_made_first_topup:
|
||||
user.has_made_first_topup = True
|
||||
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
|
||||
|
||||
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(self.bot)
|
||||
await notification_service.send_balance_topup_notification(
|
||||
session, user, transaction, old_balance
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
|
||||
|
||||
await self._send_success_notification(user_telegram_id, amount_kopeks)
|
||||
|
||||
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||||
logger.error(f"⌘ Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
|
||||
|
||||
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
|
||||
"""Обработка неудачного платежа"""
|
||||
|
||||
try:
|
||||
user_id = payment_data["user_id"]
|
||||
@@ -170,7 +184,6 @@ class TributeService:
|
||||
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
|
||||
|
||||
async def _handle_refund(self, refund_data: Dict[str, Any]):
|
||||
"""Обработка возврата"""
|
||||
|
||||
try:
|
||||
user_id = refund_data["user_id"]
|
||||
@@ -203,24 +216,51 @@ 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
|
||||
|
||||
user_language = user.language if user else "ru"
|
||||
texts = get_texts(user_language)
|
||||
|
||||
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 = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[first_button],
|
||||
[InlineKeyboardButton(text="💰 Мой баланс", callback_data="menu_balance")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")]
|
||||
]
|
||||
)
|
||||
|
||||
text = (
|
||||
f"✅ **Платеж успешно получен!**\n\n"
|
||||
f"💰 Сумма: {amount_rubles:.2f} ₽\n"
|
||||
f"💰 Сумма: {int(amount_rubles)} ₽\n"
|
||||
f"💳 Способ оплаты: Tribute\n"
|
||||
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,
|
||||
@@ -232,11 +272,10 @@ class TributeService:
|
||||
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
|
||||
|
||||
async def _send_failure_notification(self, user_id: int):
|
||||
"""Отправка уведомления о неудачном платеже"""
|
||||
|
||||
try:
|
||||
text = (
|
||||
"❌ **Платеж не прошел**\n\n"
|
||||
"⌘ **Платеж не прошел**\n\n"
|
||||
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
|
||||
"Возможные причины:\n"
|
||||
"• Недостаточно средств на карте\n"
|
||||
@@ -261,14 +300,13 @@ class TributeService:
|
||||
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
|
||||
|
||||
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
|
||||
"""Отправка уведомления о возврате"""
|
||||
|
||||
try:
|
||||
amount_rubles = amount_kopeks / 100
|
||||
|
||||
text = (
|
||||
f"🔄 **Возврат средств**\n\n"
|
||||
f"💰 Сумма возврата: {amount_rubles:.2f} ₽\n"
|
||||
f"💰 Сумма возврата: {int(amount_rubles)} ₽\n"
|
||||
f"💳 Способ: Tribute\n\n"
|
||||
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
|
||||
f"Если у вас есть вопросы, обратитесь в поддержку."
|
||||
@@ -296,7 +334,6 @@ class TributeService:
|
||||
amount_kopeks: int,
|
||||
description: str = "Принудительная обработка Tribute платежа"
|
||||
) -> bool:
|
||||
"""Принудительная обработка платежа (для отладки)"""
|
||||
|
||||
try:
|
||||
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
|
||||
@@ -304,7 +341,7 @@ class TributeService:
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.error(f"❌ Пользователь {user_id} не найден")
|
||||
logger.error(f"⌘ Пользователь {user_id} не найден")
|
||||
return False
|
||||
|
||||
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
|
||||
@@ -334,7 +371,7 @@ class TributeService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка принудительной обработки: {e}", exc_info=True)
|
||||
logger.error(f"⌘ Ошибка принудительной обработки: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
@@ -127,22 +183,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,18 +313,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 YooKassaPayment
|
||||
from sqlalchemy import select
|
||||
sent_notifications_result = await db.execute(
|
||||
select(SentNotification).where(SentNotification.user_id == user_id)
|
||||
)
|
||||
sent_notifications = sent_notifications_result.scalars().all()
|
||||
|
||||
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 user_messages_result.rowcount > 0:
|
||||
logger.info(f"🔄 Обновлено {user_messages_result.rowcount} пользовательских сообщений")
|
||||
await db.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
|
||||
|
||||
try:
|
||||
promocodes_result = await db.execute(
|
||||
update(PromoCode)
|
||||
.where(PromoCode.created_by == user_id)
|
||||
.values(created_by=None)
|
||||
)
|
||||
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:
|
||||
yookassa_result = await db.execute(
|
||||
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
|
||||
)
|
||||
@@ -257,14 +438,40 @@ 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:
|
||||
cryptobot_result = await db.execute(
|
||||
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
|
||||
)
|
||||
cryptobot_payments = cryptobot_result.scalars().all()
|
||||
|
||||
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()
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
|
||||
|
||||
try:
|
||||
transactions_result = await db.execute(
|
||||
select(Transaction).where(Transaction.user_id == user_id)
|
||||
@@ -277,49 +484,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)
|
||||
)
|
||||
@@ -331,46 +544,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)
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import aiohttp
|
||||
from packaging import version
|
||||
import re
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VersionInfo:
|
||||
def __init__(self, tag_name: str, published_at: str, name: str, body: str, prerelease: bool = False):
|
||||
self.tag_name = tag_name
|
||||
self.published_at = datetime.fromisoformat(published_at.replace('Z', '+00:00'))
|
||||
self.name = name or tag_name
|
||||
self.body = body
|
||||
self.prerelease = prerelease
|
||||
self.is_dev = 'dev' in tag_name.lower()
|
||||
|
||||
@property
|
||||
def clean_version(self) -> str:
|
||||
return re.sub(r'^v', '', self.tag_name)
|
||||
|
||||
@property
|
||||
def version_obj(self):
|
||||
try:
|
||||
clean_ver = self.clean_version
|
||||
if 'dev' in clean_ver:
|
||||
base_ver = clean_ver.split('-dev')[0]
|
||||
return version.parse(f"{base_ver}.dev")
|
||||
return version.parse(clean_ver)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
@property
|
||||
def formatted_date(self) -> str:
|
||||
return self.published_at.strftime('%d.%m.%Y %H:%M')
|
||||
|
||||
@property
|
||||
def short_description(self) -> str:
|
||||
if not self.body:
|
||||
return "Без описания"
|
||||
|
||||
description = self.body.strip()
|
||||
if len(description) > 350:
|
||||
description = description[:347] + "..."
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class VersionService:
|
||||
def __init__(self, bot=None):
|
||||
self.bot = bot
|
||||
self.repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
|
||||
self.enabled = getattr(settings, 'VERSION_CHECK_ENABLED', True)
|
||||
self.current_version = self._get_current_version()
|
||||
self.cache_ttl = 3600
|
||||
self._cache: Dict = {}
|
||||
self._last_check: Optional[datetime] = None
|
||||
self._notification_service = None
|
||||
|
||||
async def get_latest_stable_version(self) -> str:
|
||||
try:
|
||||
url = f"https://api.github.com/repos/{self.repo}/releases/latest"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data['tag_name']
|
||||
except Exception:
|
||||
pass
|
||||
return "UNKNOW"
|
||||
|
||||
def _get_current_version(self) -> str:
|
||||
import os
|
||||
|
||||
current = os.getenv('VERSION', '').strip()
|
||||
|
||||
if current:
|
||||
if '-' in current and current.startswith('v'):
|
||||
base_version = current.split('-')[0]
|
||||
if base_version.count('.') == 2:
|
||||
return base_version
|
||||
return current
|
||||
|
||||
return "UNKNOW"
|
||||
|
||||
def set_notification_service(self, notification_service):
|
||||
self._notification_service = notification_service
|
||||
|
||||
async def check_for_updates(self, force: bool = False) -> Tuple[bool, List[VersionInfo]]:
|
||||
if not self.enabled:
|
||||
return False, []
|
||||
|
||||
try:
|
||||
releases = await self._fetch_releases(force)
|
||||
if not releases:
|
||||
return False, []
|
||||
|
||||
current_ver = self._parse_version(self.current_version)
|
||||
newer_releases = []
|
||||
|
||||
for release in releases:
|
||||
release_ver = release.version_obj
|
||||
if release_ver > current_ver:
|
||||
newer_releases.append(release)
|
||||
|
||||
newer_releases.sort(key=lambda x: x.version_obj, reverse=True)
|
||||
|
||||
has_updates = len(newer_releases) > 0
|
||||
|
||||
if has_updates and not force:
|
||||
await self._send_update_notification(newer_releases)
|
||||
|
||||
return has_updates, newer_releases
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки обновлений: {e}")
|
||||
return False, []
|
||||
|
||||
async def _fetch_releases(self, force: bool = False) -> List[VersionInfo]:
|
||||
if not force and self._cache and self._last_check:
|
||||
if datetime.now() - self._last_check < timedelta(seconds=self.cache_ttl):
|
||||
return self._cache.get('releases', [])
|
||||
|
||||
url = f"https://api.github.com/repos/{self.repo}/releases"
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
|
||||
for release_data in data[:20]:
|
||||
release = VersionInfo(
|
||||
tag_name=release_data['tag_name'],
|
||||
published_at=release_data['published_at'],
|
||||
name=release_data['name'],
|
||||
body=release_data['body'] or '',
|
||||
prerelease=release_data['prerelease']
|
||||
)
|
||||
releases.append(release)
|
||||
|
||||
self._cache['releases'] = releases
|
||||
self._last_check = datetime.now()
|
||||
|
||||
logger.info(f"Получено {len(releases)} релизов из GitHub")
|
||||
return releases
|
||||
else:
|
||||
logger.warning(f"GitHub API вернул статус {response.status}")
|
||||
return []
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Таймаут при запросе к GitHub API")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запроса к GitHub API: {e}")
|
||||
return []
|
||||
|
||||
def _parse_version(self, version_str: str):
|
||||
try:
|
||||
clean_ver = re.sub(r'^v', '', version_str)
|
||||
if 'dev' in clean_ver:
|
||||
base_ver = clean_ver.split('-dev')[0]
|
||||
return version.parse(f"{base_ver}.dev")
|
||||
if 'unknow' in clean_ver.lower():
|
||||
return version.parse("0.0.0")
|
||||
return version.parse(clean_ver)
|
||||
except Exception:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
async def _send_update_notification(self, newer_releases: List[VersionInfo]):
|
||||
if not self._notification_service or not newer_releases:
|
||||
return
|
||||
|
||||
try:
|
||||
latest_version = newer_releases[0]
|
||||
cache_key = f"notified_{latest_version.tag_name}"
|
||||
|
||||
if self._cache.get(cache_key):
|
||||
return
|
||||
|
||||
await self._notification_service.send_version_update_notification(
|
||||
current_version=self.current_version,
|
||||
latest_version=latest_version,
|
||||
total_updates=len(newer_releases)
|
||||
)
|
||||
|
||||
self._cache[cache_key] = True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
|
||||
|
||||
async def get_version_info(self) -> Dict:
|
||||
try:
|
||||
has_updates, newer_releases = await self.check_for_updates()
|
||||
all_releases = await self._fetch_releases()
|
||||
|
||||
current_release = None
|
||||
current_ver = self._parse_version(self.current_version)
|
||||
|
||||
for release in all_releases:
|
||||
if release.version_obj == current_ver:
|
||||
current_release = release
|
||||
break
|
||||
|
||||
return {
|
||||
'current_version': self.current_version,
|
||||
'current_release': current_release,
|
||||
'has_updates': has_updates,
|
||||
'newer_releases': newer_releases[:5],
|
||||
'total_newer': len(newer_releases),
|
||||
'last_check': self._last_check,
|
||||
'repo_url': f"https://github.com/{self.repo}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения информации о версиях: {e}")
|
||||
return {
|
||||
'current_version': self.current_version,
|
||||
'current_release': None,
|
||||
'has_updates': False,
|
||||
'newer_releases': [],
|
||||
'total_newer': 0,
|
||||
'last_check': None,
|
||||
'repo_url': f"https://github.com/{self.repo}",
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
async def start_periodic_check(self):
|
||||
if not self.enabled:
|
||||
logger.info("Проверка версий отключена")
|
||||
return
|
||||
|
||||
logger.info(f"Запуск периодической проверки обновлений для {self.repo}")
|
||||
logger.info(f"Текущая версия: {self.current_version}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
await self.check_for_updates()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Остановка проверки обновлений")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в периодической проверке обновлений: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
def format_version_display(self, version_info: VersionInfo) -> str:
|
||||
status_icon = ""
|
||||
if version_info.prerelease:
|
||||
status_icon = "🧪"
|
||||
elif version_info.is_dev:
|
||||
status_icon = "🔧"
|
||||
else:
|
||||
status_icon = "📦"
|
||||
|
||||
return f"{status_icon} {version_info.tag_name}"
|
||||
|
||||
|
||||
version_service = VersionService()
|
||||
@@ -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]]:
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -19,7 +17,6 @@ class SubscriptionStates(StatesGroup):
|
||||
extending_subscription = State()
|
||||
confirming_traffic_reset = State()
|
||||
|
||||
|
||||
class BalanceStates(StatesGroup):
|
||||
waiting_for_amount = State()
|
||||
waiting_for_stars_payment = State()
|
||||
@@ -30,7 +27,6 @@ class PromoCodeStates(StatesGroup):
|
||||
waiting_for_code = State()
|
||||
waiting_for_referral_code = State()
|
||||
|
||||
|
||||
class AdminStates(StatesGroup):
|
||||
|
||||
waiting_for_user_search = State()
|
||||
@@ -47,6 +43,7 @@ class AdminStates(StatesGroup):
|
||||
setting_promocode_expiry = State()
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
@@ -69,12 +66,13 @@ 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"
|
||||
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
@@ -85,3 +83,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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CurrencyConverter:
|
||||
|
||||
def __init__(self):
|
||||
self._cache = {}
|
||||
self._cache_ttl = 3600 # 1 час
|
||||
self._last_update = {}
|
||||
|
||||
async def get_usd_to_rub_rate(self) -> float:
|
||||
"""Получает курс USD/RUB с кешированием"""
|
||||
|
||||
cache_key = "USD_RUB"
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Проверяем кеш
|
||||
if (cache_key in self._cache and
|
||||
cache_key in self._last_update and
|
||||
(now - self._last_update[cache_key]).seconds < self._cache_ttl):
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Получаем новый курс
|
||||
rate = await self._fetch_exchange_rate()
|
||||
|
||||
if rate:
|
||||
self._cache[cache_key] = rate
|
||||
self._last_update[cache_key] = now
|
||||
logger.info(f"Обновлен курс USD/RUB: {rate}")
|
||||
return rate
|
||||
|
||||
# Возвращаем из кеша если API недоступен
|
||||
if cache_key in self._cache:
|
||||
logger.warning("API курсов недоступен, используем кешированный курс")
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Fallback курс
|
||||
logger.warning("Используем fallback курс USD/RUB: 95")
|
||||
return 95.0
|
||||
|
||||
async def _fetch_exchange_rate(self) -> Optional[float]:
|
||||
"""Получает курс с нескольких источников"""
|
||||
|
||||
sources = [
|
||||
self._fetch_from_cbr,
|
||||
self._fetch_from_exchangerate_api,
|
||||
self._fetch_from_fixer
|
||||
]
|
||||
|
||||
for source in sources:
|
||||
try:
|
||||
rate = await source()
|
||||
if rate and 50 < rate < 200: # Разумные границы курса
|
||||
return rate
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса из {source.__name__}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def _fetch_from_cbr(self) -> Optional[float]:
|
||||
"""Получает курс с сайта ЦБ РФ"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get('https://www.cbr-xml-daily.ru/daily_json.js') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
usd_rate = data['Valute']['USD']['Value']
|
||||
return float(usd_rate)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса ЦБ: {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_from_exchangerate_api(self) -> Optional[float]:
|
||||
"""Получает курс с exchangerate-api.com"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
async with session.get('https://api.exchangerate-api.com/v4/latest/USD') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
rub_rate = data['rates']['RUB']
|
||||
return float(rub_rate)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса exchangerate-api: {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_from_fixer(self) -> Optional[float]:
|
||||
"""Получает курс с fixer.io (бесплатный план)"""
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
||||
# Используем бесплатный endpoint (EUR base)
|
||||
async with session.get('https://api.fixer.io/latest?access_key=YOUR_API_KEY&symbols=USD,RUB') as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('success'):
|
||||
# Конвертируем EUR -> USD -> RUB
|
||||
usd_eur = data['rates']['USD']
|
||||
rub_eur = data['rates']['RUB']
|
||||
usd_rub = rub_eur / usd_eur
|
||||
return float(usd_rub)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка получения курса fixer: {e}")
|
||||
return None
|
||||
|
||||
async def usd_to_rub(self, usd_amount: float) -> float:
|
||||
"""Конвертирует USD в RUB"""
|
||||
rate = await self.get_usd_to_rub_rate()
|
||||
return usd_amount * rate
|
||||
|
||||
async def rub_to_usd(self, rub_amount: float) -> float:
|
||||
"""Конвертирует RUB в USD"""
|
||||
rate = await self.get_usd_to_rub_rate()
|
||||
return rub_amount / rate
|
||||
|
||||
# Глобальный экземпляр
|
||||
currency_converter = CurrencyConverter()
|
||||
@@ -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,123 @@
|
||||
from typing import List, Dict, Tuple
|
||||
from app.config import settings
|
||||
|
||||
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() -> str:
|
||||
"""
|
||||
Генерирует текст с описанием доступных способов оплаты
|
||||
"""
|
||||
methods = get_available_payment_methods()
|
||||
|
||||
if len(methods) <= 1: # Только поддержка
|
||||
return """💳 <b>Способы пополнения баланса</b>
|
||||
|
||||
⚠️ В данный момент автоматические способы оплаты временно недоступны.
|
||||
Обратитесь в техподдержку для пополнения баланса.
|
||||
|
||||
Выберите способ пополнения:"""
|
||||
|
||||
text = "💳 <b>Способы пополнения баланса</b>\n\n"
|
||||
text += "Выберите удобный для вас способ оплаты:\n\n"
|
||||
|
||||
for method in methods:
|
||||
text += f"{method['icon']} <b>{method['name']}</b> - {method['description']}\n"
|
||||
|
||||
text += "\nВыберите способ пополнения:"
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, Tuple
|
||||
from datetime import datetime
|
||||
import html
|
||||
|
||||
ALLOWED_HTML_TAGS = {
|
||||
'b', 'strong',
|
||||
'i', 'em',
|
||||
'u', 'ins',
|
||||
's', 'strike', 'del',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'blockquote'
|
||||
}
|
||||
|
||||
SELF_CLOSING_TAGS = {
|
||||
'br', 'hr', 'img'
|
||||
}
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
@@ -105,13 +121,24 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
|
||||
|
||||
|
||||
def sanitize_html(text: str) -> str:
|
||||
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
|
||||
if not text:
|
||||
return text
|
||||
|
||||
for tag in allowed_tags:
|
||||
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
|
||||
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
|
||||
text = html.escape(text)
|
||||
|
||||
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
|
||||
for tag in ALLOWED_HTML_TAGS:
|
||||
text = re.sub(
|
||||
f'<{tag}(>|\\s[^&]*>)',
|
||||
lambda m: m.group(0).replace('<', '<').replace('>', '>'),
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
text = re.sub(
|
||||
f'</{tag}>',
|
||||
f'</{tag}>',
|
||||
text,
|
||||
flags=re.IGNORECASE
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
@@ -134,4 +161,114 @@ def validate_referral_code(code: str) -> bool:
|
||||
user_id_part = code[3:]
|
||||
return user_id_part.isdigit()
|
||||
|
||||
return validate_promocode(code)
|
||||
return validate_promocode(code)
|
||||
|
||||
|
||||
def validate_html_tags(text: str) -> Tuple[bool, str]:
|
||||
if not text:
|
||||
return True, ""
|
||||
|
||||
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>'
|
||||
tags = re.findall(tag_pattern, text)
|
||||
|
||||
for is_closing, tag_name in tags:
|
||||
tag_name_lower = tag_name.lower()
|
||||
|
||||
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 match in matches:
|
||||
full_tag = match.group(0)
|
||||
is_closing = bool(match.group(1))
|
||||
tag_name = match.group(2).lower()
|
||||
|
||||
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:
|
||||
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
|
||||
|
||||
if tag_stack:
|
||||
return False, f"Незакрытый тег: <{tag_stack[-1]}>"
|
||||
|
||||
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>
|
||||
|
||||
<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
|
||||
|
||||
@@ -7,7 +7,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:
|
||||
@@ -23,7 +23,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:
|
||||
@@ -46,28 +46,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
|
||||
user: "1000:1000"
|
||||
command: >
|
||||
bash -c "
|
||||
mkdir -p /app/logs /app/data &&
|
||||
python main.py
|
||||
"
|
||||
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:
|
||||
@@ -75,3 +93,4 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
gateway: 172.20.0.1
|
||||
|
||||
@@ -13,9 +13,11 @@ from app.database.database import init_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
from app.services.payment_service import PaymentService
|
||||
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
|
||||
|
||||
|
||||
class GracefulExit:
|
||||
@@ -49,6 +51,7 @@ async def main():
|
||||
yookassa_server_task = None
|
||||
monitoring_task = None
|
||||
maintenance_task = None
|
||||
version_check_task = None
|
||||
polling_task = None
|
||||
|
||||
try:
|
||||
@@ -77,17 +80,46 @@ async def main():
|
||||
bot, dp = await setup_bot()
|
||||
|
||||
monitoring_service.bot = bot
|
||||
maintenance_service.set_bot(bot)
|
||||
maintenance_service.set_bot(bot)
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
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.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 сервера...")
|
||||
@@ -108,15 +140,28 @@ async def main():
|
||||
logger.info("🔧 Служба техработ уже запущена")
|
||||
maintenance_task = None
|
||||
|
||||
logger.info("🔄 Запуск polling...")
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("📄 Запуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
else:
|
||||
logger.info("ℹ️ Проверка версий отключена")
|
||||
|
||||
logger.info("📄 Запуск polling...")
|
||||
polling_task = asyncio.create_task(dp.start_polling(bot, skip_updates=True))
|
||||
|
||||
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(f" Мониторинг: {'Включен' if monitoring_task else 'Отключен'}")
|
||||
logger.info(f" Техработы: {'Включен' if maintenance_task else 'Отключен'}")
|
||||
logger.info(f" Проверка версий: {'Включен' if version_check_task else 'Отключен'}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
try:
|
||||
@@ -143,6 +188,14 @@ async def main():
|
||||
if exception:
|
||||
logger.error(f"Служба техработ завершилась с ошибкой: {exception}")
|
||||
maintenance_task = asyncio.create_task(maintenance_service.start_monitoring())
|
||||
|
||||
if version_check_task and version_check_task.done():
|
||||
exception = version_check_task.exception()
|
||||
if exception:
|
||||
logger.error(f"Сервис проверки версий завершился с ошибкой: {exception}")
|
||||
if settings.is_version_check_enabled():
|
||||
logger.info("🔄 Перезапуск сервиса проверки версий...")
|
||||
version_check_task = asyncio.create_task(version_service.start_periodic_check())
|
||||
|
||||
if polling_task.done():
|
||||
exception = polling_task.exception()
|
||||
@@ -186,6 +239,20 @@ async def main():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if version_check_task and not version_check_task.done():
|
||||
logger.info("ℹ️ Остановка сервиса проверки версий...")
|
||||
version_check_task.cancel()
|
||||
try:
|
||||
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...")
|
||||
polling_task.cancel()
|
||||
@@ -195,7 +262,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,27 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'sent_notifications',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sent_notifications')
|
||||
@@ -0,0 +1,24 @@
|
||||
"""add cascade delete to sent notifications"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'cbd1be472f3d'
|
||||
down_revision: Union[str, None] = '8fd1e338eb45'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
|
||||
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])
|
||||
@@ -1,27 +1,33 @@
|
||||
# Основные зависимости
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 463 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 393 KiB |