Compare commits

...

210 Commits

Author SHA1 Message Date
Egor 74544c9a9e Update Dockerfile 2025-09-12 22:52:41 +03:00
Egor 3314bb7694 Update docker-registry.yml 2025-09-12 22:52:31 +03:00
Egor c920bac33e Update docker-hub.yml 2025-09-12 22:52:14 +03:00
Egor e6881ef617 Update .env.example 2025-09-12 22:07:28 +03:00
Egor 94a7bb43f6 Update README.md 2025-09-12 21:54:15 +03:00
Egor 97841f0cbe Merge pull request #59 from Fr1ngg/dev
Активация/Деактивация приветственного сообщения
2025-09-12 21:47:04 +03:00
Egor de392bd4de Update start.py 2025-09-12 21:45:11 +03:00
Egor f2a3e5f2fc Update universal_migration.py 2025-09-12 21:27:34 +03:00
Egor 20a2566fe6 Update start.py 2025-09-12 21:23:47 +03:00
Egor 96bd411839 Update admin.py 2025-09-12 21:21:21 +03:00
Egor 773196fd84 Update welcome_text.py 2025-09-12 21:20:35 +03:00
Egor 26dc8b4963 Update welcome_text.py 2025-09-12 21:19:43 +03:00
Egor 06a9202c7c Update models.py 2025-09-12 21:18:06 +03:00
Egor 634786917d Merge pull request #58 from yazhog/main
Пропуск правил, рефкода при регистрации, отправка бэкап файлов в тг
2025-09-12 19:16:34 +03:00
yazhog 4ea839c30a Merge pull request #15 from yazhog/codex/add-telegram-backup-notification-settings
feat: send backups to telegram
2025-09-12 13:55:26 +03:00
yazhog 10f6ea382b feat: caption backup send 2025-09-12 13:52:53 +03:00
yazhog 929414e31f fix: send backup file without caption 2025-09-12 13:47:27 +03:00
yazhog cd87a3e773 chore: clarify backup env comment 2025-09-12 13:31:36 +03:00
yazhog 7a3168dcfd Merge pull request #14 from yazhog/codex/add-skip-rules-and-referral-options-to-.env
feat: allow skipping rules and referral prompts
2025-09-12 10:48:58 +03:00
yazhog 888f8fb01a Пропуск окна активации теста при регистрации 2025-09-12 10:37:03 +03:00
yazhog 6f06ec5aa0 feat: add skip options for rules and referral 2025-09-12 10:35:47 +03:00
Egor e10cb3f2bb Update Dockerfile 2025-09-12 09:51:59 +03:00
Egor 2edd3cb5ab Update docker-registry.yml 2025-09-12 09:51:49 +03:00
Egor d6d3dc83f5 Update docker-hub.yml 2025-09-12 09:51:24 +03:00
Egor ae392691b1 Merge pull request #57 from Fr1ngg/dev
🔄 Переключение лимита трафика
2025-09-12 09:11:21 +03:00
Egor 35a202d280 Add files via upload
🔄 Переключение лимита трафика
2025-09-12 09:09:32 +03:00
Egor e9a2d8a232 Update texts.py 2025-09-12 09:07:16 +03:00
Egor b76b99ca2e Update inline.py 2025-09-12 09:06:52 +03:00
Egor 8960ea9c10 Update subscription.py 2025-09-12 09:06:25 +03:00
Egor 9f94acb580 Merge pull request #56 from Fr1ngg/dev
Индивидуальный сброс устройств
2025-09-12 08:34:50 +03:00
Egor c9811fd518 Update inline.py 2025-09-12 08:32:29 +03:00
Egor e25fb1b178 Update subscription.py 2025-09-12 08:31:30 +03:00
Egor 779a03d008 Merge pull request #55 from Fr1ngg/dev
Переработка меню управлением кол-вом устройств, добавлена возможность уменьшить кол-во устройств на подписке
2025-09-12 04:19:46 +03:00
Egor f2a972aeea Update texts.py 2025-09-12 04:18:21 +03:00
Egor 2a9163a28b Update inline.py 2025-09-12 04:17:48 +03:00
Egor 20a628089d Update subscription.py 2025-09-12 04:16:07 +03:00
Egor cf01d917d1 Merge pull request #54 from yazhog/main
Все сообщения бота с прикрепленным логотипом
2025-09-12 02:51:24 +03:00
yazhog 1544da7e12 Образец логотипа 2025-09-11 22:44:06 +03:00
yazhog 94a533508e Merge pull request #13 from yazhog/codex/find-message-handling-functions-in-bot
Send referral messages as images
2025-09-11 22:40:56 +03:00
yazhog 98cdfcff81 merge main 2025-09-11 22:40:24 +03:00
yazhog 23ddaa81be Set connect button mode to guide 2025-09-11 22:29:31 +03:00
yazhog 667e988c47 refactor: add logo file setting 2025-09-11 22:29:26 +03:00
yazhog ea6b20fefa feat: toggle logo mode 2025-09-11 21:50:29 +03:00
Egor e734763eaf Update Dockerfile 2025-09-11 05:28:23 +03:00
Egor cb98efc47e Update docker-registry.yml 2025-09-11 05:28:07 +03:00
Egor dbd3d87779 Update docker-hub.yml 2025-09-11 05:27:50 +03:00
Egor 533ae6dd80 Update admin.py 2025-09-11 05:27:10 +03:00
Egor ca62321b42 Merge pull request #52 from Fr1ngg/dev
Редактируемое приветственное сообщение
2025-09-11 05:16:41 +03:00
Egor cd3d2c3731 Update README.md 2025-09-11 05:14:22 +03:00
Egor 8b8d29a75d Update start.py 2025-09-11 05:09:30 +03:00
Egor 3f97757ec0 Update welcome_text.py 2025-09-11 05:08:33 +03:00
Egor d5f3299bd2 Update welcome_text.py 2025-09-11 05:07:52 +03:00
Egor 0b99965731 Update universal_migration.py 2025-09-11 05:04:08 +03:00
Egor 8f679832fb Update welcome_text.py 2025-09-11 04:57:49 +03:00
Egor 46564a8e91 Update welcome_text.py 2025-09-11 04:55:08 +03:00
Egor 09b0dbb60c Update welcome_text.py 2025-09-11 04:54:22 +03:00
Egor 28d53e7e3f Update welcome_text.py 2025-09-11 04:49:34 +03:00
Egor 43de39f4ec Update admin.py 2025-09-11 04:45:40 +03:00
Egor 49df0100c9 Update welcome_text.py 2025-09-11 04:43:51 +03:00
Egor c020f33c86 Update welcome_text.py 2025-09-11 04:33:08 +03:00
Egor 813daef2b1 Update start.py 2025-09-11 04:27:27 +03:00
Egor 13d4e934ec Update welcome_text.py 2025-09-11 04:25:07 +03:00
Egor 7721464611 Update admin.py 2025-09-11 04:18:18 +03:00
Egor 9e42c3e7d1 Update welcome_text.py 2025-09-11 04:17:17 +03:00
Egor badbc150ab Update welcome_text.py 2025-09-11 04:16:05 +03:00
Egor 5d57c88e4b Update bot.py 2025-09-11 04:06:13 +03:00
Egor ec18f82d26 Update start.py 2025-09-11 04:04:08 +03:00
Egor 846e5e6bf0 Update states.py 2025-09-11 04:02:12 +03:00
Egor 16b6df4d05 Update admin.py 2025-09-11 04:01:31 +03:00
Egor 9e45bd6d75 Update models.py 2025-09-11 04:00:04 +03:00
Egor d01436bf14 Create welcome_text.py 2025-09-11 03:59:24 +03:00
Egor f50e170f8d Create welcome_text.py 2025-09-11 03:58:34 +03:00
Egor 2dd76d8bd2 Update README.md 2025-09-11 03:39:30 +03:00
Egor b9956391f0 Update README.md 2025-09-11 03:37:44 +03:00
Egor fdfd0e2355 Merge pull request #51 from Fr1ngg/dev
Бекапы/Восстановление
2025-09-11 03:32:44 +03:00
Egor 548dd2fdc6 Update Dockerfile 2025-09-11 03:31:44 +03:00
Egor cdcdd7abdc Update README.md 2025-09-11 03:31:18 +03:00
Egor d5c8795e75 Delete app/data/2 2025-09-11 03:25:20 +03:00
Egor ceb0ac7538 Create 2 2025-09-11 03:25:04 +03:00
Egor 305026c37d Update Dockerfile 2025-09-11 03:14:30 +03:00
Egor f95367d12e Update .env.example 2025-09-11 03:09:02 +03:00
Egor ac962c43e8 Update backup_service.py 2025-09-11 03:03:25 +03:00
Egor 26efc70042 Update backup.py 2025-09-11 03:02:48 +03:00
Egor 7f431e1485 Update backup_service.py 2025-09-11 02:32:44 +03:00
Egor 1292046646 Update backup_service.py 2025-09-11 02:20:32 +03:00
Egor d54b455007 Update backup_service.py 2025-09-11 02:09:45 +03:00
Egor ca55d8942e Update backup.py 2025-09-11 01:59:37 +03:00
Egor f00fdcb7c8 Update backup.py 2025-09-11 01:55:56 +03:00
Egor 5443fdf064 Update requirements.txt 2025-09-10 22:57:35 +03:00
Egor c33e49661c Update config.py 2025-09-10 22:55:50 +03:00
Egor 92bd0c362e Update bot.py 2025-09-10 22:55:22 +03:00
Egor fe8cafdd99 Update main.py 2025-09-10 22:54:23 +03:00
Egor f0f8c4e4cf Update admin.py 2025-09-10 22:47:12 +03:00
Egor f8e2e23082 Create backup.py 2025-09-10 22:45:42 +03:00
Egor b915c65c41 Create backup_service.py 2025-09-10 22:42:35 +03:00
yazhog 4e7154e817 Fix QR detection for returning logo 2025-09-10 02:04:29 +03:00
yazhog 7992cf9e26 Change QR photo back to logo when leaving referral QR 2025-09-10 01:58:26 +03:00
yazhog ea958ea6c0 refactor referral handlers with photo helper 2025-09-10 01:48:41 +03:00
yazhog 9b32de5fb2 Avoid replacing media when editing captions 2025-09-10 01:37:43 +03:00
yazhog b71d0d058f Respond to referral QR callbacks immediately 2025-09-10 01:26:14 +03:00
yazhog d36e353d9b Patch message methods to send logo image 2025-09-10 01:17:30 +03:00
Egor 02106bd64b Update remnawave_api.py 2025-09-10 01:13:00 +03:00
yazhog e68d6ae19a Remove vpn logo asset from repo 2025-09-10 01:10:58 +03:00
Egor 16ae480430 Update Dockerfile 2025-09-10 00:26:52 +03:00
Egor 0b222c06a1 Update docker-hub.yml 2025-09-10 00:26:21 +03:00
Egor ff340e90d6 Update docker-registry.yml 2025-09-10 00:26:02 +03:00
Egor 4125141d01 Merge pull request #49 from yazhog/main
Режим link для ссылки подписки, фикс напоминаний о истечении подписки
2025-09-10 00:06:38 +03:00
yazhog 2f40ad53b5 Merge pull request #10 from yazhog/codex/implement-notification-tracking-system
Persist subscription expiry notifications
2025-09-09 23:36:56 +03:00
yazhog 2d3674eff7 Cascade delete sent notifications 2025-09-09 23:29:06 +03:00
yazhog 560e60b856 Persist subscription expiry notifications 2025-09-09 19:13:15 +03:00
yazhog 7cf20234b8 Merge branch 'Fr1ngg:main' into main 2025-09-09 18:52:42 +03:00
yazhog ecab0a0f45 Merge pull request #9 from yazhog/codex/add-connect_button_mode-for-link-subscription
feat: add link connect button mode
2025-09-09 18:42:32 +03:00
yazhog 587722ed5c feat: add link connect button mode 2025-09-09 13:32:20 +03:00
Egor bd7d9b3d5e Update docker-registry.yml 2025-09-09 11:05:30 +03:00
Egor 60787a2aa9 Merge pull request #48 from yazhog/main
Тест подписка сразу после старта бота, продление в любой момент
2025-09-09 10:38:59 +03:00
yazhog 9675ac3c14 Allow subscription renewal anytime 2025-09-09 10:21:13 +03:00
yazhog ca9a2f5997 Add extend subscription button for paid users 2025-09-09 10:21:13 +03:00
yazhog 652eb4f5a7 Respect renewal period settings in extension keyboard 2025-09-09 10:21:13 +03:00
yazhog 6ffbad747d Правка текстов 2025-09-09 10:21:13 +03:00
yazhog af46c596a9 Фикс текста 2025-09-09 10:21:13 +03:00
yazhog 1fcc218f26 Тексты поменял 2025-09-09 10:21:00 +03:00
yazhog 39a5d674c9 feat: add greeting and VPN advantages to trial offer 2025-09-09 10:19:25 +03:00
yazhog bdfe6ae1cf feat: add trial prompt after registration 2025-09-09 10:19:25 +03:00
yazhog 06e6c65ada Убрал про Ваш реферер получит... 2025-09-09 10:19:25 +03:00
Egor 8a7e4ff1c9 Update docker-registry.yml 2025-09-09 09:10:16 +03:00
Egor f0a1e1c338 Update docker-hub.yml 2025-09-09 09:09:57 +03:00
Egor 843af67483 Update Dockerfile 2025-09-09 09:09:31 +03:00
Egor 80d251c2d1 Update README.md 2025-09-09 09:02:04 +03:00
Egor a1c2907cce Update webhook_server.py 2025-09-09 08:56:15 +03:00
Egor c1369f113c Update README.md 2025-09-09 08:20:12 +03:00
Egor 8ea5bc5330 Update README.md 2025-09-09 08:18:02 +03:00
Egor 285dda4795 Merge pull request #47 from Fr1ngg/cp
Корректное удаление записей криптобота
2025-09-09 08:12:37 +03:00
Egor fae423177b Update user_service.py 2025-09-09 08:11:28 +03:00
Egor 51bf43289d Merge pull request #46 from Fr1ngg/cp
Интеграция Cryptobot
2025-09-09 08:03:32 +03:00
Egor 3fd87eee9a Update README.md 2025-09-09 08:00:57 +03:00
Egor ad14ab5d08 Update universal_migration.py 2025-09-09 07:42:45 +03:00
Egor aab68767fd Update payment_service.py 2025-09-09 07:31:52 +03:00
Egor 84f10229da Update balance.py 2025-09-09 07:27:10 +03:00
Egor 3a6ce31103 Create currency_converter.py 2025-09-09 07:25:39 +03:00
Egor da7675a853 Update payment_service.py 2025-09-09 07:14:33 +03:00
Egor 09726f8df0 Update universal_migration.py 2025-09-09 07:04:49 +03:00
Egor 8072ce6954 Update universal_migration.py 2025-09-09 06:59:37 +03:00
Egor 1e870e49c8 Update universal_migration.py 2025-09-09 06:55:32 +03:00
Egor eeca712a3e Update webhook_server.py 2025-09-09 06:48:30 +03:00
Egor 29c3e455e4 Update .env.example 2025-09-09 06:33:21 +03:00
Egor c23a777783 Update webhook_server.py 2025-09-09 06:32:10 +03:00
Egor 0439279602 Update inline.py 2025-09-09 06:31:21 +03:00
Egor 27c81d451b Update balance.py 2025-09-09 06:30:11 +03:00
Egor 339881f288 Update payment_service.py 2025-09-09 06:27:50 +03:00
Egor ef6bfb8ea3 Update models.py 2025-09-09 06:22:31 +03:00
Egor 4bd33fbbe2 Update config.py 2025-09-09 06:21:12 +03:00
Egor 8a5f097808 Update config.py 2025-09-09 06:20:26 +03:00
Egor 239eb9705d Create cryptobot.py 2025-09-09 06:19:32 +03:00
Egor fee141ea2b Create cryptobot.py 2025-09-09 06:18:24 +03:00
Egor ec7c121e03 Update user_service.py 2025-09-09 05:32:33 +03:00
Egor 8b047fd18c Update user_service.py 2025-09-09 05:31:42 +03:00
Egor 0c04868bef Update universal_migration.py 2025-09-09 05:26:10 +03:00
Egor deaf16e6ae Update user_service.py 2025-09-09 05:18:23 +03:00
Egor 389cac3e0a Create .dockerignore 2025-09-09 04:30:52 +03:00
Egor c04968818f Update Dockerfile 2025-09-09 04:29:55 +03:00
Egor 3a391d79d2 Update docker-registry.yml 2025-09-09 04:29:46 +03:00
Egor d1a8019c31 Update models.py 2025-09-08 20:22:38 +03:00
Egor 1c211b0a41 Update README.md 2025-09-08 20:16:31 +03:00
Egor 7c1ba6d92b Update README.md 2025-09-08 10:33:50 +03:00
Egor c0953eecf3 Update docker-registry.yml 2025-09-08 09:52:34 +03:00
Egor 7f70826023 Update docker-hub.yml 2025-09-08 09:52:06 +03:00
Egor a7aaa6fc88 Update Dockerfile 2025-09-08 09:51:29 +03:00
Egor a72b3bf705 Update version_service.py 2025-09-08 09:47:52 +03:00
Egor 71656df0bd Update version_service.py 2025-09-08 09:08:26 +03:00
Egor 7d5cff0aa9 Update version_service.py 2025-09-08 09:06:17 +03:00
Egor 4e27396a09 Update version_service.py 2025-09-08 08:51:34 +03:00
Egor 52a72a11b0 Update docker-registry.yml 2025-09-08 08:18:55 +03:00
Egor c17886489b Update Dockerfile 2025-09-08 07:46:58 +03:00
Egor ade2f106ba Update Dockerfile 2025-09-08 07:15:42 +03:00
Egor a096b2bc1a Merge pull request #43 from Fr1ngg/dev
Проверка обновлений
2025-09-08 06:45:52 +03:00
Egor de65390680 Update .env.example 2025-09-08 06:44:01 +03:00
Egor bd5f92c121 Update Dockerfile 2025-09-08 06:36:57 +03:00
Egor ad53a6bc09 Update requirements.txt 2025-09-08 06:36:20 +03:00
Egor f3b2c8056e Update Dockerfile 2025-09-08 06:36:05 +03:00
Egor 1e90903d54 Update bot.py 2025-09-08 06:22:21 +03:00
Egor 8537875537 Update admin.py 2025-09-08 06:19:31 +03:00
Egor a988e562a0 Update main.py 2025-09-08 06:17:43 +03:00
Egor d014a54181 Update config.py 2025-09-08 06:13:08 +03:00
Egor 4e5d57bf92 Update admin_notification_service.py 2025-09-08 06:11:53 +03:00
Egor 6f8ebbb9f0 Create updates.py 2025-09-08 06:10:34 +03:00
Egor 131ac32596 Create version_service.py 2025-09-08 06:09:01 +03:00
Egor 56017d023c Update docker-registry.yml 2025-09-08 05:57:46 +03:00
Egor 3afdc6cd31 Update docker-hub.yml 2025-09-08 05:57:22 +03:00
Egor 52a89eac77 Merge pull request #42 from yazhog/main
Фикс косяков с начислениями при первом пополнении
2025-09-08 00:10:30 +03:00
yazhog 0cdd311832 Merge pull request #6 from yazhog/codex/add-button-to-top-up-balance
feat: add top up prompt on insufficient balance
2025-09-07 22:17:25 +03:00
yazhog 1d5b52ef5b Переброс при недостаточно балансе на пополнение баланса 2025-09-07 22:16:49 +03:00
yazhog 3f37be4509 feat: add top up prompt on insufficient balance 2025-09-07 22:03:27 +03:00
yazhog 1636af1585 Merge pull request #5 from yazhog/codex/update-referral-bonus-calculation-logic
feat: calculate referral inviter bonus as max flat or percent
2025-09-07 21:07:33 +03:00
yazhog 127409955c feat: calculate referral inviter bonus as max flat or percent 2025-09-07 21:01:40 +03:00
Egor 423ce5e41b Update docker-compose.yml 2025-09-07 19:43:06 +03:00
Egor 770b7e8358 Update docker-compose.yml 2025-09-07 19:40:53 +03:00
Egor 0daf08ceed Update docker-compose.yml 2025-09-07 19:33:22 +03:00
Egor 1cda2259c6 Update config.py 2025-09-07 19:30:46 +03:00
Egor 5879080f33 Update .env.example 2025-09-07 19:26:54 +03:00
Egor cd3ad8bff9 Merge pull request #41 from yazhog/main
Убрал копейки + QR код для рефералки
2025-09-07 18:26:46 +03:00
yazhog 033a748069 Эмодзи на кнопках потерялись 2025-09-07 14:08:53 +03:00
yazhog de890dbd7f Merge pull request #4 from yazhog/codex/-qr
Add referral QR code generation
2025-09-07 13:53:43 +03:00
yazhog 7c5ae09a6e Refactor QR code display to edit message 2025-09-07 13:48:22 +03:00
yazhog e587e3b087 Fix back navigation after QR code 2025-09-07 13:35:19 +03:00
yazhog d698b3cfe7 Show referral QR code via screen transition 2025-09-07 13:30:41 +03:00
yazhog a01680fa2d Add referral QR code generation 2025-09-07 13:23:49 +03:00
yazhog 531fc0fb22 Merge pull request #3 from yazhog/codex/remove-decimal-points-in-ruble-display
feat: display ruble prices without kopecks
2025-09-07 13:19:36 +03:00
yazhog bb8e513598 feat: display ruble prices without kopecks 2025-09-07 13:13:03 +03:00
Egor f56679cd9f Update validators.py 2025-09-07 11:15:13 +03:00
Egor 9885e050a1 Update README.md 2025-09-07 10:22:21 +03:00
51 changed files with 6356 additions and 881 deletions
+46
View File
@@ -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/
+62 -13
View File
@@ -12,19 +12,26 @@ 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
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
DATABASE_MODE=auto
REDIS_URL=redis://redis:6379/0
# Основной URL (можно оставить пустым для автоматического выбора)
DATABASE_URL=
# Пароли для Docker (PostgreSQL/Redis)
# 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=
@@ -169,17 +176,39 @@ 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=8083
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
# Режим работы кнопки "Подключиться"
# 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
@@ -201,6 +230,32 @@ 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_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
@@ -209,9 +264,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
+86 -3
View File
@@ -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
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.2.9-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.9-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.9-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
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
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
+92 -7
View File
@@ -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
@@ -24,14 +25,58 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
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 "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.9"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.9-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.9-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
fi
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -45,20 +90,33 @@ 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
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 }}
cache-from: type=gha
cache-to: type=gha,mode=max
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
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: |
type=gha,mode=max
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-contexts: |
alpine=docker-image://alpine:latest
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -67,3 +125,30 @@ jobs:
only-severities: critical,high
write-comment: true
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker 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** | \`${{ env.REGISTRY }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
+1
View File
@@ -2,6 +2,7 @@
*
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!Dockerfile
!app-config.json
+42 -9
View File
@@ -1,28 +1,61 @@
FROM python:3.11-slim
WORKDIR /app
FROM python:3.11-slim AS builder
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/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
ARG VERSION="v2.2.9"
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"
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
COPY . .
WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p logs data && \
chown -R app:app /app
chown -R app:app /app logs data
USER app
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
VERSION=${VERSION} \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8081 8082
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"
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
+137 -88
View File
@@ -35,10 +35,10 @@
### ⚡ **Полная автоматизация VPN бизнеса**
- 🎯 **Готовое решение** - разверни за 5 минут, начни продавать сегодня
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + ЮKassa
- 💰 **Многоканальные платежи** - Telegram Stars + Tribute + CryptoBot + ЮKassa + P2P
- 🔄 **Автоматизация 99%** - от регистрации до продления подписок
- 📊 **Детальная аналитика** - полная картина вашего бизнеса
- 💬 **Уведомления в топики** - Уведомление в топик канала об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса
- 💬 **Уведомления в топики** об: Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную ⏰ Продление подписки 💰 Пополнение баланса 🚧 Включении тех работ ♻️ Появлении новой версии бота
### 🎛️ **Гибкость конфигурации**
- 🌍 **Умный выбор серверов** - автоматический пропуск при одном сервере, мультивыбор при нескольких
@@ -53,6 +53,7 @@
- 📈 **Масштабируемость** - от стартапа до крупного бизнеса
- 🔧 **Мониторинг** - автоматическое управление режимом тех. работ
- 🛡️ **Защита панели** - поддержка [remnawave-reverse-proxy](https://github.com/eGamesAPI/remnawave-reverse-proxy)
- 🗄️ **Бекапы/Восстановление** - автобекапы и восстановление бд прямо в боте с уведомления в топики
---
@@ -70,7 +71,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,25 +221,33 @@ 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
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
DATABASE_MODE=auto
REDIS_URL=redis://redis:6379/0
# Основной URL (можно оставить пустым для автоматического выбора)
DATABASE_URL=
# Пароли для Docker (PostgreSQL/Redis)
# 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
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
REMNAWAVE_SECRET_KEY=your_secret_key_here
# ========= ПОДПИСКИ =========
@@ -250,7 +261,7 @@ TRIAL_SQUAD_UUID=
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств доступных к покупке (0 = Нет лимита)
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
@@ -329,8 +340,39 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Настройки чеков для налоговой
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
@@ -338,18 +380,33 @@ 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=8083
CRYPTOBOT_DEFAULT_ASSET=USDT
CRYPTOBOT_ASSETS=USDT,TON,BTC,ETH,LTC,BNB,TRX,USDC
CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# ===== ИНТЕРФЕЙС И UX =====
# Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
@@ -376,6 +433,26 @@ 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_MAX_KEEP=7
BACKUP_COMPRESSION=true
BACKUP_INCLUDE_LOGS=false
BACKUP_LOCATION=/app/data/backups
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
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
@@ -384,12 +461,6 @@ 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
```
</details>
@@ -414,11 +485,13 @@ APP_CONFIG_CACHE_TTL=3600
- Настраиваемый триал-период
- Уведомления об истечении
- Плавный переход на платную версию
- Приветственное сообщение с предложением активации триала (С возможностью отключения)
💰 **Удобные платежи**
- ⭐ Telegram Stars
- 💳 Tribute
- 💳 YooKassa
- 💰 CryptoBot
- 🎁 Реферальные бонусы
- Детальная история транзакций
@@ -449,11 +522,12 @@ APP_CONFIG_CACHE_TTL=3600
👥 **Управление пользователями**
- 🔍 Поиск и редактирование профилей
- 💰 Управление балансами
- 📱 **НОВОЕ**: Изменение лимита устройств (1-X)
- 📊 **НОВОЕ**: Настройка лимитов трафика (0-10000 ГБ)
- 🌍 **НОВОЕ**: Мультивыбор серверов
- 🔄 **НОВОЕ**: Сброс HWID устройств
- 👥 **НОВОЕ**: Детальная реферальная статистика в профиле пользователя
- 📱 Изменение лимита устройств (1-X)
- 📊 Настройка лимитов трафика (0-10000 ГБ)
- 🌍 Мультивыбор серверов
- 🔄 Сброс HWID устройств
- 👥 Детальная реферальная статистика в профиле пользователя
- 🔄 **НОВОЕ**: Изминение типа подписки триал/платная
- 🚫 Блокировка/разблокировка/удаление
🎫 **Промо-система**
@@ -473,6 +547,7 @@ APP_CONFIG_CACHE_TTL=3600
- 🔔 Автоуведомления о продлении
- 💬 Система поддержки с HTML разметкой
- 📝 Настройка правил сервиса
- 🧪 Настраиваемое приветственное сообщение с предложением активации триала
📨 **Уведомления в закрытый канал**
- 🎯 Активация триала
@@ -480,6 +555,15 @@ APP_CONFIG_CACHE_TTL=3600
- 🔄 Конверсия из триала в платную
- ⏰ Продление подписки
- 💰 Пополнение баланса
- ♻️ Выход обновлений бота
- 🚧 Потеря соелинения с апи Remnawave
- 🗄️ **Бекапы/Восстановление бд**
- 🗄️ Отправка бд файлов в отдельный чат/топики
🗄️ **Бекапы/Восстановление**
- Ручной запуск бекапа
- Восстановление бд
- Включение/Отключение автобекапов
</td>
</tr>
@@ -487,60 +571,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
@@ -745,17 +775,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
@@ -768,6 +801,8 @@ bedolaga_bot/
│ │ ├── 📜 rules.py # Правила сервиса
│ │ ├── 📜 subscription_conversion.py # Правила сервиса
│ │ ├── 💳 yookassa.py # YooKassa операции
│ │ ├── 📨 welcome_text.py # Приветственное сообщение
│ │ ├── 💳 cryptobot.py # CryptoBot операции
│ │ ├── 🌐 server_squad.py # Серверы и сквады
│ │ ├── 🎁 promocode.py # Промокоды
│ │ └── 👥 referral.py # Рефералы
@@ -776,14 +811,16 @@ bedolaga_bot/
│ │ ├── 👤 user_service.py # Сервис пользователей
│ │ ├── 📋 subscription_service.py # Сервис подписок
│ │ ├── 💰 payment_service.py # Платежи
│ │ ├── 🗄️ backup_service.py # Бекапы
│ │ ├── 🎁 promocode_service.py # Промокоды
│ │ ├── 🚧 maintenance_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 # Декораторы
@@ -793,6 +830,7 @@ bedolaga_bot/
│ │ ├── 📄 pagination.py # Пагинация
│ │ ├── 📄 pricing_utils.py # Цены
│ │ ├── 👤 user_utils.py # Утилиты для пользователей
│ │ ├── 🫰 currency_converter.py # Курсы для CryptoBota
│ │ └── ⚡ cache.py # Кеширование
│ │
│ ├── 🛡️ middlewares/ # Middleware
@@ -812,10 +850,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/ # Миграции БД
@@ -828,8 +867,6 @@ bedolaga_bot/
### 🏥 Health Checks
- **Основной**: `http://localhost:8081/health`
- **YooKassa**: `http://localhost:8082/health`
- **Tribute**: `http://localhost:8081/tribute-webhook`
### 🔧 Полезные команды
```bash
@@ -882,6 +919,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 {
@@ -903,6 +948,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
@@ -942,7 +991,7 @@ your-domain.com {
## 🛡️ Безопасность
### 🔐 Защита панели RemnaWave
### 🔐 Защита панели Remnawave
Бот поддерживает интеграцию с системой защиты панели через куки-аутентификацию:
@@ -1007,22 +1056,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>
+15 -3
View File
@@ -24,10 +24,16 @@ from app.handlers.admin import (
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance,
user_messages as admin_user_messages
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__)
@@ -68,6 +74,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(LoggingMiddleware())
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(AuthMiddleware())
@@ -79,6 +86,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(ThrottlingMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
@@ -86,6 +94,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
promocode.register_handlers(dp)
referral.register_handlers(dp)
support.register_handlers(dp)
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
@@ -98,12 +107,15 @@ 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_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()
+137 -9
View File
@@ -15,7 +15,18 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
DATABASE_URL: str
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"
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
@@ -114,10 +125,24 @@ class Settings(BaseSettings):
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 = ""
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"
@@ -131,6 +156,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
@@ -139,6 +179,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()
@@ -179,8 +261,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
@@ -228,6 +310,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
@@ -251,7 +354,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]
@@ -270,7 +373,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]
@@ -315,9 +418,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,
@@ -375,6 +491,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:
@@ -455,13 +580,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,
@@ -482,3 +607,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()
+156
View File
@@ -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()
+52
View File
@@ -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()
+5 -3
View File
@@ -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
+174
View File
@@ -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 не указан)'
}
+78 -8
View File
@@ -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,6 +555,20 @@ 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"
@@ -585,21 +649,27 @@ class SubscriptionServer(Base):
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"), nullable=True)
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")
+305 -123
View File
@@ -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,323 @@ 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 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 +455,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 +530,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 +614,37 @@ 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("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
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 +686,9 @@ 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("✅ Дубликаты подписок исправлены")
return True
@@ -522,24 +702,22 @@ 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,
"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')
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
@@ -555,9 +733,11 @@ 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",
"subscription_conversions_table": "Таблица конверсий подписок",
"remnawave_v2_columns": "Колонки RemnaWave v2.1.5",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
@@ -574,11 +754,13 @@ 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"))
conv_count = conversions_count.fetchone()[0]
usr_count = users_count.fetchone()[0]
welcome_count = welcome_texts_count.fetchone()[0]
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий записано")
logger.info(f"📊 Статистика: {usr_count} пользователей, {conv_count} конверсий, {welcome_count} приветственных текстов")
except Exception as stats_error:
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
+167
View File
@@ -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
+70 -9
View File
@@ -83,6 +83,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
@@ -123,7 +133,7 @@ class RemnaWaveAPI:
async def __aenter__(self):
conn_type = self._detect_connection_type()
logger.info(f"🔗 Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
logger.info(f"Подключение к Remnawave: {self.base_url} (тип: {conn_type})")
headers = {
'Authorization': f'Bearer {self.api_key}',
@@ -139,15 +149,15 @@ class RemnaWaveAPI:
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 +168,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 +290,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 +453,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 +623,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:
+3 -3
View File
@@ -45,8 +45,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}, курс: {int(settings.get_stars_rate())}₽/⭐"
)
return invoice_link
@@ -80,7 +80,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)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
)
return {
"message_id": message.message_id,
+99 -21
View File
@@ -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,43 +89,43 @@ class WebhookServer:
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, trbt-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('trbt-signature')
logger.info(f"🔐 Signature: {signature}")
logger.info(f"Signature: {signature}")
if not signature:
logger.error("Отсутствует заголовок подписи Tribute webhook")
logger.error("Отсутствует заголовок подписи Tribute webhook")
return web.json_response(
{"status": "error", "reason": "missing_signature"},
status=401
@@ -125,7 +135,7 @@ class WebhookServer:
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
@@ -134,17 +144,83 @@ class WebhookServer:
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
@@ -154,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
})
+647
View File
@@ -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
)
+9 -9
View File
@@ -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}"
@@ -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:
+274
View File
@@ -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"
)
+2 -2
View File
@@ -636,9 +636,9 @@ 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
+349
View File
@@ -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>&lt;b&gt;жирный текст&lt;/b&gt;</code> <b>жирный текст</b>
<code>&lt;i&gt;курсив&lt;/i&gt;</code> <i>курсив</i>
<code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code> <u>подчеркнутый</u>
<code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code> <s>зачеркнутый</s>
<code>&lt;code&gt;моноширинный&lt;/code&gt;</code> <code>моноширинный</code>
<code>&lt;pre&gt;блок кода&lt;/pre&gt;</code> многострочный код
<code>&lt;a href="URL"&gt;ссылка&lt;/a&gt;</code> ссылка
<b>ВНИМАНИЕ:</b> Используйте ТОЛЬКО указанные выше теги!
Любые другие HTML-теги не поддерживаются и будут отображаться как обычный текст.
<b>НЕ используйте:</b> &lt;div&gt;, &lt;span&gt;, &lt;p&gt;, &lt;br&gt;, &lt;h1&gt;-&lt;h6&gt;, &lt;img&gt; и другие 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
)
+213 -2
View File
@@ -337,6 +337,10 @@ async def process_topup_amount(
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 == "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("Неизвестный способ оплаты")
@@ -381,7 +385,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"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
@@ -465,7 +469,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}")
@@ -526,6 +530,203 @@ 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
# Округляем до 2 знаков после запятой
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)
def register_handlers(dp: Dispatcher):
@@ -579,3 +780,13 @@ 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_")
)
+98 -38
View File
@@ -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,
+2 -2
View File
@@ -92,7 +92,7 @@ async def handle_successful_payment(
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"
@@ -100,7 +100,7 @@ async def handle_successful_payment(
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}")
+119 -91
View File
@@ -12,7 +12,7 @@ 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
@@ -224,13 +224,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}")
@@ -274,25 +297,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}")
@@ -388,7 +415,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)
@@ -400,6 +429,8 @@ 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
@@ -455,7 +486,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} восстановлен")
@@ -475,6 +506,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
@@ -487,7 +519,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:
@@ -498,34 +530,33 @@ async def complete_registration_from_callback(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, 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=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
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 = user.balance_kopeks / 100
await callback.message.answer(
f"Добро пожаловать, {user.full_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),
@@ -533,25 +564,23 @@ async def complete_registration_from_callback(
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
),
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.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except Exception as final_error:
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
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)
@@ -563,6 +592,8 @@ 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
@@ -618,7 +649,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} восстановлен")
@@ -638,6 +669,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
@@ -650,7 +682,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:
@@ -661,34 +693,33 @@ async def complete_registration(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, 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=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
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 = user.balance_kopeks / 100
await message.answer(
f"Добро пожаловать, {user.full_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),
@@ -699,15 +730,11 @@ async def complete_registration(
),
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.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except:
pass
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}")
@@ -857,3 +884,4 @@ def register_handlers(dp: Dispatcher):
logger.info("✅ Зарегистрирован handle_potential_referral_code")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -29,7 +29,13 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel")
],
[
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel"),
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel")
],
[
InlineKeyboardButton(text="🔄 Обновления", callback_data="admin_updates"),
InlineKeyboardButton(text="🗄️ Бекапы", callback_data="backup_panel")
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
@@ -659,3 +665,33 @@ 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_panel")
]
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
+310 -90
View File
@@ -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__)
@@ -19,8 +22,19 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
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,
@@ -104,6 +118,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 +141,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 +165,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 +197,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)
@@ -415,15 +410,7 @@ def get_balance_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -435,11 +422,27 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
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"
)
])
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="topup_stars"
)
])
keyboard.append([
InlineKeyboardButton(
text="🛠️ Через поддержку",
@@ -505,7 +508,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 +536,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 +724,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 +746,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 +761,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 +889,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 +905,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 +1074,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"
)
]
])
+99 -5
View File
@@ -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:
@@ -125,7 +125,7 @@ class RussianTexts(Texts):
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0.00"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
@@ -162,6 +162,50 @@ class RussianTexts(Texts):
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ГБит/сек
Успейте оформить до окончания тестового периода!
"""
@@ -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 = {
@@ -130,6 +130,78 @@ 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,
+604
View File
@@ -0,0 +1,604 @@
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, Base
)
logger = logging.getLogger(__name__)
@dataclass
class BackupMetadata:
timestamp: str
version: str = "1.0"
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 = [
User, Subscription, Transaction, PromoCode, PromoCodeUse,
ReferralEarning, ServiceRule, SystemSetting,
SubscriptionConversion, SentNotification, BroadcastHistory,
ServerSquad, SubscriptionServer, UserMessage,
YooKassaPayment, CryptoBotPayment
]
if self._settings.include_logs:
self.backup_models.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")
)
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.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}")
result = await db.execute(select(model))
records = result.scalars().all()
table_data = []
for record in records:
record_dict = {}
for column in model.__table__.columns:
value = getattr(record, column.name)
if isinstance(value, datetime):
record_dict[column.name] = value.isoformat()
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)
for table_name, records in backup_data.items():
if not records:
continue
model = None
for m in self.backup_models:
if m.__tablename__ == table_name:
model = m
break
if not model:
logger.warning(f"⚠️ Модель для таблицы {table_name} не найдена, пропускаем")
continue
logger.info(f"📥 Восстанавливаем таблицу {table_name} ({len(records)} записей)")
for record_data in records:
try:
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:
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) 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 and isinstance(value, str):
try:
processed_data[key] = json_lib.loads(value)
except (ValueError, TypeError):
processed_data[key] = value
else:
processed_data[key] = value
# Проверяем существует ли запись с таким ID
primary_key_col = None
for col in model.__table__.columns:
if col.primary_key:
primary_key_col = col.name
break
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:
# Обновляем существующую запись
for key, value in processed_data.items():
if key != primary_key_col: # Не обновляем primary key
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:
# Если нет primary key или он не в данных, просто добавляем
instance = model(**processed_data)
db.add(instance)
restored_records += 1
except Exception as e:
logger.error(f"Ошибка восстановления записи в {table_name}: {e}")
continue
restored_tables += 1
logger.info(f"✅ Таблица {table_name} восстановлена")
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 _clear_database_tables(self, db: AsyncSession):
tables_order = [
"subscription_servers", "sent_notifications", "broadcast_history",
"subscription_conversions", "referral_earnings", "promocode_uses",
"transactions", "yookassa_payments", "cryptobot_payments",
"subscriptions", "users", "promocodes", "server_squads",
"service_rules", "system_settings", "monitoring_logs", "user_messages"
]
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")
}
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",
"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:
self._auto_backup_task = asyncio.create_task(self._auto_backup_loop())
logger.info(f"🔄 Автобекапы включены, интервал: {self._settings.backup_interval_hours}ч")
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):
while True:
try:
await asyncio.sleep(self._settings.backup_interval_hours * 3600)
logger.info("🔄 Запуск автоматического бекапа...")
success, message, _ = await self.create_backup()
if success:
logger.info(f"✅ Автобекап завершен: {message}")
else:
logger.error(f"❌ Ошибка автобекапа: {message}")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Ошибка в цикле автобекапов: {e}")
await asyncio.sleep(3600)
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()
+19 -17
View File
@@ -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
@@ -186,31 +190,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 +252,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 +455,13 @@ class MonitoringService:
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+208 -6
View File
@@ -14,6 +14,8 @@ from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_pa
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import TransactionType, PaymentMethod
from app.external.cryptobot import CryptoBotService
from app.utils.currency_converter import currency_converter
logger = logging.getLogger(__name__)
@@ -24,6 +26,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 +51,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 +93,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 ["комиссия", "бонус"]):
@@ -125,13 +128,15 @@ class PaymentService:
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {rubles_amount:.2f}")
logger.info(
f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {int(rubles_amount)}"
)
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:
@@ -313,7 +318,7 @@ class PaymentService:
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks/100:.2f}")
logger.info(f"✅ Отправлено уведомление пользователю {user.telegram_id} о пополнении на {updated_payment.amount_kopeks//100}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
else:
@@ -352,7 +357,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}")
@@ -461,3 +466,200 @@ 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:
# Получаем сумму в USD из платежа
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:
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"
)
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
+14 -10
View File
@@ -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)
@@ -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)
+2 -2
View File
@@ -221,7 +221,7 @@ class TributeService:
text = (
f"✅ **Платеж успешно получен!**\n\n"
f"💰 Сумма: {amount_rubles:.2f}\n"
f"💰 Сумма: {int(amount_rubles)}\n"
f"💳 Способ оплаты: Tribute\n"
f"🎉 Средства зачислены на баланс!\n\n"
f"Спасибо за оплату! 🙏"
@@ -277,7 +277,7 @@ class TributeService:
text = (
f"🔄 **Возврат средств**\n\n"
f"💰 Сумма возврата: {amount_rubles:.2f}\n"
f"💰 Сумма возврата: {int(amount_rubles)}\n"
f"💳 Способ: Tribute\n\n"
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
+50 -1
View File
@@ -13,7 +13,7 @@ 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
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
)
from app.config import settings
@@ -246,6 +246,36 @@ class UserService:
except Exception as e:
logger.warning(f"⚠️ Ошибка деактивации RemnaWave: {e}")
try:
from app.database.models import UserMessage
from sqlalchemy import update
result = await db.execute(
update(UserMessage)
.where(UserMessage.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} пользовательских сообщений")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления пользовательских сообщений: {e}")
try:
from app.database.models import PromoCode
from sqlalchemy import update
result = await db.execute(
update(PromoCode)
.where(PromoCode.created_by == user_id)
.values(created_by=None)
)
if result.rowcount > 0:
logger.info(f"🔄 Обновлено {result.rowcount} промокодов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка обновления промокодов: {e}")
try:
from app.database.models import YooKassaPayment
from sqlalchemy import select
@@ -264,6 +294,25 @@ class UserService:
logger.info(f"✅ YooKassa платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
try:
from app.database.models import CryptoBotPayment
from sqlalchemy import select, delete
cryptobot_result = await db.execute(
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
cryptobot_payments = cryptobot_result.scalars().all()
if cryptobot_payments:
logger.info(f"🔄 Удаляем {len(cryptobot_payments)} CryptoBot платежей")
await db.execute(
delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
await db.flush()
logger.info(f"✅ CryptoBot платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления CryptoBot платежей: {e}")
try:
transactions_result = await db.execute(
+267
View File
@@ -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()
+4
View File
@@ -69,6 +69,10 @@ class AdminStates(StatesGroup):
creating_server_name = State()
creating_server_price = State()
creating_server_country = State()
editing_welcome_text = State()
class SupportStates(StatesGroup):
+121
View File
@@ -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()
+40
View File
@@ -0,0 +1,40 @@
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:
media = self.photo[-1].file_id
if is_qr_message(self) and LOGO_PATH.exists():
media = FSInputFile(LOGO_PATH)
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
+58
View File
@@ -0,0 +1,58 @@
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):
if message.photo and not is_qr_message(message):
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,
)
+2 -4
View File
@@ -11,8 +11,7 @@ ALLOWED_HTML_TAGS = {
'code',
'pre',
'a',
'blockquote',
'spoiler', 'tg-spoiler'
'blockquote'
}
def validate_email(email: str) -> bool:
@@ -203,6 +202,5 @@ def get_html_help_text() -> str:
- <code>&lt;pre&gt;блок кода&lt;/pre&gt;</code>
- <code>&lt;a href="url"&gt;ссылка&lt;/a&gt;</code>
- <code>&lt;blockquote&gt;цитата&lt;/blockquote&gt;</code>
- <code>&lt;spoiler&gt;спойлер&lt;/spoiler&gt;</code>
<b>Неподдерживаемые теги:</b> &lt;br&gt;, &lt;p&gt;, &lt;div&gt;, &lt;span&gt; и другие"""
<b>Неподдерживаемые теги:</b> &lt;br&gt;, &lt;p&gt;, &lt;div&gt;, &lt;span&gt;, &lt;spoiler&gt; и другие"""
+30 -7
View File
@@ -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:
@@ -35,9 +35,7 @@ services:
retries: 3
bot:
build:
context: . # директория где лежит Dockerfile
dockerfile: Dockerfile
image: fr1ngg/remnawave-bedolaga-telegram-bot:latest
container_name: remnawave_bot
restart: unless-stopped
depends_on:
@@ -48,22 +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
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:
@@ -71,3 +93,4 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
+59 -2
View File
@@ -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,8 +80,30 @@ 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)
@@ -108,7 +133,13 @@ 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)
@@ -117,6 +148,10 @@ async def main():
logger.info(f" Tribute: {settings.WEBHOOK_URL}:{settings.TRIBUTE_WEBHOOK_PORT}{settings.TRIBUTE_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 +178,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 +229,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()
@@ -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'])
+6
View File
@@ -25,3 +25,9 @@ APScheduler==3.10.4
python-dateutil==2.8.2
pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2
# Для работы с версиями
packaging==23.2
aiofiles==23.2.1
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB