Compare commits

...

470 Commits

Author SHA1 Message Date
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
Egor 68be5cbe4b Merge pull request #39 from Fr1ngg/dev
Антихлебная валидация HTML разметки при создании сообщения в меню
2025-09-07 08:45:21 +03:00
Egor 3315c83e0b Update user_messages.py 2025-09-07 08:38:25 +03:00
Egor ca9b6f4063 Update user_messages.py 2025-09-07 08:34:46 +03:00
Egor cc490b616f Update user_messages.py 2025-09-07 08:28:02 +03:00
Egor 988f9eb4eb Update validators.py 2025-09-07 08:21:14 +03:00
Egor c41eeb2c8b Update user_messages.py 2025-09-07 08:12:39 +03:00
Egor 881963ca7b Update validators.py 2025-09-07 08:11:55 +03:00
Egor db6733083c Update validators.py 2025-09-07 08:07:45 +03:00
Egor 77851b090a Merge pull request #38 from Fr1ngg/dev
Новый модуль, с настройками в админке. Выводит рандомные сообщения юзера, html поддержка
2025-09-07 06:36:30 +03:00
Egor e0ada505aa Update admin.py 2025-09-07 06:25:53 +03:00
Egor 3179f33b67 Update menu.py 2025-09-07 06:19:46 +03:00
Egor 9bbd72b65f Update menu.py 2025-09-07 06:13:18 +03:00
Egor 68ace1358b Update admin.py 2025-09-07 06:07:48 +03:00
Egor f7934fd0fe Update user_messages.py 2025-09-07 05:59:44 +03:00
Egor 15cbbc04ce Update user_messages.py 2025-09-07 05:54:15 +03:00
Egor d4260106cb Update user_messages.py 2025-09-07 05:49:31 +03:00
Egor 0a46bd18db Update user_messages.py 2025-09-07 05:43:36 +03:00
Egor 0ccbad4077 Update bot.py 2025-09-07 05:40:15 +03:00
Egor 013ce09846 Update bot.py 2025-09-07 05:37:10 +03:00
Egor 736ec95572 Update start.py 2025-09-07 05:32:19 +03:00
Egor bddf36261d Create user_messages.py 2025-09-07 05:24:30 +03:00
Egor cbd74049dd Update admin.py 2025-09-07 05:22:34 +03:00
Egor 84f3db4548 Create user_message.py 2025-09-07 05:21:38 +03:00
Egor c07ccf0daf Update models.py 2025-09-07 05:20:29 +03:00
Egor 3ff4906214 Merge pull request #37 from Fr1ngg/dev
Dev
2025-09-07 04:41:57 +03:00
Egor 1bc5133c95 Update remnawave_api.py 2025-09-07 04:37:34 +03:00
Egor dcbad389b3 Update servers.py 2025-09-07 04:36:34 +03:00
Egor dfea057a25 Update maintenance.py 2025-09-07 04:35:59 +03:00
Egor 3a6bf029d1 Update admin_notification_service.py 2025-09-07 04:35:19 +03:00
Egor 667041558e Update maintenance_service.py 2025-09-07 04:34:58 +03:00
Egor 01e51f2dfd Update remnawave_service.py 2025-09-07 04:34:04 +03:00
Egor 483971d12f Update remnawave_service.py 2025-09-07 04:28:40 +03:00
Egor fad93dab53 Update maintenance.py 2025-09-07 04:11:36 +03:00
Egor ff13b7685b Update admin_notification_service.py 2025-09-07 04:10:54 +03:00
Egor 4281701957 Update remnawave_service.py 2025-09-07 04:09:29 +03:00
Egor a0ff5dbcd4 Update admin.py 2025-09-07 03:59:50 +03:00
Egor ed9ac80e68 Update maintenance.py 2025-09-07 03:57:25 +03:00
Egor e9662b9b42 Update remnawave_service.py 2025-09-07 03:55:34 +03:00
Egor ba3fc35a14 Update admin_notification_service.py 2025-09-07 03:50:51 +03:00
Egor 18471f0162 Update maintenance_service.py 2025-09-07 03:48:30 +03:00
Egor 71073fd0e5 Update tribute_service.py 2025-09-07 03:38:02 +03:00
Egor 2e1ee6bb28 Update payment_service.py 2025-09-07 03:33:19 +03:00
Egor 239f14acaf Update subscription.py 2025-09-07 03:24:06 +03:00
Egor e87a62c180 Update admin_notification_service.py 2025-09-07 03:19:29 +03:00
Egor cb275fc4b6 Update admin_notification_service.py 2025-09-07 03:17:01 +03:00
Egor 4042dee884 Update README.md 2025-09-07 03:12:47 +03:00
Egor 2d3dc8e024 Update remnawave.py 2025-09-07 01:04:23 +03:00
Egor 530759fc81 Merge pull request #36 from yazhog/main
Фикс присылания рефкода сообщением
2025-09-07 00:45:50 +03:00
yazhog 0f29850d7f Merge pull request #2 from yazhog/codex/fix-handling-of-unregistered-users
Fix auth middleware referral state handling
2025-09-07 00:18:40 +03:00
yazhog e060c39790 Fix state detection for registration in auth middleware 2025-09-07 00:16:29 +03:00
yazhog 612d7ad529 Allow messages in registration states 2025-09-07 00:08:27 +03:00
Egor a310e88905 Merge pull request #35 from yazhog/main
Проверка подписи API ключем для Tribute
2025-09-06 23:57:46 +03:00
yazhog bda959e8d6 Merge pull request #1 from yazhog/codex/add-payment-signature-validation
feat: validate Tribute webhook signatures
2025-09-06 23:36:33 +03:00
Egor bd9d12c185 Merge pull request #34 from yazhog/main
Исправление начисления партнерки за первый платеж реферала
2025-09-06 23:31:08 +03:00
yazhog adf509dc81 chore: require Tribute signature 2025-09-06 23:11:18 +03:00
yazhog 22f5fc43dd feat(tribute): verify webhook signature 2025-09-06 23:02:57 +03:00
root 49b10b2d4a Исправление начисления партнерки за первый платеж реферала 2025-09-06 22:57:59 +03:00
root 2f218b7567 Сборка вместо latest образа 2025-09-06 22:50:47 +03:00
Egor 1bb0894894 Delete screens directory 2025-09-06 19:40:38 +03:00
Egor 39da15fec2 Merge pull request #33 from Fr1ngg/dev
Переключение типа подписки
2025-09-06 19:18:10 +03:00
Egor 9ff66b9650 Update users.py 2025-09-06 19:11:21 +03:00
Egor c5353b46b1 Update users.py 2025-09-06 19:07:12 +03:00
Egor 25aa214ffa Update users.py 2025-09-06 19:03:55 +03:00
Egor 28279b7ec2 Update README.md 2025-09-06 18:06:17 +03:00
Egor 126170bee7 Update docker-compose.yml 2025-09-06 18:03:23 +03:00
Egor 8ce7935d49 Update Dockerfile 2025-09-06 18:02:05 +03:00
Egor f756c9a7f9 Update user.py 2025-09-06 17:48:53 +03:00
Egor a59905b98f Update referral_service.py 2025-09-06 17:37:40 +03:00
Egor f0d74185a1 Update user.py 2025-09-06 17:34:09 +03:00
Egor 06937e026f Update tribute_service.py 2025-09-06 17:30:45 +03:00
Egor 65acaf7058 Update tribute_service.py 2025-09-06 17:02:29 +03:00
Egor 7fc574daf3 Update README.md 2025-09-06 07:13:53 +03:00
Egor a93eb8d2d1 Update tribute_service.py 2025-09-06 07:08:24 +03:00
Egor 9fbd15f700 Merge pull request #32 from Fr1ngg/dev
Dev
2025-09-06 07:03:27 +03:00
Egor cec96d866c Update tribute_service.py 2025-09-06 07:01:07 +03:00
Egor 80031306a4 Update tribute_service.py 2025-09-06 06:57:18 +03:00
Egor 9783781bfb Merge pull request #31 from Fr1ngg/dev
Dev
2025-09-06 06:42:23 +03:00
Egor aba7302833 Update tribute_service.py 2025-09-06 06:39:45 +03:00
Egor 61ce10c52e Update transaction.py 2025-09-06 06:36:45 +03:00
Egor f307ad03bf Update README.md 2025-09-06 06:06:31 +03:00
Egor f150bfad37 Merge pull request #30 from Fr1ngg/dev
Уведомления в канал об: 🎯 Активация триала 💎 Покупка подписки 🔄 Конверсия из триала в платную  Продление подписки 💰 Пополнение баланса
2025-09-06 05:57:58 +03:00
Egor 8c22df1f52 Update .env.example 2025-09-06 05:55:47 +03:00
Egor f396ca7df4 Update README.md 2025-09-06 05:54:36 +03:00
Egor 0b1a2b7f68 Update user_service.py 2025-09-06 05:45:04 +03:00
Egor d92b9559ac Update admin_notification_service.py 2025-09-06 05:37:02 +03:00
Egor fb3942e29d Update subscription.py 2025-09-06 05:20:00 +03:00
Egor 6009d15a51 Update transaction.py 2025-09-06 05:13:16 +03:00
Egor e2469a14c9 Update payment_service.py 2025-09-06 05:11:58 +03:00
Egor 4daacae0d9 Update balance.py 2025-09-06 05:09:31 +03:00
Egor 47dbced347 Update balance.py 2025-09-06 05:08:58 +03:00
Egor 49b129a8f4 Update subscription.py 2025-09-06 05:03:12 +03:00
Egor f8315ba18e Update config.py 2025-09-06 04:55:46 +03:00
Egor a9809e74c9 Update config.py 2025-09-06 04:55:16 +03:00
Egor 4fe61ec870 Create admin_notification_service.py 2025-09-06 04:54:48 +03:00
Egor 172ee773ee Update README.md 2025-09-06 04:47:29 +03:00
Egor b8edc4ec0d Update README.md 2025-09-06 04:45:36 +03:00
Egor d8f2f413a8 Update README.md 2025-09-06 03:52:34 +03:00
Egor 360a04fbe8 Update .env.example 2025-09-06 03:52:01 +03:00
Egor 2613d42b57 Merge pull request #29 from Fr1ngg/dev
Правки реф в админке, промокодах, в разделе с подписками. Поправлены кнопки обновления во всех разделах, вырезаны лишние кнопки. Добавлена модель бд для отслеживания конверсии перехода из триальных в платные подписки
2025-09-06 02:40:43 +03:00
Egor a06d55af38 Update subscriptions.py 2025-09-06 02:34:32 +03:00
Egor bf28617d99 Update servers.py 2025-09-06 02:32:32 +03:00
Egor 878a28cb11 Update subscription_conversion.py 2025-09-06 02:24:18 +03:00
Egor ade062c7cd Update subscription.py 2025-09-06 02:19:51 +03:00
Egor 5d774bd1aa Update subscription.py 2025-09-06 02:15:29 +03:00
Egor 8d81bfcf94 Update universal_migration.py 2025-09-06 02:10:10 +03:00
Egor 095c3a8490 Update subscription.py 2025-09-06 02:05:49 +03:00
Egor f7c8c82775 Update subscription.py 2025-09-06 02:04:17 +03:00
Egor 3d75543cf0 Create subscription_conversion.py 2025-09-06 02:03:04 +03:00
Egor 1223948596 Update models.py 2025-09-06 02:02:02 +03:00
Egor f12964ecc8 Update subscription.py 2025-09-06 01:54:51 +03:00
Egor 693dce2e28 Update subscription.py 2025-09-06 01:46:49 +03:00
Egor 166050c581 Update users.py 2025-09-06 01:29:14 +03:00
Egor d499574c4d Update referral.py 2025-09-06 01:27:27 +03:00
Egor 98363e2c28 Update users.py 2025-09-06 01:21:53 +03:00
Egor d87622417a Update promocodes.py 2025-09-06 01:07:52 +03:00
Egor 8be1af5adb Update admin.py 2025-09-06 01:06:55 +03:00
Egor 72a2bb7d04 Merge pull request #28 from Fr1ngg/dev
Правка раздела рефералов в админка, его починка, актуализация данных и доработка + правки раздела статистики в админке
2025-09-06 01:02:19 +03:00
Egor a847d4c1f6 Update statistics.py 2025-09-06 00:58:43 +03:00
Egor b860752d80 Update referrals.py 2025-09-06 00:53:34 +03:00
Egor 0a466b973b Update statistics.py 2025-09-06 00:52:50 +03:00
Egor 9ca0ca2db2 Update referrals.py 2025-09-06 00:39:33 +03:00
Egor af3f746ae1 Update referrals.py 2025-09-06 00:31:15 +03:00
Egor c64c53a26b Update referral.py 2025-09-06 00:30:13 +03:00
Egor 765e912dd1 Update referrals.py 2025-09-06 00:23:42 +03:00
Egor f205acfb0a Update referral.py 2025-09-06 00:16:51 +03:00
Egor 3c424143dc Update referrals.py 2025-09-06 00:11:51 +03:00
Egor ba87ffa0be Update referrals.py 2025-09-06 00:03:09 +03:00
Egor f211c93794 Merge pull request #27 from Fr1ngg/dev
Доработка статистики рефералов, плюс уведомления для рефералов и приглашаюших
2025-09-05 23:15:18 +03:00
Egor 8994e3e9ba Update inline.py 2025-09-05 23:03:19 +03:00
Egor 6018c07c0d Update start.py 2025-09-05 22:57:59 +03:00
Egor 41bfffdf12 Update start.py 2025-09-05 22:52:07 +03:00
Egor 1a06b88db9 Update inline.py 2025-09-05 22:48:27 +03:00
Egor bd6d9013ae Update referral_service.py 2025-09-05 22:47:48 +03:00
Egor 093d178bf8 Update referral.py 2025-09-05 22:46:08 +03:00
Egor 6169f34911 Update user_utils.py 2025-09-05 22:33:13 +03:00
Egor 3aca29777c Update user_utils.py 2025-09-05 22:28:32 +03:00
Egor c7f2b0752b Update referral.py 2025-09-05 22:21:22 +03:00
Egor e113b7e842 Update config.py 2025-09-05 22:18:10 +03:00
Egor 359bbaa31c Update config.py 2025-09-05 22:17:02 +03:00
Egor 862aeee558 Update config.py 2025-09-05 22:16:19 +03:00
Egor 4c3a3b91a6 Update user.py 2025-09-05 22:15:41 +03:00
Egor 199e78cb25 Update inline.py 2025-09-05 22:14:25 +03:00
Egor 87845e2691 Update payment_service.py 2025-09-05 22:13:52 +03:00
Egor 0c30d1ea30 Update start.py 2025-09-05 22:12:16 +03:00
Egor 2f64aa0961 Update user_utils.py 2025-09-05 22:05:08 +03:00
Egor 2266609111 Update referral_service.py 2025-09-05 22:03:28 +03:00
Egor 28e9cf6ab7 Update referral.py 2025-09-05 18:51:37 +03:00
Egor 7c55c54f23 Merge pull request #26 from Fr1ngg/dev
Dev
2025-09-05 18:46:43 +03:00
Egor 060d0a6970 Update README.md 2025-09-05 18:45:41 +03:00
Egor f0465a9ea4 Update .env.example 2025-09-05 18:44:57 +03:00
Egor 29db1f4e56 Update user.py 2025-09-05 18:40:05 +03:00
Egor 755ef03f98 Update user.py 2025-09-05 18:32:09 +03:00
Egor 458b71ea28 Update user.py 2025-09-05 18:28:34 +03:00
Egor f1f409dc63 Update payment_service.py 2025-09-05 18:27:39 +03:00
Egor 64d0f9fc2d Update user.py 2025-09-05 18:17:45 +03:00
Egor 01db9036f5 Update referral_service.py 2025-09-05 18:08:33 +03:00
Egor 674e1b73f6 Update user.py 2025-09-05 18:06:27 +03:00
Egor 9bfb9bc711 Update subscription.py 2025-09-05 17:49:11 +03:00
Egor 96f905563f Update subscription.py 2025-09-05 17:45:58 +03:00
Egor 6b278de538 Update subscription.py 2025-09-05 17:41:26 +03:00
Egor c6fc770fc1 Update models.py 2025-09-05 17:25:53 +03:00
Egor e50f208064 Update models.py 2025-09-05 17:25:21 +03:00
Egor 637af5c216 Update models.py 2025-09-05 17:20:28 +03:00
Egor 08373be215 Update universal_migration.py 2025-09-05 16:46:30 +03:00
Egor 5e4c7e339a Update balance.py 2025-09-05 16:43:21 +03:00
Egor 476dbcca93 Update subscription.py 2025-09-05 16:42:15 +03:00
Egor e8e4330b1c Update referral.py 2025-09-05 16:39:34 +03:00
Egor d5c0146d48 Update balance.py 2025-09-05 16:38:41 +03:00
Egor 83c461230c Update referral_service.py 2025-09-05 16:37:17 +03:00
Egor f27a51639c Update models.py 2025-09-05 16:35:58 +03:00
Egor ab8d4f0340 Update config.py 2025-09-05 16:35:11 +03:00
Egor 1ee15bf6d3 Merge pull request #25 from yazhog/feature/change-buttons
Немного юзерфрендли
2025-09-05 14:41:46 +03:00
yazhog 56238ad93c Немного юзерфрендли 2025-09-05 14:22:02 +03:00
Egor 2fa2c6278c Update README.md 2025-09-05 05:56:00 +03:00
Egor 519978f634 Merge pull request #24 from Fr1ngg/dev
Изменение логики расчета цен за доп. услуги (трафик, сервера, устройства). Теперь рассчитывается цена за месяц
2025-09-05 02:45:20 +03:00
Egor 7095d8f015 Update inline.py 2025-09-05 02:25:19 +03:00
Egor e8dcfa0687 Update inline.py 2025-09-05 02:22:22 +03:00
Egor a0e12fa89a Update subscription.py 2025-09-05 02:21:40 +03:00
Egor 097be9aa0f Update inline.py 2025-09-05 02:13:47 +03:00
Egor f50b5296f4 Update subscription.py 2025-09-05 02:08:59 +03:00
Egor 14e94053ee Update inline.py 2025-09-05 02:07:40 +03:00
Egor 3c27fa4d90 Update subscription.py 2025-09-05 01:56:33 +03:00
Egor 2a78ace675 Update subscription.py 2025-09-05 01:40:31 +03:00
Egor 81774f87d8 Update subscription.py 2025-09-05 01:36:49 +03:00
Egor 6735c2b38f Update subscription_service.py 2025-09-05 01:30:49 +03:00
Egor 8bdc5a2332 Update subscription.py 2025-09-05 01:29:13 +03:00
Egor d26262bfbf Update subscription_service.py 2025-09-05 01:28:55 +03:00
Egor 48f2f01bdd Update __init__.py 2025-09-05 01:28:00 +03:00
Egor ef68a43589 Create pricing_utils.py 2025-09-05 01:27:26 +03:00
Egor 733c1b72ec Update subscription.py 2025-09-05 01:25:59 +03:00
Egor c9ca360286 Update subscription_service.py 2025-09-05 01:18:14 +03:00
Egor 5aad1e025b Update users.py 2025-09-04 21:56:27 +03:00
Egor e811e7c877 Merge pull request #22 from Fr1ngg/dev
Dev
2025-09-04 21:45:36 +03:00
Egor 932e9f292b Update admin.py 2025-09-04 21:42:20 +03:00
Egor 03749574d6 Update users.py 2025-09-04 21:33:09 +03:00
Egor 1059a51688 Update users.py 2025-09-04 21:30:31 +03:00
Egor a1963d5b17 Update users.py 2025-09-04 21:26:07 +03:00
Egor 426e736cd5 Update users.py 2025-09-04 21:18:18 +03:00
Egor fb03cff403 Update users.py 2025-09-04 21:11:28 +03:00
Egor 40780f1184 Update users.py 2025-09-04 21:00:58 +03:00
Egor c76290b51e Update users.py 2025-09-04 20:55:01 +03:00
Egor 5b6dd3058e Update users.py 2025-09-04 20:46:52 +03:00
Egor c8d91a1ec1 Update admin.py 2025-09-04 20:43:55 +03:00
Egor 21952f84eb Update states.py 2025-09-04 20:42:54 +03:00
Egor 7bdfaa312b Update users.py 2025-09-04 20:42:19 +03:00
Egor 1829a469e7 Merge pull request #21 from Fr1ngg/dev
Dev
2025-09-04 18:40:02 +03:00
Egor ddd37a4e98 Update monitoring_service.py 2025-09-04 18:38:44 +03:00
Egor 2804c701c0 Update monitoring_service.py 2025-09-04 18:35:59 +03:00
Egor b3c85b2db5 Delete app/handlers/subscription_notifications.py 2025-09-04 18:28:42 +03:00
Egor 987b8170c6 Update monitoring_service.py 2025-09-04 18:26:38 +03:00
Egor ab49f3cd1e Merge pull request #20 from Fr1ngg/dev
Dev
2025-09-04 16:50:49 +03:00
Egor 732e8f7b8b Update README.md 2025-09-04 16:48:09 +03:00
Egor 9273a4e15b Update .env.example 2025-09-04 16:47:42 +03:00
Egor d89293f76a Update user_service.py 2025-09-04 16:45:57 +03:00
Egor 174462183d Update config.py 2025-09-04 16:18:34 +03:00
Egor 783927be37 Update subscription_service.py 2025-09-04 16:13:58 +03:00
Egor 98c68dd8dc Update inline.py 2025-09-04 16:01:38 +03:00
Egor 13585eab54 Update config.py 2025-09-04 15:53:22 +03:00
Egor 169336da43 Update config.py 2025-09-04 15:48:36 +03:00
Egor 9ef875f6bf Update config.py 2025-09-04 15:46:36 +03:00
Egor 3f54706a72 Update config.py 2025-09-04 15:39:09 +03:00
Egor 835e5e6f7f Update config.py 2025-09-04 15:24:29 +03:00
Egor b292442e8d Update config.py 2025-09-04 15:16:56 +03:00
Egor 9faaf5c963 Update inline.py 2025-09-04 15:13:50 +03:00
Egor 117e88fe4a Update subscription.py 2025-09-04 15:04:07 +03:00
Egor 96bf08a07c Update inline.py 2025-09-04 15:02:59 +03:00
Egor 4523403de5 Update config.py 2025-09-04 15:01:25 +03:00
Egor 02682e88c1 Update subscription.py 2025-09-04 14:48:57 +03:00
Egor 323b9a7e18 Update subscription.py 2025-09-04 14:48:10 +03:00
Egor 100dc3730f Update subscription.py 2025-09-04 14:43:09 +03:00
Egor 919047317a Update subscription.py 2025-09-04 14:38:12 +03:00
Egor 6ae5b59ed1 Update subscription.py 2025-09-04 14:37:47 +03:00
Egor b3123e2e85 Update subscription.py 2025-09-04 14:34:09 +03:00
Egor fbe0ca790e Update subscription.py 2025-09-04 14:31:40 +03:00
Egor 785648ba7a Update subscription.py 2025-09-04 14:27:22 +03:00
Egor 06f8728efa Update inline.py 2025-09-04 14:21:00 +03:00
Egor 99f102a06e Update config.py 2025-09-04 14:19:23 +03:00
Egor d759dd3a66 Update user_service.py 2025-09-04 13:58:58 +03:00
Egor 73dc22b0b8 Update texts.py 2025-09-04 04:27:33 +03:00
Egor 5a082f0eb8 Update texts.py 2025-09-04 04:20:58 +03:00
Egor 073d5a119b Update subscription.py 2025-09-04 04:20:36 +03:00
Egor 2dab106bef Update README.md 2025-09-04 04:13:00 +03:00
Egor ec8c7b7242 Update .env.example 2025-09-04 04:12:46 +03:00
Egor a4b96a089f Update user_service.py 2025-09-04 04:07:09 +03:00
Egor 269f8f6220 Update user_service.py 2025-09-04 04:04:19 +03:00
Egor f1beda2b89 Update user_service.py 2025-09-04 04:01:03 +03:00
Egor 6a00db32ae Update config.py 2025-09-04 03:28:51 +03:00
Egor 276bc5eb40 Update .env.example 2025-09-04 03:27:58 +03:00
Egor 8678afd055 Update subscription.py 2025-09-04 03:26:34 +03:00
Egor 31340ad4a5 Update subscription_service.py 2025-09-04 03:25:08 +03:00
Egor 98ce47ce71 Update inline.py 2025-09-04 03:24:07 +03:00
Egor 3462f5b3b1 Update subscription.py 2025-09-04 02:57:42 +03:00
Egor 7ec606f93c Update config.py 2025-09-03 21:11:15 +03:00
Egor 7d13bf47aa Update README.md 2025-09-03 21:08:32 +03:00
Egor 87773806fc Update .env.example 2025-09-03 21:08:09 +03:00
Egor dad6200ad8 Update bot.py 2025-09-03 21:07:40 +03:00
Egor c3991162c2 Update payment_service.py 2025-09-03 21:06:48 +03:00
Egor 08feb1f876 Update telegram_stars.py 2025-09-03 21:05:33 +03:00
Egor 6f15070052 Update balance.py 2025-09-03 21:05:13 +03:00
Egor 0c4e73ab92 Create stars_payments.py 2025-09-03 21:04:50 +03:00
Egor 48902c4968 Update telegram_stars.py 2025-09-03 20:57:01 +03:00
Egor 3c715577a6 Update telegram_stars.py 2025-09-03 20:55:25 +03:00
Egor 8e67c7a97a Update texts.py 2025-09-03 20:09:01 +03:00
Egor c938d48026 Update texts.py 2025-09-03 20:02:46 +03:00
Egor 99a4927f8a Update texts.py 2025-09-03 19:53:10 +03:00
Egor df5b8dcdcd Update inline.py 2025-09-03 19:52:51 +03:00
Egor d4a1a2e64d Update menu.py 2025-09-03 19:52:34 +03:00
Egor 5e2df269a3 Update start.py 2025-09-03 19:52:16 +03:00
Egor a2056e0441 Update docker-registry.yml 2025-09-03 18:38:28 +03:00
Egor a6bbd8f8fb Add files via upload 2025-09-03 06:39:35 +03:00
Egor 9f4e0cf5f8 Update menu.py 2025-09-03 06:34:44 +03:00
Egor c7e8365d3f Update start.py 2025-09-03 06:33:37 +03:00
Egor 49b10b490e Update inline.py 2025-09-03 06:29:20 +03:00
Egor f26bca8627 Update texts.py 2025-09-03 06:27:46 +03:00
Egor df139b3d34 Update texts.py 2025-09-03 06:22:09 +03:00
Egor 05d1edf278 Update menu.py 2025-09-03 06:21:21 +03:00
Egor a518df8ff0 Update menu.py 2025-09-03 06:16:38 +03:00
Egor 0a13fe4f40 Update menu.py 2025-09-03 06:13:15 +03:00
Egor 6410bd1085 Update menu.py 2025-09-03 06:03:26 +03:00
Egor 55c4549cff Update inline.py 2025-09-03 06:01:29 +03:00
Egor e898948073 Update texts.py 2025-09-03 05:59:21 +03:00
Egor 5f1c8d8ac0 Update README.md 2025-09-03 05:02:51 +03:00
Egor 8d1f8f2355 Update README.md 2025-09-03 04:56:05 +03:00
Egor fbbbad608b Update .env.example 2025-09-03 04:55:38 +03:00
101 changed files with 13248 additions and 2212 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/
+79 -27
View File
@@ -7,33 +7,56 @@ BOT_TOKEN=
ADMIN_IDS=
SUPPORT_USERNAME=@support
# ===== 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
# Уведомления администраторов
ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
REDIS_URL=redis://redis:6379/0
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
DATABASE_MODE=auto
# Пароли для Docker (PostgreSQL/Redis)
# Основной URL (можно оставить пустым для автоматического выбора)
DATABASE_URL=
# PostgreSQL настройки (для Docker и кастомных установок)
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=remnawave_bot
POSTGRES_USER=remnawave_user
POSTGRES_PASSWORD=secure_password_123
# SQLite настройки (для локального запуска)
SQLITE_PATH=./data/bot.db
# Redis
REDIS_URL=redis://redis:6379/0
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=
# Для панелей установленных скриптом eGames прописывать ключ в формате XXXXXXX:DDDDDDDD - https://panel.example.com/auth/login?XXXXXXX=DDDDDDDD
REMNAWAVE_SECRET_KEY=your_secret_key_here
# ========= ПОДПИСКИ =========
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
TRIAL_SQUAD_UUID=
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# ===== ПЛАТНАЯ ПОДПИСКА =====
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
DEFAULT_DEVICE_LIMIT=1
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -62,23 +85,22 @@ PRICE_90_DAYS=36900
PRICE_180_DAYS=69900
PRICE_360_DAYS=109900
# Цены за трафик
PRICE_TRAFFIC_5GB=1000
PRICE_TRAFFIC_10GB=2000
PRICE_TRAFFIC_25GB=3000
PRICE_TRAFFIC_50GB=4000
PRICE_TRAFFIC_100GB=6000
PRICE_TRAFFIC_250GB=10000
PRICE_TRAFFIC_UNLIMITED=15000
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
# Цена за дополнительное устройство
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=5000
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_REGISTRATION_REWARD=10000
REFERRED_USER_REWARD=10000
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== АВТОПРОДЛЕНИЕ =====
AUTOPAY_WARNING_DAYS=3,1
DEFAULT_AUTOPAY_DAYS_BEFORE=3
@@ -88,11 +110,11 @@ MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
# Telegram Stars (работает автоматически)
TELEGRAM_STARS_ENABLED=true
TELEGRAM_STARS_RATE_RUB=1.3
# Tribute (https://tribute.app)
TRIBUTE_ENABLED=false
TRIBUTE_API_KEY=
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
TRIBUTE_DONATE_LINK=
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
@@ -154,8 +176,24 @@ 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)
@@ -186,6 +224,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
@@ -194,9 +252,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.7-$(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.7-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.7-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
+95 -7
View File
@@ -1,18 +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
@@ -22,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.7"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.7-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.7-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
@@ -42,20 +89,34 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
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 }}
@@ -64,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
+43 -24
View File
@@ -1,43 +1,62 @@
FROM python:3.11-slim
WORKDIR /app
FROM python:3.11-slim AS builder
# Install system dependencies
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
wget \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
ARG VERSION="v2.2.7"
ARG BUILD_DATE
ARG VCS_REF
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Create non-root user with specific UID/GID for consistency
RUN groupadd -g 1000 app && \
useradd -u 1000 -g 1000 -m -s /bin/bash app
# Copy application code
COPY . .
WORKDIR /app
COPY --chown=app:app . .
# Create necessary directories and set permissions
RUN mkdir -p logs data && \
chown -R app:app /app && \
chmod -R 755 /app && \
chmod -R 777 logs
chown -R app:app /app logs data
# Switch to non-root user
USER app
# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
VERSION=${VERSION} \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
# Expose webhook ports
EXPOSE 8081
EXPOSE 8082
EXPOSE 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.revision="${VCS_REF}" \
org.opencontainers.image.source="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.url="https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot" \
org.opencontainers.image.vendor="fr1ngg"
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
# Run the application
CMD ["python", "main.py"]
+744 -286
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -23,8 +23,16 @@ from app.handlers.admin import (
monitoring as admin_monitoring, referrals as admin_referrals,
rules as admin_rules, remnawave as admin_remnawave,
statistics as admin_statistics, servers as admin_servers,
maintenance as admin_maintenance
maintenance as admin_maintenance,
user_messages as admin_user_messages,
updates as admin_updates, backup as admin_backup,
welcome_text as admin_welcome_text
)
from app.handlers.stars_payments import register_stars_handlers
from app.utils.message_patch import patch_message_methods
patch_message_methods()
logger = logging.getLogger(__name__)
@@ -66,16 +74,19 @@ 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())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
dp.message.middleware(SubscriptionStatusMiddleware())
dp.callback_query.middleware(SubscriptionStatusMiddleware())
start.register_handlers(dp)
menu.register_handlers(dp)
subscription.register_handlers(dp)
@@ -83,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)
@@ -95,9 +107,16 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
admin_backup.register_handlers(dp)
admin_welcome_text.register_welcome_text_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
try:
await maintenance_service.start_monitoring()
logger.info("Мониторинг техработ запущен")
+311 -28
View File
@@ -1,5 +1,5 @@
import os
from typing import List, Optional, Union
from typing import List, Optional, Union, Dict
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
from pathlib import Path
@@ -10,8 +10,23 @@ class Settings(BaseSettings):
BOT_TOKEN: str
ADMIN_IDS: str = ""
SUPPORT_USERNAME: str = "@support"
ADMIN_NOTIFICATIONS_ENABLED: bool = False
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
DATABASE_URL: str = ""
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
POSTGRES_DB: str = "remnawave_bot"
POSTGRES_USER: str = "remnawave_user"
POSTGRES_PASSWORD: str = "secure_password_123"
SQLITE_PATH: str = "./data/bot.db"
DATABASE_MODE: str = "auto"
DATABASE_URL: str
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
@@ -25,6 +40,7 @@ class Settings(BaseSettings):
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
ENABLE_NOTIFICATIONS: bool = True
@@ -44,22 +60,31 @@ class Settings(BaseSettings):
PRICE_180_DAYS: int = 499000
PRICE_360_DAYS: int = 899000
PRICE_TRAFFIC_5GB: int = 10000
PRICE_TRAFFIC_10GB: int = 19000
PRICE_TRAFFIC_25GB: int = 45000
PRICE_TRAFFIC_50GB: int = 85000
PRICE_TRAFFIC_100GB: int = 159000
PRICE_TRAFFIC_250GB: int = 369000
PRICE_TRAFFIC_UNLIMITED: int = 0
PRICE_TRAFFIC_5GB: int = 2000
PRICE_TRAFFIC_10GB: int = 3500
PRICE_TRAFFIC_25GB: int = 7000
PRICE_TRAFFIC_50GB: int = 11000
PRICE_TRAFFIC_100GB: int = 15000
PRICE_TRAFFIC_250GB: int = 17000
PRICE_TRAFFIC_500GB: int = 19000
PRICE_TRAFFIC_1000GB: int = 19500
PRICE_TRAFFIC_UNLIMITED: int = 20000
TRAFFIC_PACKAGES_CONFIG: str = ""
PRICE_PER_DEVICE: int = 5000
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
REFERRAL_REGISTRATION_REWARD: int = 5000
REFERRED_USER_REWARD: int = 2500
REFERRAL_COMMISSION_PERCENT: int = 10
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
REFERRAL_INVITER_BONUS_KOPEKS: int = 10000
REFERRAL_COMMISSION_PERCENT: int = 25
REFERRAL_NOTIFICATIONS_ENABLED: bool = True
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3
REFERRED_USER_REWARD: int = 0
AUTOPAY_WARNING_DAYS: str = "3,1"
@@ -75,10 +100,10 @@ class Settings(BaseSettings):
MAINTENANCE_MESSAGE: str = "🔧 Ведутся технические работы. Сервис временно недоступен. Попробуйте позже."
TELEGRAM_STARS_ENABLED: bool = True
TELEGRAM_STARS_RATE_RUB: float = 1.3
TRIBUTE_ENABLED: bool = False
TRIBUTE_API_KEY: Optional[str] = None
TRIBUTE_WEBHOOK_SECRET: Optional[str] = None
TRIBUTE_DONATE_LINK: Optional[str] = None
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
@@ -100,9 +125,21 @@ 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"
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
@@ -117,6 +154,18 @@ 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"
@field_validator('LOG_FILE', mode='before')
@classmethod
@@ -125,6 +174,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()
@@ -165,8 +256,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
@@ -214,6 +305,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
@@ -237,7 +349,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]
@@ -256,7 +368,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]
@@ -281,16 +393,182 @@ class Settings(BaseSettings):
service_name=self.PAYMENT_SERVICE_NAME,
description=description
)
def get_stars_rate(self) -> float:
return self.TELEGRAM_STARS_RATE_RUB
def stars_to_rubles(self, stars: int) -> float:
return stars * self.get_stars_rate()
def rubles_to_stars(self, rubles: float) -> int:
return max(1, int(rubles / self.get_stars_rate()))
def get_admin_notifications_chat_id(self) -> Optional[int]:
if not self.ADMIN_NOTIFICATIONS_CHAT_ID:
return None
try:
return int(self.ADMIN_NOTIFICATIONS_CHAT_ID)
except (ValueError, TypeError):
return None
def is_admin_notifications_enabled(self) -> bool:
return (self.ADMIN_NOTIFICATIONS_ENABLED and
self.get_admin_notifications_chat_id() is not None)
def get_referral_settings(self) -> Dict:
return {
"minimum_topup_kopeks": self.REFERRAL_MINIMUM_TOPUP_KOPEKS,
"first_topup_bonus_kopeks": self.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
"inviter_bonus_kopeks": self.REFERRAL_INVITER_BONUS_KOPEKS,
"commission_percent": self.REFERRAL_COMMISSION_PERCENT,
"notifications_enabled": self.REFERRAL_NOTIFICATIONS_ENABLED,
"referred_user_reward": self.REFERRED_USER_REWARD
}
def is_referral_notifications_enabled(self) -> bool:
return self.REFERRAL_NOTIFICATIONS_ENABLED
def get_traffic_packages(self) -> List[Dict]:
import logging
logger = logging.getLogger(__name__)
try:
packages = []
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
logger.info(f"CONFIG STRING: '{config_str}'")
if not config_str:
logger.info("CONFIG EMPTY, USING FALLBACK")
return self._get_fallback_traffic_packages()
logger.info("PARSING CONFIG...")
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) != 3:
continue
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true'
packages.append({
"gb": gb,
"price": price,
"enabled": enabled
})
except ValueError:
continue
logger.info(f"PARSED {len(packages)} packages from config")
return packages if packages else self._get_fallback_traffic_packages()
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:
if self.TRAFFIC_PACKAGES_CONFIG.strip():
packages = []
for package_config in self.TRAFFIC_PACKAGES_CONFIG.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) != 3:
continue
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true'
packages.append({
"gb": gb,
"price": price,
"enabled": enabled
})
except ValueError:
continue
if packages:
return packages
except Exception as e:
pass
return [
{"gb": 5, "price": self.PRICE_TRAFFIC_5GB, "enabled": True},
{"gb": 10, "price": self.PRICE_TRAFFIC_10GB, "enabled": True},
{"gb": 25, "price": self.PRICE_TRAFFIC_25GB, "enabled": True},
{"gb": 50, "price": self.PRICE_TRAFFIC_50GB, "enabled": True},
{"gb": 100, "price": self.PRICE_TRAFFIC_100GB, "enabled": True},
{"gb": 250, "price": self.PRICE_TRAFFIC_250GB, "enabled": True},
{"gb": 500, "price": self.PRICE_TRAFFIC_500GB, "enabled": True},
{"gb": 1000, "price": self.PRICE_TRAFFIC_1000GB, "enabled": True},
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
]
def get_traffic_price(self, gb: int) -> int:
packages = self.get_traffic_packages()
for package in packages:
if package["gb"] == gb and package["enabled"]:
return package["price"]
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
if finite_packages:
max_package = max(finite_packages, key=lambda x: x["gb"])
if gb > max_package["gb"]:
if unlimited_package:
return unlimited_package["price"]
else:
return max_package["price"]
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
if suitable_packages:
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
return nearest_package["price"]
if unlimited_package:
return unlimited_package["price"]
return 0
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,
@@ -300,12 +578,17 @@ PERIOD_PRICES = {
360: settings.PRICE_360_DAYS,
}
TRAFFIC_PRICES = {
5: settings.PRICE_TRAFFIC_5GB,
10: settings.PRICE_TRAFFIC_10GB,
25: settings.PRICE_TRAFFIC_25GB,
50: settings.PRICE_TRAFFIC_50GB,
100: settings.PRICE_TRAFFIC_100GB,
250: settings.PRICE_TRAFFIC_250GB,
0: settings.PRICE_TRAFFIC_UNLIMITED,
}
def get_traffic_prices() -> Dict[int, int]:
packages = settings.get_traffic_packages()
return {package["gb"]: package["price"] for package in packages}
TRAFFIC_PRICES = get_traffic_prices()
def refresh_traffic_prices():
global 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()
+81 -44
View File
@@ -91,6 +91,7 @@ async def get_referral_earnings_sum(
async def get_referral_statistics(db: AsyncSession) -> dict:
users_with_referrals_result = await db.execute(
select(func.count(func.distinct(User.id)))
.where(User.referred_by_id.isnot(None))
@@ -117,41 +118,72 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
total_paid = referral_paid + transaction_paid
top_referrers_result = await db.execute(
referrals_stats_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('referrals_count'),
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('referral_earnings'),
func.coalesce(func.sum(Transaction.amount_kopeks), 0).label('transaction_earnings')
)
.outerjoin(
ReferralEarning,
ReferralEarning.user_id == User.referred_by_id
)
.outerjoin(
Transaction,
and_(
Transaction.user_id == User.referred_by_id,
Transaction.type == TransactionType.REFERRAL_REWARD.value
)
)
.where(
and_(
User.referred_by_id.isnot(None),
User.referred_by_id != User.id
)
func.count(User.id).label('referrals_count')
)
.where(User.referred_by_id.isnot(None))
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(5)
)
top_referrers_raw = top_referrers_result.all()
referrals_stats = {row.referrer_id: row.referrals_count for row in referrals_stats_result.all()}
referral_earnings_result = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('referral_earnings')
)
.group_by(ReferralEarning.user_id)
)
referral_earnings = {row.referrer_id: row.referral_earnings for row in referral_earnings_result.all()}
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('transaction_earnings')
)
.where(Transaction.type == TransactionType.REFERRAL_REWARD.value)
.group_by(Transaction.user_id)
)
transaction_earnings = {row.referrer_id: row.transaction_earnings for row in transaction_earnings_result.all()}
top_referrers_data = {}
for referrer_id, count in referrals_stats.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['referrals_count'] = count
for referrer_id, earnings in referral_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
for referrer_id, earnings in transaction_earnings.items():
if referrer_id not in top_referrers_data:
top_referrers_data[referrer_id] = {
'referrals_count': 0,
'total_earned': 0
}
top_referrers_data[referrer_id]['total_earned'] += earnings or 0
sorted_referrers = sorted(
top_referrers_data.items(),
key=lambda x: (x[1]['total_earned'], x[1]['referrals_count']),
reverse=True
)
top_referrers = []
for row in top_referrers_raw:
for referrer_id, stats in sorted_referrers[:5]:
user_result = await db.execute(
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
.where(User.id == row.referrer_id)
.where(User.id == referrer_id)
)
user = user_result.first()
@@ -166,23 +198,22 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
else:
display_name = f"ID{user.telegram_id}"
total_earned = (row.referral_earnings or 0) + (row.transaction_earnings or 0)
top_referrers.append({
"user_id": row.referrer_id,
"user_id": user.telegram_id,
"display_name": display_name,
"username": user.username,
"total_earned_kopeks": total_earned,
"referrals_count": row.referrals_count
"telegram_id": user.telegram_id,
"total_earned_kopeks": stats['total_earned'],
"referrals_count": stats['referrals_count']
})
today = datetime.utcnow().date()
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_referral_earnings = await db.execute(
today_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= today)
)
today_transaction_earnings = await db.execute(
today_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -191,14 +222,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
today_earnings = today_referral_earnings.scalar() + today_transaction_earnings.scalar()
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
week_ago = datetime.utcnow() - timedelta(days=7)
week_referral_earnings = await db.execute(
week_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= week_ago)
)
week_transaction_earnings = await db.execute(
week_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -207,14 +238,14 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
week_earnings = week_referral_earnings.scalar() + week_transaction_earnings.scalar()
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
month_ago = datetime.utcnow() - timedelta(days=30)
month_referral_earnings = await db.execute(
month_referral_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.created_at >= month_ago)
)
month_transaction_earnings = await db.execute(
month_transaction_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
@@ -223,7 +254,9 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
)
)
)
month_earnings = month_referral_earnings.scalar() + month_transaction_earnings.scalar()
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
logger.info(f"Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек")
return {
"users_with_referrals": users_with_referrals,
@@ -248,13 +281,17 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
from app.database.models import Subscription, SubscriptionStatus
current_time = datetime.utcnow()
active_referrals_result = await db.execute(
select(func.count(User.id))
.join(User.subscription)
.join(Subscription, User.id == Subscription.user_id)
.where(
and_(
User.referred_by_id == user_id,
User.subscription.has()
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.end_date > current_time
)
)
)
@@ -265,4 +302,4 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
"active_referrals": active_referrals,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
}
+192 -21
View File
@@ -6,9 +6,11 @@ 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
logger = logging.getLogger(__name__)
@@ -120,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
@@ -353,6 +356,37 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
)
purchased_month = month_result.scalar()
try:
from app.database.crud.subscription_conversion import get_conversion_statistics
conversion_stats = await get_conversion_statistics(db)
trial_to_paid_conversion = conversion_stats.get("conversion_rate", 0)
renewals_count = conversion_stats.get("month_conversions", 0)
logger.info(f"📊 Статистика конверсии из таблицы conversions:")
logger.info(f" Общее количество конверсий: {conversion_stats.get('total_conversions', 0)}")
logger.info(f" Процент конверсии: {trial_to_paid_conversion}%")
logger.info(f" Конверсий за месяц: {renewals_count}")
except ImportError:
logger.warning("⚠️ Таблица subscription_conversions не найдена, используем старую логику")
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
total_users_result = await db.execute(select(func.count(User.id)))
total_users = total_users_result.scalar()
if total_users > 0:
trial_to_paid_conversion = round((users_with_paid / total_users) * 100, 1)
else:
trial_to_paid_conversion = 0
renewals_count = 0
return {
"total_subscriptions": total_subscriptions,
"active_subscriptions": active_subscriptions,
@@ -360,10 +394,11 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
"paid_subscriptions": paid_subscriptions,
"purchased_today": purchased_today,
"purchased_week": purchased_week,
"purchased_month": purchased_month
"purchased_month": purchased_month,
"trial_to_paid_conversion": trial_to_paid_conversion,
"renewals_count": renewals_count
}
async def update_subscription_usage(
db: AsyncSession,
subscription: Subscription,
@@ -407,9 +442,19 @@ async def add_subscription_servers(
server_squad_ids: List[int],
paid_prices: List[int] = None
) -> Subscription:
if paid_prices is None:
paid_prices = [0] * len(server_squad_ids)
months_remaining = get_remaining_months(subscription.end_date)
paid_prices = []
from app.database.models import ServerSquad
for server_id in server_squad_ids:
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_id)
)
server_price_per_month = result.scalar() or 0
total_price_for_period = server_price_per_month * months_remaining
paid_prices.append(total_price_for_period)
for i, server_id in enumerate(server_squad_ids):
subscription_server = SubscriptionServer(
@@ -422,9 +467,79 @@ async def add_subscription_servers(
await db.commit()
await db.refresh(subscription)
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов")
logger.info(f"🌍 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}")
return subscription
async def get_server_monthly_price(
db: AsyncSession,
server_squad_id: int
) -> int:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_squad_id)
)
return result.scalar() or 0
async def get_servers_monthly_prices(
db: AsyncSession,
server_squad_ids: List[int]
) -> List[int]:
prices = []
for server_id in server_squad_ids:
price = await get_server_monthly_price(db, server_id)
prices.append(price)
return prices
async def calculate_subscription_total_cost(
db: AsyncSession,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int
) -> Tuple[int, dict]:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
servers_price_per_month = sum(servers_prices)
total_servers_price = servers_price_per_month * months_in_period
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
details = {
'base_price': base_price,
'traffic_price_per_month': traffic_price_per_month,
'total_traffic_price': total_traffic_price,
'servers_price_per_month': servers_price_per_month,
'total_servers_price': total_servers_price,
'devices_price_per_month': devices_price_per_month,
'total_devices_price': total_devices_price,
'months_in_period': months_in_period,
'servers_individual_prices': [price * months_in_period for price in servers_prices]
}
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Базовый период: {base_price/100}")
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}")
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {total_cost/100}")
return total_cost, details
async def get_subscription_server_ids(
db: AsyncSession,
subscription_id: int
@@ -497,30 +612,43 @@ async def get_subscription_renewal_cost(
period_days: int
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES, settings
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
servers_info = await get_subscription_servers(db, subscription_id)
servers_cost = sum(server_info['paid_price_kopeks'] for server_info in servers_info)
subscription = await db.get(Subscription, subscription_id)
if not subscription:
return base_price
traffic_cost = 0
if subscription.traffic_limit_gb > 0:
traffic_cost = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
servers_info = await get_subscription_servers(db, subscription_id)
servers_price_per_month = 0
for server_info in servers_info:
from app.database.models import ServerSquad
result = await db.execute(
select(ServerSquad.price_kopeks)
.where(ServerSquad.id == server_info['server_id'])
)
current_server_price = result.scalar() or 0
servers_price_per_month += current_server_price
devices_cost = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_servers_cost = servers_price_per_month * months_in_period
total_cost = base_price + servers_cost + traffic_cost + devices_cost
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_cost = traffic_price_per_month * months_in_period
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней:")
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_cost = devices_price_per_month * months_in_period
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_cost/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}")
logger.info(f" 💎 ИТОГО: {total_cost/100}")
return total_cost
@@ -530,6 +658,49 @@ async def get_subscription_renewal_cost(
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_cost_for_remaining_period(
db: AsyncSession,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None
) -> int:
if additional_server_ids is None:
additional_server_ids = []
months_to_pay = get_remaining_months(subscription.end_date)
total_cost = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_total_cost = traffic_price_per_month * months_to_pay
total_cost += traffic_total_cost
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}")
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_cost = devices_price_per_month * months_to_pay
total_cost += devices_total_cost
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}")
if additional_server_ids:
from app.database.models import ServerSquad
for server_id in additional_server_ids:
result = await db.execute(
select(ServerSquad.price_kopeks, ServerSquad.display_name)
.where(ServerSquad.id == server_id)
)
server_data = result.first()
if server_data:
server_price_per_month, server_name = server_data
server_total_cost = server_price_per_month * months_to_pay
total_cost += server_total_cost
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}")
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}")
return total_cost
async def expire_subscription(
db: AsyncSession,
subscription: Subscription
@@ -0,0 +1,118 @@
import logging
from datetime import datetime, timedelta
from typing import Optional, List
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionConversion, User
logger = logging.getLogger(__name__)
async def create_subscription_conversion(
db: AsyncSession,
user_id: int,
trial_duration_days: int,
payment_method: str,
first_payment_amount_kopeks: int,
first_paid_period_days: int
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.utcnow(),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
first_paid_period_days=first_paid_period_days
)
db.add(conversion)
await db.commit()
await db.refresh(conversion)
logger.info(f"✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks/100}")
return conversion
async def get_conversion_by_user_id(
db: AsyncSession,
user_id: int
) -> Optional[SubscriptionConversion]:
result = await db.execute(
select(SubscriptionConversion)
.where(SubscriptionConversion.user_id == user_id)
.order_by(SubscriptionConversion.converted_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_conversion_statistics(db: AsyncSession) -> dict:
total_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
)
total_conversions = total_conversions_result.scalar()
users_with_paid_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
users_with_paid = users_with_paid_result.scalar()
if total_conversions > 0:
conversion_rate = round((total_conversions / max(total_conversions, users_with_paid)) * 100, 1)
elif users_with_paid > 0:
conversion_rate = 100.0
else:
conversion_rate = 0.0
avg_trial_duration_result = await db.execute(
select(func.avg(SubscriptionConversion.trial_duration_days))
)
avg_trial_duration = avg_trial_duration_result.scalar() or 0
avg_first_payment_result = await db.execute(
select(func.avg(SubscriptionConversion.first_payment_amount_kopeks))
)
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id))
.where(SubscriptionConversion.converted_at >= month_ago)
)
month_conversions = month_conversions_result.scalar()
logger.info(f"📊 Статистика конверсий:")
logger.info(f" Всего записей о конверсиях: {total_conversions}")
logger.info(f" Пользователей с платными подписками: {users_with_paid}")
logger.info(f" Рассчитанная конверсия: {conversion_rate}%")
return {
"total_conversions": total_conversions,
"conversion_rate": conversion_rate,
"avg_trial_duration_days": round(avg_trial_duration, 1),
"avg_first_payment_rubles": round((avg_first_payment or 0) / 100, 2),
"month_conversions": month_conversions
}
async def get_users_had_trial_count(db: AsyncSession) -> int:
conversions_count_result = await db.execute(
select(func.count(func.distinct(SubscriptionConversion.user_id)))
)
conversions_count = conversions_count_result.scalar()
paid_users_result = await db.execute(
select(func.count(User.id))
.where(User.has_had_paid_subscription == True)
)
paid_users_count = paid_users_result.scalar()
return max(conversions_count, paid_users_count)
+19 -11
View File
@@ -277,12 +277,12 @@ async def get_revenue_by_period(
return [{"date": row.date, "amount_kopeks": row.amount} for row in result]
async def find_tribute_transactions_by_payment_id(
db: AsyncSession,
payment_id: str,
user_telegram_id: Optional[int] = None
) -> List[Transaction]:
"""Найти все Tribute транзакции по payment_id"""
query = select(Transaction).options(selectinload(Transaction.user))
@@ -313,18 +313,27 @@ async def check_tribute_payment_duplicate(
amount_kopeks: int,
user_telegram_id: int
) -> Optional[Transaction]:
cutoff_time = datetime.utcnow() - timedelta(hours=24)
transactions = await find_tribute_transactions_by_payment_id(
db, payment_id, user_telegram_id
)
exact_external_id = f"donation_{payment_id}"
for transaction in transactions:
if (transaction.amount_kopeks == amount_kopeks and
transaction.is_completed and
transaction.user.telegram_id == user_telegram_id):
return transaction
query = select(Transaction).options(selectinload(Transaction.user)).where(
and_(
Transaction.payment_method == PaymentMethod.TRIBUTE.value,
Transaction.external_id == exact_external_id,
Transaction.amount_kopeks == amount_kopeks,
Transaction.is_completed == True,
Transaction.created_at >= cutoff_time
)
).join(User).where(User.telegram_id == user_telegram_id)
return None
result = await db.execute(query)
transaction = result.scalar_one_or_none()
if transaction:
logger.info(f"🔍 Найден дубликат платежа в течение 24ч: {transaction.id}")
return transaction
async def create_unique_tribute_transaction(
@@ -334,7 +343,6 @@ async def create_unique_tribute_transaction(
amount_kopeks: int,
description: str
) -> Transaction:
"""Создать уникальную Tribute транзакцию с защитой от дубликатов"""
external_id = f"donation_{payment_id}"
+19 -18
View File
@@ -48,7 +48,6 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
result = await db.execute(
select(User).where(User.referral_code == referral_code)
)
@@ -92,7 +91,8 @@ async def create_user(
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False
has_had_paid_subscription=False,
has_made_first_topup=False
)
db.add(user)
@@ -125,27 +125,31 @@ async def add_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str = "Пополнение баланса"
description: str = "Пополнение баланса",
create_transaction: bool = True,
bot = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
from app.database.models import TransactionType
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=description
)
await db.commit()
await db.refresh(user)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
return True
@@ -154,6 +158,7 @@ async def add_user_balance(
await db.rollback()
return False
async def add_user_balance_by_id(
db: AsyncSession,
telegram_id: int,
@@ -171,11 +176,7 @@ async def add_user_balance_by_id(
except Exception as e:
logger.error(f"Ошибка пополнения баланса пользователя {telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка пополнения баланса пользователя {user_id}: {e}")
await db.rollback()
return False
async def subtract_user_balance(
db: AsyncSession,
+157
View File
@@ -0,0 +1,157 @@
import logging
import random
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import UserMessage
logger = logging.getLogger(__name__)
async def create_user_message(
db: AsyncSession,
message_text: str,
created_by: int,
is_active: bool = True,
sort_order: int = 0
) -> UserMessage:
message = UserMessage(
message_text=message_text,
is_active=is_active,
sort_order=sort_order,
created_by=created_by
)
db.add(message)
await db.commit()
await db.refresh(message)
logger.info(f"✅ Создано сообщение ID {message.id} пользователем {created_by}")
return message
async def get_user_message_by_id(db: AsyncSession, message_id: int) -> Optional[UserMessage]:
result = await db.execute(
select(UserMessage).where(UserMessage.id == message_id)
)
return result.scalar_one_or_none()
async def get_active_user_messages(db: AsyncSession) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.where(UserMessage.is_active == True)
.order_by(UserMessage.sort_order.asc(), UserMessage.created_at.desc())
)
return result.scalars().all()
async def get_random_active_message(db: AsyncSession) -> Optional[str]:
active_messages = await get_active_user_messages(db)
if not active_messages:
return None
random_message = random.choice(active_messages)
return random_message.message_text
async def get_all_user_messages(
db: AsyncSession,
offset: int = 0,
limit: int = 50
) -> List[UserMessage]:
result = await db.execute(
select(UserMessage)
.order_by(UserMessage.created_at.desc())
.offset(offset)
.limit(limit)
)
return result.scalars().all()
async def get_user_messages_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count(UserMessage.id)))
return result.scalar()
async def update_user_message(
db: AsyncSession,
message_id: int,
message_text: Optional[str] = None,
is_active: Optional[bool] = None,
sort_order: Optional[int] = None
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
if message_text is not None:
message.message_text = message_text
if is_active is not None:
message.is_active = is_active
if sort_order is not None:
message.sort_order = sort_order
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
logger.info(f"📝 Обновлено сообщение ID {message_id}")
return message
async def toggle_user_message_status(
db: AsyncSession,
message_id: int
) -> Optional[UserMessage]:
message = await get_user_message_by_id(db, message_id)
if not message:
return None
message.is_active = not message.is_active
message.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
status_text = "активировано" if message.is_active else "деактивировано"
logger.info(f"🔄 Сообщение ID {message_id} {status_text}")
return message
async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
message = await get_user_message_by_id(db, message_id)
if not message:
return False
await db.delete(message)
await db.commit()
logger.info(f"🗑️ Удалено сообщение ID {message_id}")
return True
async def get_user_messages_stats(db: AsyncSession) -> dict:
total_result = await db.execute(select(func.count(UserMessage.id)))
total_messages = total_result.scalar()
active_result = await db.execute(
select(func.count(UserMessage.id)).where(UserMessage.is_active == True)
)
active_messages = active_result.scalar()
return {
"total_messages": total_messages,
"active_messages": active_messages,
"inactive_messages": total_messages - active_messages
}
+109
View File
@@ -0,0 +1,109 @@
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)
.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 set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -> bool:
try:
await db.execute(
update(WelcomeText).values(is_active=False)
)
new_welcome_text = WelcomeText(
text_content=text_content,
is_active=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 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:
welcome_text = await get_current_welcome_text_or_default()
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 не указан)'
}
+119 -1
View File
@@ -7,7 +7,7 @@ from sqlalchemy import (
ForeignKey, Float, JSON, BigInteger
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.sql import func
@@ -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"
@@ -125,6 +175,7 @@ class User(Base):
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
@property
def balance_rubles(self) -> float:
@@ -333,6 +384,33 @@ class Transaction(Base):
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
class SubscriptionConversion(Base):
__tablename__ = "subscription_conversions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
converted_at = Column(DateTime, default=func.now())
trial_duration_days = Column(Integer, nullable=True)
payment_method = Column(String(50), nullable=True)
first_payment_amount_kopeks = Column(Integer, nullable=True)
first_paid_period_days = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="subscription_conversions")
@property
def first_payment_amount_rubles(self) -> float:
return (self.first_payment_amount_kopeks or 0) / 100
def __repr__(self):
return f"<SubscriptionConversion(user_id={self.user_id}, converted_at={self.converted_at})>"
class PromoCode(Base):
__tablename__ = "promocodes"
@@ -477,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"
@@ -554,3 +646,29 @@ class SubscriptionServer(Base):
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
class UserMessage(Base):
__tablename__ = "user_messages"
id = Column(Integer, primary_key=True, index=True)
message_text = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="created_messages")
def __repr__(self):
return f"<UserMessage(id={self.id}, active={self.is_active}, text='{self.message_text[:50]}...')>"
class WelcomeText(Base):
__tablename__ = "welcome_texts"
id = Column(Integer, primary_key=True, index=True)
text_content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
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")
+463 -108
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,162 +86,406 @@ 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 create_welcome_texts_table():
table_exists = await check_table_exists('welcome_texts')
if table_exists:
logger.info("Таблица welcome_texts уже существует")
return True
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,
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_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,
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_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,
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_updated ON welcome_texts(updated_at);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица welcome_texts успешно создана")
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("=== МИГРАЦИЯ РЕФЕРАЛЬНОЙ СИСТЕМЫ ===")
try:
async with engine.begin() as conn:
db_type = await get_database_type()
column_exists = await check_column_exists('users', 'has_made_first_topup')
if not column_exists:
logger.info("Добавление колонки has_made_first_topup в таблицу users")
if db_type == 'sqlite':
column_def = 'BOOLEAN DEFAULT 0'
else:
column_def = 'BOOLEAN DEFAULT FALSE'
await conn.execute(text(f"ALTER TABLE users ADD COLUMN has_made_first_topup {column_def}"))
logger.info("Колонка has_made_first_topup успешно добавлена")
logger.info("Обновление существующих пользователей...")
if db_type == 'sqlite':
update_sql = """
UPDATE users
SET has_made_first_topup = 1
WHERE balance_kopeks > 0 OR has_had_paid_subscription = 1
"""
else:
update_sql = """
UPDATE users
SET has_made_first_topup = TRUE
WHERE balance_kopeks > 0 OR has_had_paid_subscription = TRUE
"""
result = await conn.execute(text(update_sql))
updated_count = result.rowcount
logger.info(f"Обновлено {updated_count} пользователей с has_made_first_topup = TRUE")
logger.info("✅ Миграция реферальной системы завершена")
return True
else:
logger.info("Колонка has_made_first_topup уже существует")
return True
except Exception as e:
logger.error(f"Ошибка миграции реферальной системы: {e}")
return False
async def create_subscription_conversions_table():
table_exists = await check_table_exists('subscription_conversions')
if table_exists:
logger.info("Таблица subscription_conversions уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE subscription_conversions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INTEGER NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INTEGER NULL,
first_paid_period_days INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE subscription_conversions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
converted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INTEGER NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INTEGER NULL,
first_paid_period_days INTEGER NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE subscription_conversions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
converted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
trial_duration_days INT NULL,
payment_method VARCHAR(50) NULL,
first_payment_amount_kopeks INT NULL,
first_paid_period_days INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_subscription_conversions_user_id ON subscription_conversions(user_id);
CREATE INDEX idx_subscription_conversions_converted_at ON subscription_conversions(converted_at);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица subscription_conversions успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы subscription_conversions: {e}")
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}")
@@ -321,21 +564,50 @@ async def fix_subscription_duplicates_universal():
raise
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
try:
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 готова")
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()
if conversions_created:
logger.info("✅ Таблица subscription_conversions готова")
else:
logger.warning("⚠️ Проблемы с таблицей subscription_conversions")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
@@ -349,6 +621,7 @@ async def run_universal_migration():
if total_count == unique_count:
logger.info("База данных уже в корректном состоянии")
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
return True
deleted_count = await fix_subscription_duplicates_universal()
@@ -368,8 +641,90 @@ async def run_universal_migration():
return False
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
logger.info("✅ Реферальная система обновлена")
logger.info("✅ CryptoBot таблица готова")
logger.info("✅ Таблица конверсий подписок создана")
logger.info("✅ Дубликаты подписок исправлены")
return True
except Exception as e:
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
return False
return False
async def check_migration_status():
logger.info("=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===")
try:
status = {
"has_made_first_topup_column": False,
"cryptobot_table": False,
"user_messages_table": False,
"welcome_texts_table": False,
"subscription_duplicates": False,
"subscription_conversions_table": False
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
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')
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
) as dups
"""))
duplicates_count = duplicates_check.fetchone()[0]
status["subscription_duplicates"] = (duplicates_count == 0)
check_names = {
"has_made_first_topup_column": "Колонка реферальной системы",
"cryptobot_table": "Таблица CryptoBot payments",
"user_messages_table": "Таблица пользовательских сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_duplicates": "Отсутствие дубликатов подписок"
}
for check_key, check_status in status.items():
check_name = check_names.get(check_key, check_key)
icon = "" if check_status else ""
logger.info(f"{icon} {check_name}: {'OK' if check_status else 'ТРЕБУЕТ ВНИМАНИЯ'}")
all_good = all(status.values())
if all_good:
logger.info("🎉 Все миграции выполнены успешно!")
try:
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} конверсий, {welcome_count} приветственных текстов")
except Exception as stats_error:
logger.debug(f"Не удалось получить дополнительную статистику: {stats_error}")
else:
logger.warning("⚠️ Некоторые миграции требуют внимания")
missing_migrations = [check_names[k] for k, v in status.items() if not v]
logger.warning(f"Требуют выполнения: {', '.join(missing_migrations)}")
return status
except Exception as e:
logger.error(f"Ошибка проверки статуса миграций: {e}")
return None
+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:
+22 -10
View File
@@ -13,6 +13,14 @@ class TelegramStarsService:
def __init__(self, bot: Bot):
self.bot = bot
@staticmethod
def calculate_stars_from_rubles(rubles: float) -> int:
return settings.rubles_to_stars(rubles)
@staticmethod
def calculate_rubles_from_stars(stars: int) -> float:
return settings.stars_to_rubles(stars)
async def create_invoice(
self,
chat_id: int,
@@ -23,7 +31,8 @@ class TelegramStarsService:
start_parameter: Optional[str] = None
) -> Optional[str]:
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
invoice_link = await self.bot.create_invoice_link(
title=title,
@@ -35,7 +44,10 @@ class TelegramStarsService:
start_parameter=start_parameter
)
logger.info(f"Создан Stars invoice на {stars_amount} звезд для {chat_id}")
logger.info(
f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽) "
f"для {chat_id}, курс: {int(settings.get_stars_rate())}₽/⭐"
)
return invoice_link
except Exception as e:
@@ -52,7 +64,8 @@ class TelegramStarsService:
keyboard: Optional[InlineKeyboardMarkup] = None
) -> Optional[Dict[str, Any]]:
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = self.calculate_stars_from_rubles(amount_rubles)
message = await self.bot.send_invoice(
chat_id=chat_id,
@@ -65,10 +78,14 @@ class TelegramStarsService:
reply_markup=keyboard
)
logger.info(f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд")
logger.info(
f"Отправлен Stars invoice {message.message_id} на {stars_amount} звезд "
f"(~{int(amount_rubles)}₽), курс: {int(settings.get_stars_rate())}₽/⭐"
)
return {
"message_id": message.message_id,
"stars_amount": stars_amount,
"rubles_amount": amount_rubles,
"payload": payload
}
@@ -88,13 +105,8 @@ class TelegramStarsService:
ok=ok,
error_message=error_message
)
logger.info(f"Ответ на pre_checkout_query: ok={ok}")
return True
except Exception as e:
logger.error(f"Ошибка ответа на pre_checkout_query: {e}")
return False
def calculate_stars_amount(self, rubles: float) -> int:
return max(1, int(rubles))
def calculate_rubles_from_stars(self, stars: int) -> float:
return float(stars)
+11 -12
View File
@@ -10,10 +10,9 @@ logger = logging.getLogger(__name__)
class TributeService:
def __init__(self):
self.api_key = settings.TRIBUTE_API_KEY
self.webhook_secret = settings.TRIBUTE_WEBHOOK_SECRET
self.donate_link = settings.TRIBUTE_DONATE_LINK
async def create_payment_link(
@@ -38,27 +37,27 @@ class TributeService:
return None
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
if not self.webhook_secret:
logger.warning("Webhook secret не настроен, пропускаем проверку")
return True
if not self.api_key:
logger.warning("API key не настроен, пропускаем проверку")
return True
try:
expected_signature = hmac.new(
self.webhook_secret.encode(),
self.api_key.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
is_valid = hmac.compare_digest(signature, expected_signature)
if is_valid:
logger.info("✅ Подпись Tribute webhook проверена успешно")
else:
logger.error("❌ Неверная подпись Tribute webhook")
return is_valid
except Exception as e:
logger.error(f"Ошибка проверки подписи webhook: {e}")
return False
+110 -25
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,65 +89,138 @@ class WebhookServer:
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Tribute-Signature',
'Access-Control-Allow-Headers': 'Content-Type, trbt-signature, Crypto-Pay-API-Signature',
}
)
async def _tribute_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"📥 Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"📋 Headers: {dict(request.headers)}")
logger.info(f"Получен Tribute webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("⚠️ Получен пустой webhook от Tribute")
logger.warning("Получен пустой webhook от Tribute")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"📄 Payload: {payload}")
logger.info(f"Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"📊 Распарсенные данные: {webhook_data}")
logger.info(f"Распарсенные данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON: {e}")
logger.error(f"Ошибка парсинга JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('X-Tribute-Signature')
logger.info(f"🔐 Signature: {signature}")
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
signature = request.headers.get('trbt-signature')
logger.info(f"Signature: {signature}")
if not signature:
logger.error("Отсутствует заголовок подписи Tribute webhook")
return web.json_response(
{"status": "error", "reason": "missing_signature"},
status=401
)
if settings.TRIBUTE_API_KEY:
from app.external.tribute import TributeService as TributeAPI
tribute_api = TributeAPI()
if not tribute_api.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись Tribute webhook")
logger.error("Неверная подпись Tribute webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
result = await self.tribute_service.process_webhook(payload, signature)
result = await self.tribute_service.process_webhook(payload)
if result:
logger.info(f"Tribute webhook обработан успешно: {result}")
logger.info(f"Tribute webhook обработан успешно: {result}")
return web.json_response({"status": "ok", "result": result}, status=200)
else:
logger.error("Ошибка обработки Tribute webhook")
logger.error("Ошибка обработки Tribute webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
logger.error(f"Критическая ошибка обработки Tribute webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
)
async def _cryptobot_webhook_handler(self, request: web.Request) -> web.Response:
try:
logger.info(f"Получен CryptoBot webhook: {request.method} {request.path}")
logger.info(f"Headers: {dict(request.headers)}")
raw_body = await request.read()
if not raw_body:
logger.warning("Получен пустой CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "empty_body"},
status=400
)
payload = raw_body.decode('utf-8')
logger.info(f"CryptoBot Payload: {payload}")
try:
webhook_data = json.loads(payload)
logger.info(f"CryptoBot данные: {webhook_data}")
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга CryptoBot JSON: {e}")
return web.json_response(
{"status": "error", "reason": "invalid_json"},
status=400
)
signature = request.headers.get('Crypto-Pay-API-Signature')
logger.info(f"CryptoBot Signature: {signature}")
if signature and settings.CRYPTOBOT_WEBHOOK_SECRET:
from app.external.cryptobot import CryptoBotService
cryptobot_service = CryptoBotService()
if not cryptobot_service.verify_webhook_signature(payload, signature):
logger.error("Неверная подпись CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "invalid_signature"},
status=401
)
from app.services.payment_service import PaymentService
from app.database.database import AsyncSessionLocal
payment_service = PaymentService(self.bot)
async with AsyncSessionLocal() as db:
result = await payment_service.process_cryptobot_webhook(db, webhook_data)
if result:
logger.info(f"CryptoBot webhook обработан успешно")
return web.json_response({"status": "ok"}, status=200)
else:
logger.error("Ошибка обработки CryptoBot webhook")
return web.json_response(
{"status": "error", "reason": "processing_failed"},
status=400
)
except Exception as e:
logger.error(f"Критическая ошибка обработки CryptoBot webhook: {e}", exc_info=True)
return web.json_response(
{"status": "error", "reason": "internal_error", "message": str(e)},
status=500
@@ -147,8 +230,10 @@ class WebhookServer:
return web.json_response({
"status": "ok",
"service": "tribute-webhooks",
"service": "payment-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"port": settings.TRIBUTE_WEBHOOK_PORT,
"path": settings.TRIBUTE_WEBHOOK_PATH
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
})
+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
)
+203 -4
View File
@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
class MaintenanceStates(StatesGroup):
waiting_for_reason = State()
waiting_for_notification_message = State()
@admin_required
@@ -30,6 +31,14 @@ async def show_maintenance_panel(
status_info = maintenance_service.get_status_info()
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
panel_status = await rw_service.get_panel_status_summary()
except Exception as e:
logger.error(f"Ошибка получения статуса панели: {e}")
panel_status = {"description": "❓ Не удалось проверить", "has_issues": True}
status_emoji = "🔧" if status_info["is_active"] else ""
status_text = "Включен" if status_info["is_active"] else "Выключен"
@@ -55,14 +64,19 @@ async def show_maintenance_panel(
if status_info["consecutive_failures"] > 0:
failures_info = f"\n⚠️ <b>Неудачных проверок подряд:</b> {status_info['consecutive_failures']}"
panel_info = f"\n🌐 <b>Панель Remnawave:</b> {panel_status['description']}"
if panel_status.get("response_time"):
panel_info += f"\n⚡ <b>Время отклика:</b> {panel_status['response_time']}с"
message_text = f"""
🔧 <b>Режим технических работ</b>
🔧 <b>Управление техническими работами</b>
{status_emoji} <b>Статус:</b> {status_text}
{api_emoji} <b>API RemnaWave:</b> {api_text}
{status_emoji} <b>Режим техработ:</b> {status_text}
{api_emoji} <b>API Remnawave:</b> {api_text}
{monitoring_emoji} <b>Мониторинг:</b> {monitoring_text}
⏱️ <b>Интервал проверки:</b> {status_info['check_interval']}с
🤖 <b>Автовключение:</b> {'Включено' if status_info['auto_enable_configured'] else 'Отключено'}
{panel_info}
{enabled_info}
{last_check_info}
{failures_info}
@@ -72,7 +86,12 @@ async def show_maintenance_panel(
await callback.message.edit_text(
message_text,
reply_markup=get_maintenance_keyboard(db_user.language, status_info["is_active"], status_info["monitoring_active"])
reply_markup=get_maintenance_keyboard(
db_user.language,
status_info["is_active"],
status_info["monitoring_active"],
panel_status.get("has_issues", False)
)
)
await callback.answer()
@@ -186,6 +205,166 @@ async def force_api_check(
await show_maintenance_panel(callback, db_user, db, None)
@admin_required
@error_handler
async def check_panel_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
await callback.answer("Проверка статуса панели...", show_alert=False)
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
status_data = await rw_service.check_panel_health()
status_text = {
"online": "🟢 Панель работает нормально",
"offline": "🔴 Панель недоступна",
"degraded": "🟡 Панель работает со сбоями"
}.get(status_data["status"], "❓ Статус неизвестен")
message_parts = [
f"🌐 <b>Статус панели Remnawave</b>\n",
f"{status_text}",
f"⚡ Время отклика: {status_data.get('response_time', 0)}с",
f"👥 Пользователей онлайн: {status_data.get('users_online', 0)}",
f"🖥️ Нод онлайн: {status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)}"
]
if status_data.get("api_error"):
message_parts.append(f"❌ Ошибка: {status_data['api_error'][:100]}")
message = "\n".join(message_parts)
await callback.message.answer(message, parse_mode="HTML")
except Exception as e:
await callback.message.answer(f"❌ Ошибка проверки статуса: {str(e)}")
@admin_required
@error_handler
async def send_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
await state.set_state(MaintenanceStates.waiting_for_notification_message)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="🟢 Онлайн", callback_data="manual_notify_online"),
types.InlineKeyboardButton(text="🔴 Офлайн", callback_data="manual_notify_offline")
],
[
types.InlineKeyboardButton(text="🟡 Проблемы", callback_data="manual_notify_degraded"),
types.InlineKeyboardButton(text="🔧 Обслуживание", callback_data="manual_notify_maintenance")
],
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
await callback.message.edit_text(
"📢 <b>Ручная отправка уведомления</b>\n\nВыберите статус для уведомления:",
reply_markup=keyboard
)
@admin_required
@error_handler
async def handle_manual_notification(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
status_map = {
"manual_notify_online": "online",
"manual_notify_offline": "offline",
"manual_notify_degraded": "degraded",
"manual_notify_maintenance": "maintenance"
}
status = status_map.get(callback.data)
if not status:
await callback.answer("Неизвестный статус")
return
await state.update_data(notification_status=status)
status_names = {
"online": "🟢 Онлайн",
"offline": "🔴 Офлайн",
"degraded": "🟡 Проблемы",
"maintenance": "🔧 Обслуживание"
}
await callback.message.edit_text(
f"📢 <b>Отправка уведомления: {status_names[status]}</b>\n\n"
f"Введите сообщение для уведомления или отправьте /skip для отправки без дополнительного текста:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def process_notification_message(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
current_state = await state.get_state()
if current_state != MaintenanceStates.waiting_for_notification_message:
return
data = await state.get_data()
status = data.get("notification_status")
if not status:
await message.answer("Ошибка: статус не выбран")
await state.clear()
return
notification_message = ""
if message.text and message.text != "/skip":
notification_message = message.text[:300]
try:
from app.services.remnawave_service import RemnaWaveService
rw_service = RemnaWaveService()
success = await rw_service.send_manual_status_notification(
message.bot,
status,
notification_message
)
if success:
await message.answer("✅ Уведомление отправлено")
else:
await message.answer("❌ Ошибка отправки уведомления")
except Exception as e:
logger.error(f"Ошибка отправки ручного уведомления: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
await state.clear()
await message.answer(
"Вернуться к панели техработ:",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔧 Панель техработ", callback_data="maintenance_panel")]
])
)
@admin_required
@error_handler
async def back_to_admin_panel(
@@ -224,6 +403,21 @@ def register_handlers(dp: Dispatcher):
F.data == "maintenance_check_api"
)
dp.callback_query.register(
check_panel_status,
F.data == "maintenance_check_panel"
)
dp.callback_query.register(
send_manual_notification,
F.data == "maintenance_manual_notify"
)
dp.callback_query.register(
handle_manual_notification,
F.data.startswith("manual_notify_")
)
dp.callback_query.register(
back_to_admin_panel,
F.data == "admin_panel"
@@ -233,3 +427,8 @@ def register_handlers(dp: Dispatcher):
process_maintenance_reason,
MaintenanceStates.waiting_for_reason
)
dp.message.register(
process_notification_message,
MaintenanceStates.waiting_for_notification_message
)
+34
View File
@@ -969,6 +969,38 @@ async def show_promocode_stats(
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def show_general_promocode_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
total_codes = await get_promocodes_count(db)
active_codes = await get_promocodes_count(db, is_active=True)
text = f"""
📊 <b>Общая статистика промокодов</b>
📈 <b>Основные показатели:</b>
- Всего промокодов: {total_codes}
- Активных: {active_codes}
- Неактивных: {total_codes - active_codes}
Для детальной статистики выберите конкретный промокод из списка.
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promo_list")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_promocodes")
]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promocodes_menu, F.data == "admin_promocodes")
@@ -984,6 +1016,7 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_edit_promocode_amount, F.data.startswith("promo_edit_amount_"))
dp.callback_query.register(start_edit_promocode_days, F.data.startswith("promo_edit_days_"))
dp.callback_query.register(start_edit_promocode_uses, F.data.startswith("promo_edit_uses_"))
dp.callback_query.register(show_general_promocode_stats, F.data == "admin_promo_general_stats")
dp.callback_query.register(
show_promocode_edit_menu,
@@ -997,3 +1030,4 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
+174 -30
View File
@@ -1,6 +1,7 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
import datetime
from app.config import settings
from app.database.models import User
@@ -19,54 +20,197 @@ async def show_referral_statistics(
db_user: User,
db: AsyncSession
):
stats = await get_referral_statistics(db)
avg_per_referrer = 0
if stats['active_referrers'] > 0:
avg_per_referrer = stats['total_paid_kopeks'] / stats['active_referrers']
text = f"""
try:
stats = await get_referral_statistics(db)
avg_per_referrer = 0
if stats.get('active_referrers', 0) > 0:
avg_per_referrer = stats.get('total_paid_kopeks', 0) / stats['active_referrers']
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
<b>Общие показатели:</b>
- Пользователей с рефералами: {stats['users_with_referrals']}
- Активных рефереров: {stats['active_referrers']}
- Выплачено всего: {settings.format_price(stats['total_paid_kopeks'])}
- Пользователей с рефералами: {stats.get('users_with_referrals', 0)}
- Активных рефереров: {stats.get('active_referrers', 0)}
- Выплачено всего: {settings.format_price(stats.get('total_paid_kopeks', 0))}
<b>За период:</b>
- Сегодня: {settings.format_price(stats['today_earnings_kopeks'])}
- За неделю: {settings.format_price(stats['week_earnings_kopeks'])}
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
- Сегодня: {settings.format_price(stats.get('today_earnings_kopeks', 0))}
- За неделю: {settings.format_price(stats.get('week_earnings_kopeks', 0))}
- За месяц: {settings.format_price(stats.get('month_earnings_kopeks', 0))}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
<b>Топ-5 рефереров:</b>
"""
for i, referrer in enumerate(stats['top_referrers'][:5], 1):
text += f"{i}. ID {referrer['user_id']}: {settings.format_price(referrer['total_earned_kopeks'])} ({referrer['referrals_count']} реф.)\n"
if not stats['top_referrers']:
text += "Нет данных\n"
text += f"""
top_referrers = stats.get('top_referrers', [])
if top_referrers:
for i, referrer in enumerate(top_referrers[:5], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
user_id = referrer.get('user_id', 'N/A')
if count > 0:
text += f"{i}. ID {user_id}: {settings.format_price(earned)} ({count} реф.)\n"
else:
logger.warning(f"Реферер {user_id} имеет {count} рефералов, но есть в топе")
else:
text += "Нет данных\n"
text += f"""
<b>Настройки:</b>
- Бонус за регистрацию: {settings.format_price(settings.REFERRAL_REGISTRATION_REWARD)}
<b>Настройки реферальной системы:</b>
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия: {settings.REFERRAL_COMMISSION_PERCENT}%
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
- Уведомления: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
<i>🕐 Обновлено: {current_time}</i>
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="👥 Топ рефереров", callback_data="admin_referrals_top")],
[types.InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_referrals_settings")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("Обновлено")
except Exception as edit_error:
if "message is not modified" in str(edit_error):
await callback.answer("Данные актуальны")
else:
logger.error(f"Ошибка редактирования сообщения: {edit_error}")
await callback.answer("Ошибка обновления")
except Exception as e:
logger.error(f"Ошибка в show_referral_statistics: {e}", exc_info=True)
current_time = datetime.datetime.now().strftime("%H:%M:%S")
text = f"""
🤝 <b>Реферальная статистика</b>
❌ <b>Ошибка загрузки данных</b>
<b>Текущие настройки:</b>
- Минимальное пополнение: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
- Бонус за первое пополнение: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
- Бонус пригласившему: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
- Бонус новому пользователю: {settings.format_price(settings.REFERRED_USER_REWARD)}
- Комиссия с покупок: {settings.REFERRAL_COMMISSION_PERCENT}%
<i>🕐 Время: {current_time}</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except:
pass
await callback.answer("Произошла ошибка при загрузке статистики")
@admin_required
@error_handler
async def show_top_referrers(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
stats = await get_referral_statistics(db)
top_referrers = stats.get('top_referrers', [])
text = "🏆 <b>Топ рефереров</b>\n\n"
if top_referrers:
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
emoji = ""
if i == 1:
emoji = "🥇 "
elif i == 2:
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
else:
text += "Нет данных о рефererах\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
except Exception as e:
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@admin_required
@error_handler
async def show_referral_settings(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
text = f"""
⚙️ <b>Настройки реферальной системы</b>
<b>Бонусы и награды:</b>
• Минимальная сумма пополнения для участия: {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}
• Бонус за первое пополнение реферала: {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}
• Бонус пригласившему за первое пополнение: {settings.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}
• Бонус новому пользователю при регистрации: {settings.format_price(settings.REFERRED_USER_REWARD)}
<b>Комиссионные:</b>
• Процент с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%
<b>Уведомления:</b>
• Статус: {'✅ Включены' if settings.REFERRAL_NOTIFICATIONS_ENABLED else '❌ Отключены'}
• Попытки отправки: {getattr(settings, 'REFERRAL_NOTIFICATION_RETRY_ATTEMPTS', 3)}
<i>💡 Для изменения настроек отредактируйте файл .env и перезапустите бота</i>
"""
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
+14 -14
View File
@@ -34,7 +34,7 @@ async def show_remnawave_menu(
status_emoji = "" if connection_test["status"] == "connected" else ""
text = f"""
🖥️ <b>Управление RemnaWave</b>
🖥️ <b>Управление Remnawave</b>
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
@@ -116,7 +116,7 @@ async def show_system_stats(
return f" (🔺 {difference_str})"
text = f"""
📊 <b>Детальная статистика RemnaWave</b>
📊 <b>Детальная статистика Remnawave</b>
🖥️ <b>Сервер:</b>
- CPU: {server_info.get('cpu_cores', 0)} ядер ({server_info.get('cpu_physical_cores', 0)} физ.)
@@ -238,7 +238,7 @@ async def show_traffic_stats(
return f" 🔺 {diff_str}"
text = f"""
📊 <b>Статистика трафика RemnaWave</b>
📊 <b>Статистика трафика Remnawave</b>
⚡ <b>Реалтайм данные:</b>
- Скачивание: {format_bytes(total_realtime_download)}
@@ -1247,7 +1247,7 @@ async def finish_squad_creation(
f"Сквад готов к использованию!",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📋 Список сквадов", callback_data="admin_rw_squads")],
[types.InlineKeyboardButton(text="⬅️ К панели RemnaWave", callback_data="admin_remnawave")]
[types.InlineKeyboardButton(text="⬅️ К панели Remnawave", callback_data="admin_remnawave")]
])
)
await callback.answer("✅ Сквад создан!")
@@ -1320,7 +1320,7 @@ async def show_sync_options(
db: AsyncSession
):
text = """
🔄 <b>Синхронизация с RemnaWave</b>
🔄 <b>Синхронизация с Remnawave</b>
🔄 <b>Полная синхронизация выполняет:</b>
• Создание новых пользователей из панели в боте
@@ -1360,7 +1360,7 @@ async def sync_all_users(
🔄 <b>Выполняется полная синхронизация...</b>
📋 Этапы:
• Загрузка ВСЕХ пользователей из панели RemnaWave
• Загрузка ВСЕХ пользователей из панели Remnawave
• Создание новых пользователей в боте
• Обновление существующих пользователей
• Деактивация подписок отсутствующих пользователей
@@ -1401,7 +1401,7 @@ async def sync_all_users(
🗑️ <b>Деактивированные подписки:</b>
Деактивированы подписки пользователей, которые
отсутствуют в панели RemnaWave.
отсутствуют в панели Remnawave.
💰 Балансы пользователей сохранены.
"""
@@ -1546,7 +1546,7 @@ async def validate_subscriptions(
if stats['fixed'] > 0:
text += "\n✅ <b>Исправленные проблемы:</b>\n"
text += "• Статусы просроченных подписок\n"
text += "• Отсутствующие данные RemnaWave\n"
text += "• Отсутствующие данные Remnawave\n"
text += "• Некорректные лимиты трафика\n"
text += "• Настройки устройств\n"
@@ -1600,7 +1600,7 @@ async def cleanup_subscriptions(
if stats['deactivated'] > 0:
text += f"\n🗑️ <b>Деактивированные подписки:</b>\n"
text += f"Отключены подписки пользователей, которые\n"
text += f"отсутствуют в панели RemnaWave.\n"
text += f"отсутствуют в панели Remnawave.\n"
else:
text += f"\n✅ Все подписки актуальны!\nНеактуальных подписок не найдено."
@@ -1664,8 +1664,8 @@ async def force_cleanup_all_orphaned_users(
• Удалены использования промокодов
• Сброшены балансы к нулю
• Удалены подключенные серверы
• Сброшены HWID устройства в RemnaWave
• Очищены RemnaWave UUID
• Сброшены HWID устройства в Remnawave
• Очищены Remnawave UUID
"""
else:
text += f"\n✅ Неактуальных подписок не найдено!\nВсе пользователи синхронизированы с панелью."
@@ -1703,8 +1703,8 @@ async def confirm_force_cleanup(
• ВСЕ использования промокодов
• ВСЕ подключенные серверы подписок
• ВСЕ балансы (сброс к нулю)
• ВСЕ HWID устройства в RemnaWave
• ВСЕ RemnaWave UUID и ссылки
• ВСЕ HWID устройства в Remnawave
• ВСЕ Remnawave UUID и ссылки
⚡ <b>Это действие НЕОБРАТИМО!</b>
@@ -1813,7 +1813,7 @@ async def sync_users(
if sync_type == "all_users" and 'deleted' in stats and stats['deleted'] > 0:
text += f"\n🗑️ <b>Удаленные подписки:</b>\n"
text += f"Деактивированы подписки пользователей,\n"
text += f"которые отсутствуют в панели RemnaWave."
text += f"которые отсутствуют в панели Remnawave."
text += f"\n\n💡 <b>Рекомендации:</b>\n"
if sync_type == "all_users":
+12 -19
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'])}
Выберите действие:
"""
@@ -51,9 +51,6 @@ async def show_servers_menu(
types.InlineKeyboardButton(text="📊 Синхронизировать счетчики", callback_data="admin_servers_sync_counts"),
types.InlineKeyboardButton(text="📈 Подробная статистика", callback_data="admin_servers_stats")
],
[
types.InlineKeyboardButton(text=" Добавить сервер", callback_data="admin_servers_add")
],
[
types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")
]
@@ -86,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}"
@@ -130,10 +127,6 @@ async def show_servers_list(
keyboard.append(nav_row)
keyboard.extend([
[
types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_servers_list"),
types.InlineKeyboardButton(text=" Добавить", callback_data="admin_servers_add")
],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
@@ -154,7 +147,7 @@ async def sync_servers_with_remnawave(
):
await callback.message.edit_text(
"🔄 Синхронизация с RemnaWave...\n\nПодождите, это может занять время.",
"🔄 Синхронизация с Remnawave...\n\nПодождите, это может занять время.",
reply_markup=None
)
@@ -164,7 +157,7 @@ async def sync_servers_with_remnawave(
if not squads:
await callback.message.edit_text(
"❌ Не удалось получить данные о сквадах из RemnaWave.\n\nПроверьте настройки API.",
"❌ Не удалось получить данные о сквадах из Remnawave.\n\nПроверьте настройки API.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
@@ -229,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>
@@ -311,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>
@@ -385,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"
@@ -431,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=[
@@ -620,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>
"""
@@ -629,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:
@@ -959,4 +952,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(delete_server_confirm, F.data.startswith("admin_server_delete_") & ~F.data.contains("confirm"))
dp.callback_query.register(delete_server_execute, F.data.startswith("admin_server_delete_confirm_"))
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
dp.callback_query.register(handle_servers_pagination, F.data.startswith("admin_servers_list_page_"))
+91 -45
View File
@@ -50,6 +50,8 @@ async def show_users_statistics(
total_users = stats['total_users']
active_rate = format_percentage(stats['active_users'] / total_users * 100 if total_users > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
👥 <b>Статистика пользователей</b>
@@ -66,16 +68,27 @@ async def show_users_statistics(
<b>Активность:</b>
- Коэффициент активности: {active_rate}
- Рост за месяц: +{stats['new_month']} ({format_percentage(stats['new_month'] / total_users * 100 if total_users > 0 else 0)})
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_users")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики пользователей: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
return
await callback.answer("✅ Статистика обновлена")
@admin_required
@@ -89,6 +102,7 @@ async def show_subscriptions_statistics(
total_subs = stats['total_subscriptions']
conversion_rate = format_percentage(stats['paid_subscriptions'] / total_subs * 100 if total_subs > 0 else 0)
current_time = format_datetime(datetime.utcnow())
text = f"""
📱 <b>Статистика подписок</b>
@@ -107,16 +121,24 @@ async def show_subscriptions_statistics(
- Сегодня: {stats['purchased_today']}
- За неделю: {stats['purchased_week']}
- За месяц: {stats['purchased_month']}
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_subs")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики подписок: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -131,6 +153,7 @@ async def show_revenue_statistics(
month_stats = await get_transactions_statistics(db, month_start, now)
all_time_stats = await get_transactions_statistics(db)
current_time = format_datetime(datetime.utcnow())
text = f"""
💰 <b>Статистика доходов</b>
@@ -156,15 +179,23 @@ async def show_revenue_statistics(
if method and data['count'] > 0:
text += f"{method}: {data['count']} ({settings.format_price(data['amount'])})\n"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
# [types.InlineKeyboardButton(text="📈 Период", callback_data="admin_revenue_period")],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_revenue")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления статистики доходов: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -175,6 +206,7 @@ async def show_referral_statistics(
db: AsyncSession
):
stats = await get_referral_statistics(db)
current_time = format_datetime(datetime.utcnow())
avg_per_referrer = 0
if stats['active_referrers'] > 0:
@@ -194,7 +226,7 @@ async def show_referral_statistics(
- За месяц: {settings.format_price(stats['month_earnings_kopeks'])}
<b>Средние показатели:</b>
- На одного реферера: {settings.format_price(int(avg_per_referrer))}
- На одного рефререра: {settings.format_price(int(avg_per_referrer))}
<b>Топ рефереры:</b>
"""
@@ -208,14 +240,22 @@ async def show_referral_statistics(
else:
text += "Пока нет активных рефереров"
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
text += f"\n<b>Обновлено:</b> {current_time}"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_referrals")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления реферальной статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@@ -232,6 +272,7 @@ async def show_summary_statistics(
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
revenue_stats = await get_transactions_statistics(db, month_start, now)
current_time = format_datetime(datetime.utcnow())
conversion_rate = 0
if user_stats['total_users'] > 0:
@@ -263,18 +304,23 @@ async def show_summary_statistics(
- Пользователи: +{user_stats['new_month']} за месяц
- Продажи: +{sub_stats['purchased_month']} за месяц
<b>Обновлено:</b> {format_datetime(datetime.utcnow())}
<b>Обновлено:</b> {current_time}
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
)
await callback.answer()
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_stats_summary")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_statistics")]
])
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer("✅ Статистика обновлена")
except Exception as e:
if "message is not modified" in str(e):
await callback.answer("📊 Данные актуальны", show_alert=False)
else:
logger.error(f"Ошибка обновления общей статистики: {e}")
await callback.answer("❌ Ошибка обновления данных", show_alert=True)
@admin_required
@error_handler
@@ -347,4 +393,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_revenue_by_period,
F.data == f"period_{period}"
)
)
+5 -5
View File
@@ -260,11 +260,11 @@ async def show_subscriptions_stats(
"""
keyboard = [
[
types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
# [
# types.InlineKeyboardButton(text="📊 Экспорт данных", callback_data="admin_subs_export"),
# types.InlineKeyboardButton(text="📈 Графики", callback_data="admin_subs_charts")
# ],
# [types.InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_subs_stats")],
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_subscriptions")]
]
+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"
)
+584
View File
@@ -0,0 +1,584 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.filters import StateFilter
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.user_message import (
create_user_message, get_all_user_messages, get_user_message_by_id,
update_user_message, delete_user_message, toggle_user_message_status,
get_user_messages_stats
)
from app.database.models import User
from app.keyboards.admin import get_admin_main_keyboard
from app.utils.decorators import admin_required, error_handler
from app.localization.texts import get_texts
logger = logging.getLogger(__name__)
class UserMessageStates(StatesGroup):
waiting_for_message_text = State()
waiting_for_edit_text = State()
def get_user_messages_keyboard(language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="📝 Добавить сообщение",
callback_data="add_user_message"
)
],
[
InlineKeyboardButton(
text="📋 Список сообщений",
callback_data="list_user_messages:0"
)
],
[
InlineKeyboardButton(
text="📊 Статистика",
callback_data="user_messages_stats"
)
],
[
InlineKeyboardButton(
text="🔙 Назад в админку",
callback_data="admin_panel"
)
]
])
def get_message_actions_keyboard(message_id: int, is_active: bool, language: str = "ru"):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
status_text = "🔴 Деактивировать" if is_active else "🟢 Активировать"
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="✏️ Редактировать",
callback_data=f"edit_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text=status_text,
callback_data=f"toggle_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🗑️ Удалить",
callback_data=f"delete_user_message:{message_id}"
)
],
[
InlineKeyboardButton(
text="🔙 К списку",
callback_data="list_user_messages:0"
)
]
])
@admin_required
@error_handler
async def show_user_messages_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
text = (
"📢 <b>Управление сообщениями в главном меню</b>\n\n"
"Здесь вы можете добавлять сообщения, которые будут показываться пользователям "
"в главном меню между информацией о подписке и кнопками действий.\n\n"
"• Сообщения поддерживают HTML теги\n"
"• Можно создать несколько сообщений\n"
"• Активные сообщения показываются случайно\n"
"• Неактивные сообщения не показываются"
)
await callback.message.edit_text(
text,
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def add_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
from app.utils.validators import get_html_help_text
await callback.message.edit_text(
f"📝 <b>Добавление нового сообщения</b>\n\n"
f"Введите текст сообщения, которое будет показываться в главном меню.\n\n"
f"{get_html_help_text()}\n\n"
f"Отправьте /cancel для отмены.",
parse_mode="HTML"
)
await state.set_state(UserMessageStates.waiting_for_message_text)
await callback.answer()
@admin_required
@error_handler
async def process_new_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Добавление сообщения отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
message_text = message.text.strip()
if len(message_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
from app.utils.validators import validate_html_tags, get_html_help_text
is_valid, error_msg = validate_html_tags(message_text)
if not is_valid:
await message.answer(
f"❌ Ошибка в HTML разметке: {error_msg}\n\n"
f"Исправьте ошибку и попробуйте еще раз, или отправьте /cancel для отмены.",
parse_mode=None
)
return
try:
new_message = await create_user_message(
db=db,
message_text=message_text,
created_by=db_user.id,
is_active=True
)
await state.clear()
await message.answer(
f"✅ <b>Сообщение добавлено!</b>\n\n"
f"<b>ID:</b> {new_message.id}\n"
f"<b>Статус:</b> {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n"
f"<b>Создано:</b> {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Предварительный просмотр:</b>\n"
f"<blockquote>{message_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка создания сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при создании сообщения. Попробуйте еще раз.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
@admin_required
@error_handler
async def list_user_messages(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
page = 0
if ":" in callback.data:
try:
page = int(callback.data.split(":")[1])
except (ValueError, IndexError):
page = 0
limit = 5
offset = page * limit
messages = await get_all_user_messages(db, offset=offset, limit=limit)
if not messages:
await callback.message.edit_text(
"📋 <b>Список сообщений</b>\n\n"
"Сообщений пока нет. Добавьте первое сообщение!",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
return
text = "📋 <b>Список сообщений</b>\n\n"
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
preview = msg.message_text[:100] + "..." if len(msg.message_text) > 100 else msg.message_text
preview = preview.replace('<', '&lt;').replace('>', '&gt;')
text += (
f"{status_emoji} <b>ID {msg.id}</b>\n"
f"{preview}\n"
f"📅 {msg.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = []
for msg in messages:
status_emoji = "🟢" if msg.is_active else "🔴"
keyboard.append([
InlineKeyboardButton(
text=f"{status_emoji} ID {msg.id}",
callback_data=f"view_user_message:{msg.id}"
)
])
nav_buttons = []
if page > 0:
nav_buttons.append(
InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"list_user_messages:{page-1}"
)
)
nav_buttons.append(
InlineKeyboardButton(
text=" Добавить",
callback_data="add_user_message"
)
)
if len(messages) == limit:
nav_buttons.append(
InlineKeyboardButton(
text="Вперед ➡️",
callback_data=f"list_user_messages:{page+1}"
)
)
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def view_user_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно"
text = (
f"📋 <b>Сообщение ID {message.id}</b>\n\n"
f"<b>Статус:</b> {status_text}\n"
f"<b>Создано:</b> {message.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"<b>Обновлено:</b> {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Содержимое:</b>\n"
f"<blockquote>{message.message_text}</blockquote>"
)
await callback.message.edit_text(
text,
reply_markup=get_message_actions_keyboard(
message_id, message.is_active, db_user.language
),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def toggle_message_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await toggle_user_message_status(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
status_text = "активировано" if message.is_active else "деактивировано"
await callback.answer(f"✅ Сообщение {status_text}")
await view_user_message(callback, db_user, db)
@admin_required
@error_handler
async def delete_message_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтвердить удаление сообщения"""
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
success = await delete_user_message(db, message_id)
if success:
await callback.answer("✅ Сообщение удалено")
await list_user_messages(
types.CallbackQuery(
id=callback.id,
from_user=callback.from_user,
chat_instance=callback.chat_instance,
data="list_user_messages:0",
message=callback.message
),
db_user,
db
)
else:
await callback.answer("❌ Ошибка удаления сообщения", show_alert=True)
@admin_required
@error_handler
async def show_messages_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
stats = await get_user_messages_stats(db)
text = (
"📊 <b>Статистика сообщений</b>\n\n"
f"📝 Всего сообщений: <b>{stats['total_messages']}</b>\n"
f"🟢 Активных: <b>{stats['active_messages']}</b>\n"
f"🔴 Неактивных: <b>{stats['inactive_messages']}</b>\n\n"
"Активные сообщения показываются пользователям случайным образом "
"в главном меню между информацией о подписке и кнопками действий."
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔙 Назад",
callback_data="user_messages_panel"
)
]
])
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def edit_user_message_start(
callback: types.CallbackQuery,
state: FSMContext,
db_user: User,
db: AsyncSession
):
try:
message_id = int(callback.data.split(":")[1])
except (ValueError, IndexError):
await callback.answer("❌ Неверный ID сообщения", show_alert=True)
return
message = await get_user_message_by_id(db, message_id)
if not message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
return
await callback.message.edit_text(
f"✏️ <b>Редактирование сообщения ID {message.id}</b>\n\n"
f"<b>Текущий текст:</b>\n"
f"<blockquote>{message.message_text}</blockquote>\n\n"
f"Введите новый текст сообщения или отправьте /cancel для отмены:",
parse_mode="HTML"
)
await state.set_data({"editing_message_id": message_id})
await state.set_state(UserMessageStates.waiting_for_edit_text)
await callback.answer()
@admin_required
@error_handler
async def process_edit_message_text(
message: types.Message,
state: FSMContext,
db_user: User,
db: AsyncSession
):
if message.text == "/cancel":
await state.clear()
await message.answer(
"❌ Редактирование отменено.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
return
data = await state.get_data()
message_id = data.get("editing_message_id")
if not message_id:
await state.clear()
await message.answer("❌ Ошибка: ID сообщения не найден")
return
new_text = message.text.strip()
if len(new_text) > 4000:
await message.answer(
"❌ Сообщение слишком длинное. Максимум 4000 символов.\n"
"Попробуйте еще раз или отправьте /cancel для отмены."
)
return
try:
updated_message = await update_user_message(
db=db,
message_id=message_id,
message_text=new_text
)
if updated_message:
await state.clear()
await message.answer(
f"✅ <b>Сообщение обновлено!</b>\n\n"
f"<b>ID:</b> {updated_message.id}\n"
f"<b>Обновлено:</b> {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
f"<b>Новый текст:</b>\n"
f"<blockquote>{new_text}</blockquote>",
reply_markup=get_user_messages_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await state.clear()
await message.answer(
"❌ Сообщение не найдено или ошибка обновления.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
except Exception as e:
logger.error(f"Ошибка обновления сообщения: {e}")
await state.clear()
await message.answer(
"❌ Произошла ошибка при обновлении сообщения.",
reply_markup=get_user_messages_keyboard(db_user.language)
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
show_user_messages_panel,
F.data == "user_messages_panel"
)
dp.callback_query.register(
add_user_message_start,
F.data == "add_user_message"
)
dp.message.register(
process_new_message_text,
StateFilter(UserMessageStates.waiting_for_message_text)
)
dp.callback_query.register(
edit_user_message_start,
F.data.startswith("edit_user_message:")
)
dp.message.register(
process_edit_message_text,
StateFilter(UserMessageStates.waiting_for_edit_text)
)
dp.callback_query.register(
list_user_messages,
F.data.startswith("list_user_messages")
)
dp.callback_query.register(
view_user_message,
F.data.startswith("view_user_message:")
)
dp.callback_query.register(
toggle_message_status,
F.data.startswith("toggle_user_message:")
)
dp.callback_query.register(
delete_message_confirm,
F.data.startswith("delete_user_message:")
)
dp.callback_query.register(
show_messages_stats,
F.data == "user_messages_stats"
)
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
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
)
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
):
await callback.message.edit_text(
"👋 Управление приветственным текстом\n\n"
"Здесь вы можете изменить текст, который показывается новым пользователям после регистрации.\n\n"
"💡 Доступные плейсхолдеры для автозамены:",
reply_markup=get_welcome_text_keyboard(db_user.language)
)
await callback.answer()
@admin_required
@error_handler
async def show_current_welcome_text(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
current_text = await get_active_welcome_text(db)
if not current_text:
current_text = await get_current_welcome_text_or_default()
status = "📝 Используется стандартный текст:"
else:
status = "📝 Текущий приветственный текст:"
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}\n\n"
f"<code>{current_text}</code>\n\n"
f"💡 Доступные плейсхолдеры:\n{placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_placeholders_help(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
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),
parse_mode="HTML"
)
await callback.answer()
@admin_required
@error_handler
async def show_formatting_help(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
formatting_info = get_telegram_formatting_info()
await callback.message.edit_text(
formatting_info,
reply_markup=get_welcome_text_keyboard(db_user.language),
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
):
current_text = await get_active_welcome_text(db)
if not current_text:
current_text = await get_current_welcome_text_or_default()
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:
placeholders = get_available_placeholders()
placeholders_text = "\n".join([f"• <code>{key}</code>" for key in placeholders.keys()])
await message.answer(
f"✅ Приветственный текст успешно обновлен!\n\n"
f"Новый текст:\n"
f"<code>{new_text}</code>\n\n"
f"💡 Будут заменяться плейсхолдеры: {placeholders_text}",
reply_markup=get_welcome_text_keyboard(db_user.language),
parse_mode="HTML"
)
else:
await message.answer(
"❌ Ошибка при сохранении текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language)
)
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:
await callback.message.edit_text(
f"✅ Приветственный текст сброшен на стандартный!\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),
parse_mode="HTML"
)
else:
await callback.message.edit_text(
"❌ Ошибка при сбросе текста. Попробуйте еще раз.",
reply_markup=get_welcome_text_keyboard(db_user.language)
)
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)
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),
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(
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
)
+223 -4
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("Неизвестный способ оплаты")
@@ -356,10 +360,15 @@ async def process_stars_payment_amount(
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
await message.answer(" Оплата Stars временно недоступна")
await message.answer(" Оплата Stars временно недоступна")
return
try:
from app.external.telegram_stars import TelegramStarsService
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
payment_service = PaymentService(message.bot)
invoice_link = await payment_service.create_stars_invoice(
amount_kopeks=amount_kopeks,
@@ -374,7 +383,9 @@ async def process_stars_payment_amount(
await message.answer(
f"⭐ <b>Оплата через Telegram Stars</b>\n\n"
f"Сумма: {texts.format_price(amount_kopeks)}\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"К оплате: {stars_amount} звезд\n"
f"📊 Курс: {int(settings.get_stars_rate())}₽ за звезду\n\n"
f"Нажмите кнопку ниже для оплаты:",
reply_markup=keyboard,
parse_mode="HTML"
@@ -384,7 +395,7 @@ async def process_stars_payment_amount(
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
await message.answer(" Ошибка создания платежа")
await message.answer(" Ошибка создания платежа")
@error_handler
@@ -458,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}")
@@ -519,6 +530,204 @@ 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):
@@ -571,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_")
)
+55 -11
View File
@@ -10,6 +10,7 @@ from app.keyboards.inline import get_main_menu_keyboard
from app.localization.texts import get_texts
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
from app.database.crud.user_message import get_random_active_message
logger = logging.getLogger(__name__)
@@ -19,7 +20,6 @@ async def show_main_menu(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
from datetime import datetime
@@ -32,11 +32,7 @@ async def show_main_menu(
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = texts.MAIN_MENU.format(
user_name=db_user.full_name,
balance=texts.format_price(db_user.balance_kopeks),
subscription_status=_get_subscription_status(db_user, texts)
)
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
@@ -45,8 +41,10 @@ async def show_main_menu(
is_admin=settings.is_admin(db_user.telegram_id),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks
),
parse_mode="HTML"
)
await callback.answer()
@@ -101,15 +99,36 @@ async def handle_back_to_menu(
db_user: User,
db: AsyncSession
):
await state.clear()
await show_main_menu(callback, db_user, db)
texts = get_texts(db_user.language)
has_active_subscription = db_user.subscription is not None
subscription_is_active = False
if db_user.subscription:
subscription_is_active = db_user.subscription.is_active
menu_text = await get_main_menu_text(db_user, texts, db)
await callback.message.edit_text(
menu_text,
reply_markup=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=db_user.balance_kopeks
),
parse_mode="HTML"
)
await callback.answer()
def _get_subscription_status(user: User, texts) -> str:
if not user.subscription:
return " Отсутствует"
return " Отсутствует"
subscription = user.subscription
current_time = datetime.utcnow()
@@ -137,6 +156,31 @@ def _get_subscription_status(user: User, texts) -> str:
else:
return f"💎 Активна\n⚠️ истекает сегодня!"
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
+252 -49
View File
@@ -1,13 +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.crud.referral import get_referral_earnings_sum
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
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__)
@@ -17,7 +26,6 @@ async def show_referral_info(
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
summary = await get_user_referral_summary(db, db_user.id)
@@ -28,38 +36,214 @@ async def show_referral_info(
referral_text = f"👥 <b>Реферальная программа</b>\n\n"
referral_text += f"📊 <b>Ваша статистика:</b>\n"
referral_text += f"• Приглашено пользователей: {summary['invited_count']}\n"
referral_text += f"Купили подписку: {summary['paid_referrals_count']}\n"
referral_text += f"Заработано всего: {texts.format_price(summary['total_earned_kopeks'])}\n"
referral_text += f"За последний месяц: {texts.format_price(summary['month_earned_kopeks'])}\n\n"
referral_text += f"• Приглашено пользователей: <b>{summary['invited_count']}</b>\n"
referral_text += f"Сделали первое пополнение: <b>{summary['paid_referrals_count']}</b>\n"
referral_text += f"Активных рефералов: <b>{summary['active_referrals_count']}</b>\n"
referral_text += f"Конверсия: <b>{summary['conversion_rate']}%</b>\n"
referral_text += f"• Заработано всего: <b>{texts.format_price(summary['total_earned_kopeks'])}</b>\n"
referral_text += f"• За последний месяц: <b>{texts.format_price(summary['month_earned_kopeks'])}</b>\n\n"
referral_text += f"🎁 <b>Как работают награды:</b>\n"
referral_text += f"• Новый пользователь получает: {texts.format_price(settings.REFERRED_USER_REWARD)}\n"
referral_text += f"• Вы получаете при первой покупке реферала: {texts.format_price(settings.REFERRAL_REGISTRATION_REWARD)}\n"
referral_text += f"• Комиссия с каждой покупки реферала: {settings.REFERRAL_COMMISSION_PERCENT}%\n\n"
referral_text += f"• Новый пользователь получает: <b>{texts.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}</b> при первом пополнении от <b>{texts.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)}</b>\n"
referral_text += f"• Вы получаете при первом пополнении реферала: <b>{texts.format_price(settings.REFERRAL_INVITER_BONUS_KOPEKS)}</b>\n"
referral_text += f"• Комиссия с каждого пополнения реферала: <b>{settings.REFERRAL_COMMISSION_PERCENT}%</b>\n\n"
referral_text += f"🔗 <b>Ваша реферальная ссылка:</b>\n"
referral_text += f"<code>{referral_link}</code>\n\n"
referral_text += f"🆔 <b>Ваш код:</b> <code>{db_user.referral_code}</code>\n\n"
if summary['recent_earnings']:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in summary['recent_earnings'][:3]:
reason_text = {
"referral_first_purchase": "🎉 Первая покупка",
"referral_commission": "💰 Комиссия",
"referral_registration_pending": "⏳ Ожидание покупки"
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: {texts.format_price(earning['amount_kopeks'])} от {earning['referral_name']}\n"
meaningful_earnings = [
earning for earning in summary['recent_earnings'][:5]
if earning['amount_kopeks'] > 0
]
if meaningful_earnings:
referral_text += f"💰 <b>Последние начисления:</b>\n"
for earning in meaningful_earnings[:3]:
reason_text = {
"referral_first_topup": "🎉 Первое пополнение",
"referral_commission_topup": "💰 Комиссия с пополнения",
"referral_commission": "💰 Комиссия с покупки"
}.get(earning['reason'], earning['reason'])
referral_text += f"{reason_text}: <b>{texts.format_price(earning['amount_kopeks'])}</b> от {earning['referral_name']}\n"
referral_text += "\n"
if summary['earnings_by_type']:
referral_text += f"📈 <b>Доходы по типам:</b>\n"
if 'referral_first_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_first_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Бонусы за первые пополнения: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
if 'referral_commission_topup' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission_topup']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с пополнений: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
if 'referral_commission' in summary['earnings_by_type']:
data = summary['earnings_by_type']['referral_commission']
if data['total_amount_kopeks'] > 0:
referral_text += f"• Комиссии с покупок: <b>{data['count']}</b> ({texts.format_price(data['total_amount_kopeks'])})\n"
referral_text += "\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,
db: AsyncSession,
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 edit_or_answer_photo(
callback,
"📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]]
),
parse_mode=None,
)
await callback.answer()
return
text = f"👥 <b>Ваши рефералы</b> (стр. {referrals_data['current_page']}/{referrals_data['total_pages']})\n\n"
for i, referral in enumerate(referrals_data['referrals'], 1):
status_emoji = "🟢" if referral['status'] == 'active' else "🔴"
topup_emoji = "💰" if referral['has_made_first_topup'] else ""
text += f"{i}. {status_emoji} <b>{referral['full_name']}</b>\n"
text += f" {topup_emoji} Пополнений: {referral['topups_count']}\n"
text += f" 💎 Заработано с него: {texts.format_price(referral['total_earned_kopeks'])}\n"
text += f" 📅 Регистрация: {referral['days_since_registration']} дн. назад\n"
if referral['days_since_activity'] is not None:
text += f" 🕐 Активность: {referral['days_since_activity']} дн. назад\n"
else:
text += f" 🕐 Активность: давно\n"
text += "\n"
keyboard = []
nav_buttons = []
if referrals_data['has_prev']:
nav_buttons.append(types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"referral_list_page_{page - 1}"
))
if referrals_data['has_next']:
nav_buttons.append(types.InlineKeyboardButton(
text="Вперед ➡️",
callback_data=f"referral_list_page_{page + 1}"
))
if nav_buttons:
keyboard.append(nav_buttons)
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)])
await edit_or_answer_photo(
callback,
text,
types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def show_referral_analytics(
callback: types.CallbackQuery,
db_user: User,
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 edit_or_answer_photo(
callback,
text,
types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_referrals")]
]),
)
await callback.answer()
@@ -68,40 +252,37 @@ async def create_invite_message(
callback: types.CallbackQuery,
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.REFERRED_USER_REWARD)} на баланс!\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
)
],
[
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_referrals"
)
]
[types.InlineKeyboardButton(
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()
@@ -117,3 +298,25 @@ 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,
F.data == "referral_list"
)
dp.callback_query.register(
show_referral_analytics,
F.data == "referral_analytics"
)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(
callback, db_user, db, int(callback.data.split('_')[-1])
),
F.data.startswith("referral_list_page_")
)
+132
View File
@@ -0,0 +1,132 @@
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_service import PaymentService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.user import get_user_by_telegram_id
logger = logging.getLogger(__name__)
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
error_message="Ошибка валидации платежа. Попробуйте еще раз."
)
return
try:
from app.database.database import get_db
async for db in get_db():
user = await get_user_by_telegram_id(db, query.from_user.id)
if not user:
logger.warning(f"Пользователь {query.from_user.id} не найден в БД")
await query.answer(
ok=False,
error_message="Пользователь не найден. Обратитесь в поддержку."
)
return
break
except Exception as db_error:
logger.error(f"Ошибка подключения к БД в pre_checkout_query: {db_error}")
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
)
return
await query.answer(ok=True)
logger.info(f"✅ Pre-checkout одобрен для пользователя {query.from_user.id}")
except Exception as e:
logger.error(f"Ошибка в pre_checkout_query: {e}", exc_info=True)
await query.answer(
ok=False,
error_message="Техническая ошибка. Попробуйте позже."
)
async def handle_successful_payment(
message: types.Message,
db: AsyncSession,
**kwargs
):
try:
payment = message.successful_payment
user_id = message.from_user.id
logger.info(
f"💳 Успешный Stars платеж от {user_id}: "
f"{payment.total_amount} XTR, "
f"payload: {payment.invoice_payload}, "
f"charge_id: {payment.telegram_payment_charge_id}"
)
user = await get_user_by_telegram_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден при обработке Stars платежа")
await message.answer(
"❌ Ошибка: пользователь не найден. Обратитесь в поддержку."
)
return
payment_service = PaymentService(message.bot)
success = await payment_service.process_stars_payment(
db=db,
user_id=user.id,
stars_amount=payment.total_amount,
payload=payment.invoice_payload,
telegram_payment_charge_id=payment.telegram_payment_charge_id
)
if success:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(payment.total_amount)
await message.answer(
f"🎉 <b>Платеж успешно обработан!</b>\n\n"
f"⭐ Потрачено звезд: {payment.total_amount}\n"
f"💰 Зачислено на баланс: {int(rubles_amount)}\n"
f"🆔 ID транзакции: {payment.telegram_payment_charge_id[:8]}...\n\n"
f"Спасибо за пополнение! 🚀",
parse_mode="HTML"
)
logger.info(
f"✅ Stars платеж успешно обработан: "
f"пользователь {user.id}, {payment.total_amount} звезд → {int(rubles_amount)}"
)
else:
logger.error(f"Ошибка обработки Stars платежа для пользователя {user.id}")
await message.answer(
"❌ Произошла ошибка при зачислении средств. "
"Обратитесь в поддержку, платеж будет проверен вручную."
)
except Exception as e:
logger.error(f"Ошибка в successful_payment: {e}", exc_info=True)
await message.answer(
"❌ Техническая ошибка при обработке платежа. "
"Обратитесь в поддержку для решения проблемы."
)
def register_stars_handlers(dp: Dispatcher):
dp.pre_checkout_query.register(
handle_pre_checkout_query,
F.currency == "XTR"
)
dp.message.register(
handle_successful_payment,
F.successful_payment
)
logger.info("🌟 Зарегистрированы обработчики Telegram Stars платежей")
+138 -133
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from aiogram import Dispatcher, types, F
from aiogram import Dispatcher, types, F, Bot
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,11 +12,13 @@ from app.database.crud.user import (
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
)
from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
logger = logging.getLogger(__name__)
@@ -135,19 +137,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
if user.subscription:
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
await message.answer(
texts.MAIN_MENU.format(
user_name=user.full_name,
balance=texts.format_price(user.balance_kopeks),
subscription_status=_get_subscription_status(user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
await state.clear()
return
@@ -380,13 +382,13 @@ async def process_referral_code_skip(
await complete_registration_from_callback(callback, state, db)
async def complete_registration_from_callback(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
logger.info(f"🏁 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
logger.info(f"🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}")
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
@@ -404,27 +406,24 @@ async def complete_registration_from_callback(
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
user_name = existing_user.full_name
balance_kopeks = existing_user.balance_kopeks
menu_text = await get_main_menu_text(existing_user, texts, db)
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status(existing_user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks
),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await callback.message.answer(f"Добро пожаловать, {user_name}!")
await callback.message.answer(f"Добро пожаловать, {existing_user.full_name}!")
await state.clear()
return
@@ -493,74 +492,34 @@ async def complete_registration_from_callback(
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id)
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
await callback.message.answer(bonus_message)
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
from app.database.crud.welcome_text import get_welcome_text_for_user
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
offer_text = await get_welcome_text_for_user(db, user_name)
try:
await callback.message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
await callback.message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
except Exception as fallback_error:
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await callback.message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
except Exception as final_error:
logger.error(f"❌ Критическая ошибка при отправке простого сообщения: {final_error}")
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
logger.error(f"Ошибка при отправке предложения триала: {e}")
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)
@@ -578,20 +537,20 @@ async def complete_registration(
if existing_user.subscription:
subscription_is_active = existing_user.subscription.is_active
menu_text = await get_main_menu_text(existing_user, texts, db)
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=existing_user.full_name,
balance=texts.format_price(existing_user.balance_kopeks),
subscription_status=_get_subscription_status(existing_user, texts)
),
menu_text,
reply_markup=get_main_menu_keyboard(
language=existing_user.language,
is_admin=settings.is_admin(existing_user.telegram_id),
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks
),
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
@@ -664,71 +623,67 @@ async def complete_registration(
if referrer_id:
try:
await process_referral_registration(db, user.id, referrer_id)
bonus_message = f"🎉 Вы получили {settings.REFERRED_USER_REWARD/100}₽ за регистрацию по реферальной ссылке!"
await message.answer(bonus_message)
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
from app.database.crud.welcome_text import get_welcome_text_for_user
has_active_subscription = False
subscription_is_active = False
user_name = user.full_name
balance_kopeks = user.balance_kopeks
user_telegram_id = user.telegram_id
user_language = user.language
has_had_paid_subscription = user.has_had_paid_subscription
offer_text = await get_welcome_text_for_user(db, message.from_user)
try:
await message.answer(
texts.MAIN_MENU.format(
user_name=user_name,
balance=texts.format_price(balance_kopeks),
subscription_status=_get_subscription_status_simple(texts)
),
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user_telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = balance_kopeks / 100
await message.answer(
f"Добро пожаловать, {user_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user_language,
is_admin=settings.is_admin(user_telegram_id),
has_had_paid_subscription=has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user_telegram_id}")
except Exception as fallback_error:
logger.error(f"❌ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await message.answer(f"Добро пожаловать, {user_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user_telegram_id}")
except:
pass
logger.info(f"✅ Регистрация завершена для пользователя: {user_telegram_id}")
logger.error(f"Ошибка при отправке предложения триала: {e}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
def _get_subscription_status(user, texts):
if user.subscription and user.subscription.is_active:
return getattr(texts, 'SUBSCRIPTION_ACTIVE', 'Активна')
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user or not hasattr(user, 'subscription'):
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
if not user.subscription:
return getattr(texts, 'SUBSCRIPTION_NONE', 'Нет активной подписки')
subscription = user.subscription
from datetime import datetime
current_time = datetime.utcnow()
if hasattr(subscription, 'end_date') and subscription.end_date <= current_time:
return f"🔴 Истекла\n📅 {subscription.end_date.strftime('%d.%m.%Y')}"
if hasattr(subscription, 'end_date'):
days_left = (subscription.end_date - current_time).days
else:
days_left = 0
is_trial = getattr(subscription, 'is_trial', False)
if is_trial:
if days_left > 1:
return f"🎁 Тестовая подписка\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left == 1:
return "🎁 Тестовая подписка\n⚠️ истекает завтра!"
else:
return "🎁 Тестовая подписка\n⚠️ истекает сегодня!"
else:
if days_left > 7:
return f"💎 Активна\n📅 до {subscription.end_date.strftime('%d.%m.%Y')} ({days_left} дн.)"
elif days_left > 1:
return f"💎 Активна\n⚠️ истекает через {days_left} дн."
elif days_left == 1:
return "💎 Активна\n⚠️ истекает завтра!"
else:
return "💎 Активна\n⚠️ истекает сегодня!"
def _get_subscription_status_simple(texts):
@@ -746,6 +701,55 @@ def get_referral_code_keyboard(language: str):
)]
])
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
subscription_status=_get_subscription_status(user, texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=user_name,
subscription_status=_get_subscription_status_simple(texts)
)
try:
random_message = await get_random_active_message(db)
if random_message:
if "Выберите действие:" in base_text:
parts = base_text.split("Выберите действие:")
if len(parts) == 2:
return f"{parts[0]}\n{random_message}\n\nВыберите действие:{parts[1]}"
if "Выберите действие:" in base_text:
return base_text.replace("Выберите действие:", f"\n{random_message}\n\nВыберите действие:")
else:
return f"{base_text}\n\n{random_message}"
except Exception as e:
logger.error(f"Ошибка получения случайного сообщения: {e}")
return base_text
def register_handlers(dp: Dispatcher):
@@ -787,3 +791,4 @@ def register_handlers(dp: Dispatcher):
logger.info("✅ Зарегистрирован handle_potential_referral_code")
logger.info("🔧 === КОНЕЦ регистрации обработчиков start.py ===")
+1538 -486
View File
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from app.database.models import User, Subscription
from app.database.crud.user import get_user_by_id, subtract_user_balance
from app.database.crud.subscription import get_expiring_subscriptions, extend_subscription
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType
from app.keyboards.inline import get_autopay_notification_keyboard, get_subscription_expiring_keyboard
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
async def send_subscription_expiring_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
days_left: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
if subscription.is_trial:
text = texts.TRIAL_ENDING_SOON.format(
price=texts.format_price(30000)
)
else:
autopay_status = texts.AUTOPAY_ENABLED_TEXT if subscription.autopay_enabled else texts.AUTOPAY_DISABLED_TEXT
if subscription.autopay_enabled:
action_text = f"💰 Убедитесь, что на балансе достаточно средств: {texts.format_price(user.balance_kopeks)}"
else:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
text = texts.SUBSCRIPTION_EXPIRING_PAID.format(
days=days_left,
end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"),
autopay_status=autopay_status,
action_text=action_text
)
keyboard = get_subscription_expiring_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление об истечении подписки пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления об истечении подписки: {e}")
return False
async def send_autopay_failed_notification(
bot: Bot,
db: AsyncSession,
subscription: Subscription,
required_amount: int
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
return False
texts = get_texts(user.language)
text = texts.AUTOPAY_FAILED.format(
balance=texts.format_price(user.balance_kopeks),
required=texts.format_price(required_amount)
)
keyboard = get_autopay_notification_keyboard(subscription.id, user.language)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML"
)
logger.info(f"✅ Отправлено уведомление о неудачном автоплатеже пользователю {user.telegram_id}")
return True
except TelegramBadRequest as e:
logger.warning(f"⚠️ Не удалось отправить уведомление пользователю {user.telegram_id}: {e}")
return False
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления о неудачном автоплатеже: {e}")
return False
async def process_autopayment(
bot: Bot,
db: AsyncSession,
subscription: Subscription
) -> bool:
try:
user = await get_user_by_id(db, subscription.user_id)
if not user:
logger.error(f"Пользователь {subscription.user_id} не найден для автоплатежа")
return False
subscription_service = SubscriptionService()
renewal_cost = await subscription_service.calculate_renewal_price(
subscription, 30, db
)
if user.balance_kopeks < renewal_cost:
logger.warning(f"Недостаточно средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
success = await subtract_user_balance(
db, user, renewal_cost,
f"Автопродление подписки на 30 дней"
)
if not success:
logger.error(f"Ошибка списания средств для автоплатежа у пользователя {user.telegram_id}")
await send_autopay_failed_notification(bot, db, subscription, renewal_cost)
return False
await extend_subscription(db, subscription, 30)
await subscription_service.update_remnawave_user(db, subscription)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=renewal_cost,
description="Автопродление подписки на 30 дней"
)
texts = get_texts(user.language)
success_text = texts.AUTOPAY_SUCCESS.format(
days=30,
amount=texts.format_price(renewal_cost),
new_end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M")
)
await bot.send_message(
chat_id=user.telegram_id,
text=success_text,
parse_mode="HTML"
)
logger.info(f"✅ Автоплатеж успешно выполнен для пользователя {user.telegram_id}")
return True
except Exception as e:
logger.error(f"❌ Ошибка обработки автоплатежа: {e}")
return False
+1 -1
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
async def tribute_webhook(request):
try:
signature = request.headers.get('X-Signature', '')
signature = request.headers.get('trbt-signature', '')
payload = await request.text()
tribute_service = TributeService()
+94 -19
View File
@@ -28,6 +28,15 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics"),
InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_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")
]
@@ -76,7 +85,7 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
InlineKeyboardButton(text=" Создать", callback_data="admin_promo_create")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_promo_stats")
InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_promo_general_stats")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
@@ -180,7 +189,10 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
InlineKeyboardButton(text="📱 Подписка", callback_data=f"admin_user_subscription_{user_id}")
],
[
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}"),
InlineKeyboardButton(text="⚙️ Настройка", callback_data=f"admin_user_servers_{user_id}"),
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
],
[
InlineKeyboardButton(text="📋 Транзакции", callback_data=f"admin_user_transactions_{user_id}")
]
]
@@ -577,26 +589,72 @@ def get_admin_pagination_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_maintenance_keyboard(language: str = "ru", is_active: bool = False, monitoring_active: bool = False) -> InlineKeyboardMarkup:
"""Клавиатура для управления техработами"""
def get_maintenance_keyboard(
language: str,
is_maintenance_active: bool,
is_monitoring_active: bool,
panel_has_issues: bool = False
) -> InlineKeyboardMarkup:
keyboard = []
if language == "en":
toggle_text = "🔴 Disable maintenance" if is_active else "🔧 Enable maintenance"
monitoring_text = "⏹️ Stop monitoring" if monitoring_active else "🔄 Start monitoring"
check_api_text = "🔍 Check API"
back_text = "⬅️ Back to admin"
if is_maintenance_active:
keyboard.append([
InlineKeyboardButton(
text="🟢 Выключить техработы",
callback_data="maintenance_toggle"
)
])
else:
toggle_text = "🔴 Выключить техработы" if is_active else "🔧 Включить техработы"
monitoring_text = "⏹️ Остановить мониторинг" if monitoring_active else "🔄 Запустить мониторинг"
check_api_text = "🔍 Проверить API"
back_text = "⬅️ Назад в админку"
keyboard.append([
InlineKeyboardButton(
text="🔧 Включить техработы",
callback_data="maintenance_toggle"
)
])
keyboard = [
[InlineKeyboardButton(text=toggle_text, callback_data="maintenance_toggle")],
[InlineKeyboardButton(text=monitoring_text, callback_data="maintenance_monitoring")],
[InlineKeyboardButton(text=check_api_text, callback_data="maintenance_check_api")],
[InlineKeyboardButton(text=back_text, callback_data="admin_panel")]
]
if is_monitoring_active:
keyboard.append([
InlineKeyboardButton(
text="⏹️ Остановить мониторинг",
callback_data="maintenance_monitoring"
)
])
else:
keyboard.append([
InlineKeyboardButton(
text="▶️ Запустить мониторинг",
callback_data="maintenance_monitoring"
)
])
keyboard.append([
InlineKeyboardButton(
text="🔍 Проверить API",
callback_data="maintenance_check_api"
),
InlineKeyboardButton(
text="🌐 Статус панели" + ("⚠️" if panel_has_issues else ""),
callback_data="maintenance_check_panel"
)
])
keyboard.append([
InlineKeyboardButton(
text="📢 Отправить уведомление",
callback_data="maintenance_manual_notify"
)
])
keyboard.append([
InlineKeyboardButton(
text="🔄 Обновить",
callback_data="maintenance_panel"
),
InlineKeyboardButton(
text="◀️ Назад",
callback_data="admin_panel"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -607,3 +665,20 @@ def get_sync_simplified_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_welcome_text_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="📝 Изменить текст", callback_data="edit_welcome_text"),
InlineKeyboardButton(text="👁️ Показать текущий", callback_data="show_welcome_text")
],
[
InlineKeyboardButton(text="🏷️ HTML форматирование", callback_data="show_formatting_help"),
InlineKeyboardButton(text="💡 Плейсхолдеры", callback_data="show_placeholders_help")
],
[
InlineKeyboardButton(text="🔄 Сбросить", callback_data="reset_welcome_text"),
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
+515 -174
View File
@@ -1,10 +1,16 @@
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__)
def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -16,25 +22,45 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
)
],
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
])
def get_main_menu_keyboard(
language: str = "ru",
language: str = "ru",
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
subscription_is_active: bool = False
subscription_is_active: bool = False,
balance_kopeks: int = 0
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.DEBUG:
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}")
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
else:
balance_button_text = f"💰 Баланс: {texts.format_price(balance_kopeks)}"
keyboard = [
[
InlineKeyboardButton(text=texts.MENU_BALANCE, callback_data="menu_balance"),
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance")
]
]
if has_active_subscription and subscription_is_active:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
])
show_trial = not has_had_paid_subscription and not has_active_subscription
@@ -92,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,
@@ -102,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
@@ -110,7 +149,7 @@ def get_subscription_keyboard(
if connect_mode == "miniapp_subscription":
keyboard.append([
InlineKeyboardButton(
text="🚀 Открыть в мини-приложении",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=subscription.subscription_url)
)
])
@@ -118,7 +157,7 @@ def get_subscription_keyboard(
if settings.MINIAPP_CUSTOM_URL:
keyboard.append([
InlineKeyboardButton(
text="🚀 Открыть приложение",
text="🔗 Подключиться",
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
])
@@ -126,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")
@@ -156,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)
@@ -233,28 +240,57 @@ def get_subscription_period_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
import logging
logger = logging.getLogger(__name__)
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
all_packages = settings.get_traffic_packages()
logger.info(f"🔍 ALL PACKAGES: {all_packages}")
enabled_packages = [pkg for pkg in all_packages if pkg['enabled']]
disabled_packages = [pkg for pkg in all_packages if not pkg['enabled']]
logger.info(f"🔍 ENABLED: {len(enabled_packages)} packages")
logger.info(f"🔍 DISABLED: {len(disabled_packages)} packages")
for pkg in disabled_packages:
logger.info(f"🔍 DISABLED PACKAGE: {pkg['gb']}GB = {pkg['price']} kopeks, enabled={pkg['enabled']}")
texts = get_texts(language)
keyboard = []
packages = [
(5, texts.TRAFFIC_5GB),
(10, texts.TRAFFIC_10GB),
(25, texts.TRAFFIC_25GB),
(50, texts.TRAFFIC_50GB),
(100, texts.TRAFFIC_100GB),
(250, texts.TRAFFIC_250GB),
(0, texts.TRAFFIC_UNLIMITED)
]
traffic_packages = settings.get_traffic_packages()
for gb, text in packages:
for package in traffic_packages:
gb = package["gb"]
price = package["price"]
enabled = package["enabled"]
if not enabled:
continue
if gb == 0:
text = f"♾️ Безлимит - {settings.format_price(package['price'])}"
else:
text = f"📊 {gb} ГБ - {settings.format_price(package['price'])}"
keyboard.append([
InlineKeyboardButton(text=text, callback_data=f"traffic_{gb}")
])
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text="⚠️ Пакеты трафика не настроены",
callback_data="no_traffic_packages"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="subscription_config_back")
@@ -262,7 +298,6 @@ def get_traffic_packages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_countries_keyboard(countries: List[dict], selected: List[str], language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
@@ -306,21 +341,30 @@ def get_devices_keyboard(current: int, language: str = "ru") -> InlineKeyboardMa
keyboard = []
start_devices = settings.DEFAULT_DEVICE_LIMIT
end_devices = max(start_devices + 3, 6)
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 50
end_devices = min(max_devices + 1, start_devices + 10)
buttons = []
for devices in range(start_devices, end_devices):
price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (включено)"
price_text = f" (+{texts.format_price(price)})" if price > 0 else " (вкл.)"
emoji = "" if devices == current else ""
device_word = _get_device_declension(devices)
button_text = f"{emoji} {devices}{price_text}"
keyboard.append([
buttons.append(
InlineKeyboardButton(
text=f"{emoji} {devices} {device_word}{price_text}",
text=button_text,
callback_data=f"devices_{devices}"
)
])
)
for i in range(0, len(buttons), 2):
if i + 1 < len(buttons):
keyboard.append([buttons[i], buttons[i + 1]])
else:
keyboard.append([buttons[i]])
keyboard.extend([
[InlineKeyboardButton(text="✅ Продолжить", callback_data="devices_continue")],
@@ -366,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(
@@ -386,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="🛠️ Через поддержку",
@@ -456,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"
)
],
@@ -476,14 +528,41 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
def get_referral_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
keyboard = [
[
InlineKeyboardButton(text=texts.CREATE_INVITE, callback_data="referral_create_invite")
InlineKeyboardButton(
text="📝 Создать приглашение",
callback_data="referral_create_invite"
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
InlineKeyboardButton(
text="📱 Показать QR код",
callback_data="referral_show_qr"
)
],
[
InlineKeyboardButton(
text="👥 Список рефералов",
callback_data="referral_list"
)
],
[
InlineKeyboardButton(
text="📊 Аналитика",
callback_data="referral_analytics"
)
],
[
InlineKeyboardButton(
text=texts.BACK,
callback_data="back_to_menu"
)
]
])
]
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_support_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
@@ -610,60 +689,148 @@ def get_extend_subscription_keyboard(language: str = "ru") -> InlineKeyboardMark
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_traffic_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
def get_add_traffic_keyboard(language: str = "ru", subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
texts = get_texts(language)
keyboard = []
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
packages = [
(5, f"📊 +5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}"),
(10, f"📊 +10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}"),
(25, f"📊 +25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}"),
(50, f"📊 +50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}"),
(100, f"📊 +100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}"),
(0, f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}")
]
if not enabled_packages:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text="❌ Нет доступных пакетов" if language == "ru" else "❌ No packages available",
callback_data="no_traffic_packages"
)],
[InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)]
])
for gb, text in packages:
keyboard.append([
buttons = []
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
total_price = price_per_month * months_multiplier
if gb == 0:
if language == "ru":
text = f"♾️ Безлимитный трафик - {total_price//100}{period_text}"
else:
text = f"♾️ Unlimited traffic - {total_price//100}{period_text}"
else:
if language == "ru":
text = f"📊 +{gb} ГБ трафика - {total_price//100}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price//100}{period_text}"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_devices_keyboard(current_devices: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
return InlineKeyboardMarkup(inline_keyboard=buttons)
max_devices = 10
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
for add_count in range(1, min(6, max_devices - current_devices + 1)):
price = add_count * settings.PRICE_PER_DEVICE
total_devices = current_devices + add_count
months_multiplier = 1
period_text = ""
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
device_price_per_month = settings.PRICE_PER_DEVICE
buttons = []
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:
emoji = ""
action_text = ""
price_text = " (без возврата)"
add_device_word = _get_device_declension(add_count)
button_text = f"{emoji} {devices_count} устр.{action_text}{price_text}"
keyboard.append([
InlineKeyboardButton(
text=f"📱 +{add_count} {add_device_word} (итого: {total_devices}) - {settings.format_price(price)}",
callback_data=f"add_devices_{add_count}"
)
buttons.append([
InlineKeyboardButton(text=button_text, callback_data=f"change_devices_{devices_count}")
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="menu_subscription")
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="subscription_settings"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
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:
@@ -689,64 +856,72 @@ def get_manage_countries_keyboard(
countries: List[dict],
selected: List[str],
current_subscription_countries: List[str],
language: str = "ru"
language: str = "ru",
subscription_end_date: datetime = None
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
from app.utils.pricing_utils import get_remaining_months
months_multiplier = 1
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
logger.info(f"🔍 Расчет для управления странами: осталось {months_multiplier} месяцев до {subscription_end_date}")
buttons = []
total_cost = 0
for country in countries:
if not country.get('is_available', True):
continue
uuid = country['uuid']
name = country['name']
price_per_month = country['price_kopeks']
is_currently_connected = country['uuid'] in current_subscription_countries
is_selected = country['uuid'] in selected
if is_currently_connected:
if is_selected:
emoji = ""
status = ""
if uuid in current_subscription_countries:
if uuid in selected:
icon = ""
else:
emoji = ""
status = " (отключить БЕСПЛАТНО)"
icon = ""
else:
if is_selected:
emoji = ""
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
status = price_text
if uuid in selected:
icon = ""
total_cost += price_per_month * months_multiplier
else:
emoji = ""
price_text = f" (+{texts.format_price(country['price_kopeks'])})" if country['price_kopeks'] > 0 else " (Бесплатно)"
status = price_text
icon = ""
keyboard.append([
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}₽/мес × {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}₽)"
display_name = f"{icon} {name}{price_text}"
else:
display_name = f"{icon} {name}"
buttons.append([
InlineKeyboardButton(
text=f"{emoji} {country['name']}{status}",
callback_data=f"country_manage_{country['uuid']}"
text=display_name,
callback_data=f"country_manage_{uuid}"
)
])
if not keyboard:
keyboard.append([
InlineKeyboardButton(
text="❌ Нет доступных серверов",
callback_data="no_servers"
)
])
if total_cost > 0:
apply_text = f"✅ Применить изменения ({total_cost//100} ₽)"
logger.info(f"🔍 Общая стоимость новых серверов: {total_cost/100}")
else:
apply_text = "✅ Применить изменения"
added = [c for c in selected if c not in current_subscription_countries]
removed = [c for c in current_subscription_countries if c not in selected]
apply_text = "✅ Применить изменения"
if added or removed:
changes_count = len(added) + len(removed)
apply_text += f" ({changes_count})"
keyboard.extend([
[InlineKeyboardButton(text=apply_text, callback_data="countries_apply")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="menu_subscription")]
buttons.append([
InlineKeyboardButton(text=apply_text, callback_data="countries_apply")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
buttons.append([
InlineKeyboardButton(
text="⬅️ Назад" if language == "ru" else "⬅️ Back",
callback_data="menu_subscription"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_device_selection_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
from app.config import settings
@@ -899,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"
)
]
])
+104 -10
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:
@@ -105,19 +105,17 @@ class RussianTexts(Texts):
REFERRAL_CODE_APPLIED = "🎁 Реферальный код применен! Вы получите бонус после первой покупки."
REFERRAL_CODE_INVALID = "❌ Неверный реферальный код"
REFERRAL_CODE_SKIP = "⏭️ Пропустить"
MAIN_MENU = """👤 <b>{user_name}</b>
💰 <b>Баланс:</b> {balance}
📱 <b>Подписка:</b> {subscription_status}
Выберите действие:
"""
MENU_BALANCE = "💰 Баланс"
MENU_SUBSCRIPTION = "📱 Подписка"
MENU_TRIAL = "🎁 Тестовая подписка"
MENU_TRIAL = "🧪 Тестовая подписка"
MENU_BUY_SUBSCRIPTION = "💎 Купить подписку"
MENU_EXTEND_SUBSCRIPTION = "⏰ Продлить подписку"
MENU_PROMOCODE = "🎫 Промокод"
@@ -126,9 +124,11 @@ class RussianTexts(Texts):
MENU_RULES = "📋 Правила сервиса"
MENU_LANGUAGE = "🌐 Язык"
MENU_ADMIN = "⚙️ Админ-панель"
BALANCE_BUTTON = "💰 Баланс: {balance}"
BALANCE_BUTTON_ZERO = "💰 Баланс: 0 ₽"
SUBSCRIPTION_NONE = "❌ Нет активной подписки"
SUBSCRIPTION_TRIAL = "🎁 Тестовая подписка"
SUBSCRIPTION_TRIAL = "🧪 Тестовая подписка"
SUBSCRIPTION_ACTIVE = "✅ Активна"
SUBSCRIPTION_EXPIRED = "⏰ Истекла"
@@ -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 = {
+18 -14
View File
@@ -44,16 +44,18 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
is_registration_process = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (isinstance(event, CallbackQuery) and current_state and
any(str(state) in str(current_state) for state in registration_states))
or (isinstance(event, CallbackQuery) and event.data and
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
or (current_state in registration_states)
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
)
)
if is_registration_process:
@@ -93,16 +95,18 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_rules_accept,
RegistrationStates.waiting_for_referral_code
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
is_start_or_registration = (
(isinstance(event, Message) and event.text and event.text.startswith('/start'))
or (isinstance(event, CallbackQuery) and current_state and
any(str(state) in str(current_state) for state in registration_states))
or (isinstance(event, CallbackQuery) and event.data and
(event.data in ['rules_accept', 'rules_decline', 'referral_skip']))
or (current_state in registration_states)
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
)
)
if is_start_or_registration:
+710
View File
@@ -0,0 +1,710 @@
import logging
from typing import Optional, Dict, Any
from datetime import datetime
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, Subscription, Transaction
from app.database.crud.user import get_user_by_id
logger = logging.getLogger(__name__)
class AdminNotificationService:
def __init__(self, bot: Bot):
self.bot = bot
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
if not referred_by_id:
return "Нет"
try:
referrer = await get_user_by_id(db, referred_by_id)
if not referrer:
return f"ID {referred_by_id} (не найден)"
if referrer.username:
return f"@{referrer.username} (ID: {referred_by_id})"
else:
return f"ID {referrer.telegram_id}"
except Exception as e:
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
return f"ID {referred_by_id}"
async def send_trial_activation_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> bool:
if not self._is_enabled():
return False
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
📱 Устройства: {settings.TRIAL_DEVICE_LIMIT}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
return False
async def send_subscription_purchase_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
period_days: int,
was_trial_conversion: bool = False
) -> bool:
if not self._is_enabled():
return False
try:
event_type = "🔄 КОНВЕРСИЯ ИЗ ТРИАЛА" if was_trial_conversion else "💎 ПОКУПКА ПОДПИСКИ"
if was_trial_conversion:
user_status = "🎯 Конверсия из триала"
elif user.has_had_paid_subscription:
user_status = "🔄 Продление/Обновление"
else:
user_status = "🆕 Первая покупка"
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
📱 <b>Параметры подписки:</b>
📅 Период: {period_days} дней
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
📆 <b>Действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о покупке: {e}")
return False
async def send_version_update_notification(
self,
current_version: str,
latest_version,
total_updates: int
) -> bool:
"""Отправляет уведомление о новых обновлениях"""
if not self._is_enabled():
return False
try:
if latest_version.prerelease:
update_type = "🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ"
type_icon = "🧪"
elif latest_version.is_dev:
update_type = "🔧 DEV ВЕРСИЯ"
type_icon = "🔧"
else:
update_type = "📦 НОВАЯ ВЕРСИЯ"
type_icon = "📦"
description = latest_version.short_description
if len(description) > 200:
description = description[:197] + "..."
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
📝 <b>Описание:</b>
{description}
🔢 <b>Всего доступно обновлений:</b> {total_updates}
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об обновлении: {e}")
return False
async def send_version_check_error_notification(
self,
error_message: str,
current_version: str
) -> bool:
if not self._is_enabled():
return False
try:
message = f"""⚠️ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>
📦 <b>Текущая версия:</b> <code>{current_version}</code>
<b>Ошибка:</b> {error_message}
🔄 Следующая попытка через час.
Проверьте доступность GitHub API и настройки сети.
<i>Система автоматических обновлений {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об ошибке проверки версий: {e}")
return False
async def send_balance_topup_notification(
self,
db: AsyncSession,
user: User,
transaction: Transaction,
old_balance: int
) -> bool:
if not self._is_enabled():
return False
try:
topup_status = "🆕 Первое пополнение" if not user.has_made_first_topup else "🔄 Пополнение"
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
💰 <b>Баланс:</b>
📉 Было: {settings.format_price(old_balance)}
📈 Стало: {settings.format_price(user.balance_kopeks)}
Изменение: +{settings.format_price(balance_change)}
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
<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_subscription_extension_notification(
self,
db: AsyncSession,
user: User,
subscription: Subscription,
transaction: Transaction,
extended_days: int,
old_end_date: datetime
) -> bool:
if not self._is_enabled():
return False
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
<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_message(self, text: str) -> bool:
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
return False
try:
message_kwargs = {
'chat_id': self.chat_id,
'text': text,
'parse_mode': 'HTML',
'disable_web_page_preview': True
}
if self.topic_id:
message_kwargs['message_thread_id'] = self.topic_id
await self.bot.send_message(**message_kwargs)
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
return True
except TelegramForbiddenError:
logger.error(f"Бот не имеет прав для отправки в чат {self.chat_id}")
return False
except TelegramBadRequest as e:
logger.error(f"Ошибка отправки уведомления: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке уведомления: {e}")
return False
def _is_enabled(self) -> bool:
return self.enabled and bool(self.chat_id)
def _get_payment_method_display(self, payment_method: Optional[str]) -> str:
method_names = {
'telegram_stars': '⭐ Telegram Stars',
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
}
if not payment_method:
return '💰 С баланса'
return method_names.get(payment_method, f'💰 С баланса')
def _format_traffic(self, traffic_gb: int) -> str:
if traffic_gb == 0:
return "∞ Безлимит"
return f"{traffic_gb} ГБ"
def _get_subscription_status(self, user: User) -> str:
if not user.subscription:
return "❌ Нет подписки"
sub = user.subscription
if sub.is_trial:
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
elif sub.is_active:
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
else:
return "❌ Неактивна"
async def _get_servers_info(self, squad_uuids: list) -> str:
if not squad_uuids:
return "❌ Нет серверов"
try:
from app.handlers.subscription import get_servers_display_names
servers_names = await get_servers_display_names(squad_uuids)
return f"{len(squad_uuids)} шт. ({servers_names})"
except Exception as e:
logger.warning(f"Не удалось получить названия серверов: {e}")
return f"{len(squad_uuids)} шт."
async def send_maintenance_status_notification(
self,
event_type: str,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
if event_type == "enable":
if details.get("auto_enabled", False):
icon = "⚠️"
title = "АВТОМАТИЧЕСКОЕ ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "warning"
else:
icon = "🔧"
title = "ВКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "info"
elif event_type == "disable":
icon = ""
title = "ОТКЛЮЧЕНИЕ ТЕХРАБОТ"
alert_type = "success"
elif event_type == "api_status":
if status == "online":
icon = "🟢"
title = "API REMNAWAVE ВОССТАНОВЛЕНО"
alert_type = "success"
else:
icon = "🔴"
title = "API REMNAWAVE НЕДОСТУПНО"
alert_type = "error"
elif event_type == "monitoring":
if status == "started":
icon = "🔍"
title = "МОНИТОРИНГ ЗАПУЩЕН"
alert_type = "info"
else:
icon = "⏹️"
title = "МОНИТОРИНГ ОСТАНОВЛЕН"
alert_type = "info"
else:
icon = ""
title = "СИСТЕМА ТЕХРАБОТ"
alert_type = "info"
message_parts = [f"{icon} <b>{title}</b>", ""]
if event_type == "enable":
if details.get("reason"):
message_parts.append(f"📋 <b>Причина:</b> {details['reason']}")
if details.get("enabled_at"):
enabled_at = details["enabled_at"]
if isinstance(enabled_at, str):
from datetime import datetime
enabled_at = datetime.fromisoformat(enabled_at)
message_parts.append(f"🕐 <b>Время включения:</b> {enabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
message_parts.append(f"🤖 <b>Автоматически:</b> {'Да' if details.get('auto_enabled', False) else 'Нет'}")
message_parts.append("")
message_parts.append("❗ Обычные пользователи временно не могут использовать бота.")
elif event_type == "disable":
if details.get("disabled_at"):
disabled_at = details["disabled_at"]
if isinstance(disabled_at, str):
from datetime import datetime
disabled_at = datetime.fromisoformat(disabled_at)
message_parts.append(f"🕐 <b>Время отключения:</b> {disabled_at.strftime('%d.%m.%Y %H:%M:%S')}")
if details.get("duration"):
duration = details["duration"]
if isinstance(duration, (int, float)):
hours = int(duration // 3600)
minutes = int((duration % 3600) // 60)
if hours > 0:
duration_str = f"{hours}ч {minutes}мин"
else:
duration_str = f"{minutes}мин"
message_parts.append(f"⏱️ <b>Длительность:</b> {duration_str}")
message_parts.append(f"🤖 <b>Было автоматическим:</b> {'Да' if details.get('was_auto', False) else 'Нет'}")
message_parts.append("")
message_parts.append("✅ Сервис снова доступен для пользователей.")
elif event_type == "api_status":
message_parts.append(f"🔗 <b>API URL:</b> {details.get('api_url', 'неизвестно')}")
if status == "online":
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("consecutive_failures", 0) > 0:
message_parts.append(f"🔄 <b>Неудачных попыток было:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("API снова отвечает на запросы.")
else:
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Попытка №:</b> {details['consecutive_failures']}")
if details.get("error"):
error_msg = str(details["error"])[:100]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
message_parts.append("")
message_parts.append("⚠️ Началась серия неудачных проверок API.")
elif event_type == "monitoring":
if status == "started":
if details.get("check_interval"):
message_parts.append(f"🔄 <b>Интервал проверки:</b> {details['check_interval']} сек")
if details.get("auto_enable_configured") is not None:
auto_enable = "Включено" if details["auto_enable_configured"] else "Отключено"
message_parts.append(f"🤖 <b>Автовключение:</b> {auto_enable}")
if details.get("max_failures"):
message_parts.append(f"🎯 <b>Порог ошибок:</b> {details['max_failures']}")
message_parts.append("")
message_parts.append("Система будет следить за доступностью API.")
else:
message_parts.append("Автоматический мониторинг API остановлен.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о техработах: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
async def send_remnawave_panel_status_notification(
self,
status: str,
details: Dict[str, Any] = None
) -> bool:
if not self._is_enabled():
return False
try:
details = details or {}
status_config = {
"online": {"icon": "🟢", "title": "ПАНЕЛЬ REMNAWAVE ДОСТУПНА", "alert_type": "success"},
"offline": {"icon": "🔴", "title": "ПАНЕЛЬ REMNAWAVE НЕДОСТУПНА", "alert_type": "error"},
"degraded": {"icon": "🟡", "title": "ПАНЕЛЬ REMNAWAVE РАБОТАЕТ СО СБОЯМИ", "alert_type": "warning"},
"maintenance": {"icon": "🔧", "title": "ПАНЕЛЬ REMNAWAVE НА ОБСЛУЖИВАНИИ", "alert_type": "info"}
}
config = status_config.get(status, status_config["offline"])
message_parts = [
f"{config['icon']} <b>{config['title']}</b>",
""
]
if details.get("api_url"):
message_parts.append(f"🔗 <b>URL:</b> {details['api_url']}")
if details.get("response_time"):
message_parts.append(f"⚡ <b>Время отклика:</b> {details['response_time']} сек")
if details.get("last_check"):
last_check = details["last_check"]
if isinstance(last_check, str):
from datetime import datetime
last_check = datetime.fromisoformat(last_check)
message_parts.append(f"🕐 <b>Последняя проверка:</b> {last_check.strftime('%H:%M:%S')}")
if status == "online":
if details.get("uptime"):
message_parts.append(f"⏱️ <b>Время работы:</b> {details['uptime']}")
if details.get("users_online"):
message_parts.append(f"👥 <b>Пользователей онлайн:</b> {details['users_online']}")
message_parts.append("")
message_parts.append("✅ Все системы работают нормально.")
elif status == "offline":
if details.get("error"):
error_msg = str(details["error"])[:150]
message_parts.append(f"❌ <b>Ошибка:</b> {error_msg}")
if details.get("consecutive_failures"):
message_parts.append(f"🔄 <b>Неудачных попыток:</b> {details['consecutive_failures']}")
message_parts.append("")
message_parts.append("⚠️ Панель недоступна. Проверьте соединение и статус сервера.")
elif status == "degraded":
if details.get("issues"):
issues = details["issues"]
if isinstance(issues, list):
message_parts.append("⚠️ <b>Обнаруженные проблемы:</b>")
for issue in issues[:3]:
message_parts.append(f"{issue}")
else:
message_parts.append(f"⚠️ <b>Проблема:</b> {issues}")
message_parts.append("")
message_parts.append("Панель работает, но возможны задержки или сбои.")
elif status == "maintenance":
if details.get("maintenance_reason"):
message_parts.append(f"🔧 <b>Причина:</b> {details['maintenance_reason']}")
if details.get("estimated_duration"):
message_parts.append(f"⏰ <b>Ожидаемая длительность:</b> {details['estimated_duration']}")
if details.get("manual_message"):
message_parts.append(f"💬 <b>Сообщение:</b> {details['manual_message']}")
message_parts.append("")
message_parts.append("Панель временно недоступна для обслуживания.")
from datetime import datetime
message_parts.append("")
message_parts.append(f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>")
message = "\n".join(message_parts)
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе панели Remnawave: {e}")
return False
+574
View File
@@ -0,0 +1,574 @@
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 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)
)
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}")
backup_service = BackupService()
+78 -28
View File
@@ -33,7 +33,6 @@ class MaintenanceService:
self._last_notification_sent = None
def set_bot(self, bot):
"""Устанавливает ссылку на бота для отправки уведомлений"""
self._bot = bot
logger.info("Бот установлен для maintenance_service")
@@ -58,12 +57,49 @@ class MaintenanceService:
else:
return settings.get_maintenance_message()
async def _send_admin_notification(self, message: str, alert_type: str = "info"):
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return False
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self._bot)
if not notification_service._is_enabled():
logger.debug("Уведомления администраторов отключены")
return False
emoji_map = {
"error": "🚨",
"warning": "⚠️",
"success": "",
"info": ""
}
emoji = emoji_map.get(alert_type, "")
formatted_message = f"{emoji} <b>ТЕХНИЧЕСКИЕ РАБОТЫ</b>\n\n{message}\n\n⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
return await notification_service._send_message(formatted_message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления через AdminNotificationService: {e}")
return False
async def _notify_admins(self, message: str, alert_type: str = "info"):
"""Отправка уведомлений администраторам"""
if not self._bot:
logger.warning("Бот не установлен, уведомления не могут быть отправлены")
return
notification_sent = await self._send_admin_notification(message, alert_type)
if notification_sent:
logger.info("Уведомление успешно отправлено через AdminNotificationService")
return
logger.info("Отправляем уведомление напрямую администраторам")
cache_key = f"maintenance_notification_{alert_type}"
if await cache.get(cache_key):
return
@@ -116,15 +152,13 @@ class MaintenanceService:
await self._save_status_to_cache()
notification_msg = f"""
Режим технических работ ВКЛЮЧЕН
notification_msg = f"""Режим технических работ ВКЛЮЧЕН
📋 <b>Причина:</b> {self._status.reason}
🤖 <b>Автоматически:</b> {'Да' if auto else 'Нет'}
🕐 <b>Время:</b> {self._status.enabled_at.strftime('%d.%m.%Y %H:%M:%S')}
Обычные пользователи временно не смогут использовать бота.
"""
Обычные пользователи временно не смогут использовать бота."""
await self._notify_admins(notification_msg, "warning" if auto else "info")
@@ -163,15 +197,13 @@ class MaintenanceService:
else:
duration_str = f"\n⏱️ <b>Длительность:</b> {minutes}мин"
notification_msg = f"""
Режим технических работ ВЫКЛЮЧЕН
notification_msg = f"""Режим технических работ ВЫКЛЮЧЕН
🤖 <b>Автоматически:</b> {'Да' if was_auto else 'Нет'}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%d.%m.%Y %H:%M:%S')}
{duration_str}
Сервис снова доступен для пользователей.
"""
Сервис снова доступен для пользователей."""
await self._notify_admins(notification_msg, "success")
@@ -191,17 +223,15 @@ class MaintenanceService:
await self._load_status_from_cache()
self._check_task = asyncio.create_task(self._monitoring_loop())
logger.info(f"🔄 Запущен мониторинг API RemnaWave (интервал: {settings.get_maintenance_check_interval()}с)")
logger.info(f"🔄 Запущен мониторинг API Remnawave (интервал: {settings.get_maintenance_check_interval()}с)")
await self._notify_admins(f"""
Мониторинг технических работ запущен
await self._notify_admins(f"""Мониторинг технических работ запущен
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
Система будет следить за доступностью API.
""", "info")
Система будет следить за доступностью API.""", "info")
return True
@@ -240,17 +270,14 @@ class MaintenanceService:
is_connected = await test_api_connection(api)
if is_connected:
# API восстановилось
if not self._status.api_status:
await self._notify_admins(f"""
API RemnaWave восстановлено!
await self._notify_admins(f"""API Remnawave восстановлено!
<b>Статус:</b> Доступно
🕐 <b>Время восстановления:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Неудачных попыток было:</b> {self._status.consecutive_failures}
API снова отвечает на запросы.
""", "success")
API снова отвечает на запросы.""", "success")
self._status.api_status = True
self._status.consecutive_failures = 0
@@ -266,15 +293,13 @@ API снова отвечает на запросы.
self._status.consecutive_failures += 1
if was_available:
await self._notify_admins(f"""
API RemnaWave недоступно!
await self._notify_admins(f"""API Remnawave недоступно!
<b>Статус:</b> Недоступно
🕐 <b>Время обнаружения:</b> {self._status.last_check.strftime('%H:%M:%S')}
🔄 <b>Попытка:</b> {self._status.consecutive_failures}
Началась серия неудачных проверок API.
""", "error")
Началась серия неудачных проверок API.""", "error")
if (self._status.consecutive_failures >= self._max_consecutive_failures and
not self._status.is_active and
@@ -291,14 +316,12 @@ API RemnaWave недоступно!
logger.error(f"Ошибка проверки API: {e}")
if self._status.api_status:
await self._notify_admins(f"""
Ошибка при проверке API RemnaWave
await self._notify_admins(f"""Ошибка при проверке API Remnawave
<b>Ошибка:</b> {str(e)}
🕐 <b>Время:</b> {datetime.utcnow().strftime('%H:%M:%S')}
Не удалось выполнить проверку доступности API.
""", "error")
Не удалось выполнить проверку доступности API.""", "error")
self._status.api_status = False
self._status.consecutive_failures += 1
@@ -401,6 +424,33 @@ API RemnaWave недоступно!
"checked_at": end_time,
"consecutive_failures": self._status.consecutive_failures
}
async def send_remnawave_status_notification(self, status: str, details: str = "") -> bool:
try:
status_emojis = {
"online": "🟢",
"offline": "🔴",
"warning": "🟡",
"error": "⚠️"
}
emoji = status_emojis.get(status, "")
message = f"""Статус панели Remnawave изменился
{emoji} <b>Статус:</b> {status.upper()}
🔗 <b>URL:</b> {settings.REMNAWAVE_API_URL}
{details}"""
alert_type = "error" if status in ["offline", "error"] else "info"
await self._notify_admins(message, alert_type)
logger.info(f"Отправлено уведомление о статусе Remnawave: {status}")
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления о статусе Remnawave: {e}")
return False
maintenance_service = MaintenanceService()
+90 -52
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
@@ -37,6 +41,7 @@ class MonitoringService:
self.payment_service = PaymentService()
self.bot = bot
self._notified_users: Set[str] = set()
self._last_cleanup = datetime.utcnow()
async def start_monitoring(self):
if self.is_running:
@@ -62,6 +67,8 @@ class MonitoringService:
async def _monitoring_cycle(self):
async for db in get_db():
try:
await self._cleanup_notification_cache()
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
@@ -69,10 +76,6 @@ class MonitoringService:
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
current_hour = datetime.utcnow().hour
if current_hour == 0:
self._notified_users.clear()
await self._log_monitoring_event(
db, "monitoring_cycle_completed",
"Цикл мониторинга успешно завершен",
@@ -90,6 +93,15 @@ class MonitoringService:
finally:
break
async def _cleanup_notification_cache(self):
current_time = datetime.utcnow()
if (current_time - self._last_cleanup).total_seconds() >= 3600:
old_count = len(self._notified_users)
self._notified_users.clear()
self._last_cleanup = current_time
logger.info(f"🧹 Очищен кеш уведомлений ({old_count} записей)")
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
expired_subscriptions = await get_expired_subscriptions(db)
@@ -168,30 +180,51 @@ class MonitoringService:
async def _check_expiring_subscriptions(self, db: AsyncSession):
try:
warning_days = settings.get_autopay_warning_days()
all_processed_users = set()
for days in warning_days:
expiring_subscriptions = await self._get_expiring_paid_subscriptions(db, days)
sent_count = 0
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
notification_key = f"expiring_{user.telegram_id}_{days}d"
if notification_key in self._notified_users:
continue
user_key = f"user_{user.telegram_id}_today"
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:
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:
await self._send_subscription_expiring_notification(user, subscription, days)
self._notified_users.add(notification_key)
logger.info(f"⚠️ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
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} дней")
else:
logger.warning(f"❌ Не удалось отправить уведомление пользователю {user.telegram_id}")
if expiring_subscriptions:
if sent_count > 0:
await self._log_monitoring_event(
db, "expiring_notifications_sent",
f"Отправлено {len(expiring_subscriptions)} уведомлений об истечении через {days} дней",
{"days": days, "count": len(expiring_subscriptions)}
f"Отправлено {sent_count} уведомлений об истечении через {days} дней",
{"days": days, "count": sent_count}
)
except Exception as e:
@@ -219,16 +252,15 @@ class MonitoringService:
user = subscription.user
if not user:
continue
notification_key = f"trial_2h_{user.telegram_id}"
if notification_key in self._notified_users:
continue
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
continue
if self.bot:
await self._send_trial_ending_notification(user, subscription)
self._notified_users.add(notification_key)
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
success = await self._send_trial_ending_notification(user, subscription)
if success:
await record_notification(db, user.id, subscription.id, "trial_2h")
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
await self._log_monitoring_event(
@@ -257,9 +289,9 @@ class MonitoringService:
)
)
logger.info(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.info(f"📅 Текущее время: {current_time}")
logger.info(f"📅 Пороговая дата: {threshold_date}")
logger.debug(f"🔍 Поиск платных подписок, истекающих в ближайшие {days_before} дней")
logger.debug(f"📅 Текущее время: {current_time}")
logger.debug(f"📅 Пороговая дата: {threshold_date}")
subscriptions = result.scalars().all()
logger.info(f"📊 Найдено {len(subscriptions)} платных подписок для уведомлений")
@@ -340,15 +372,15 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка обработки автоплатежей: {e}")
async def _send_subscription_expired_notification(self, user: User):
async def _send_subscription_expired_notification(self, user: User) -> bool:
try:
message = """
<b>Подписка истекла</b>
<b>Подписка истекла</b>
Ваша подписка истекла. Для восстановления доступа продлите подписку.
Ваша подписка истекла. Для восстановления доступа продлите подписку.
🔧 Доступ к серверам заблокирован до продления.
"""
🔧 Доступ к серверам заблокирован до продления.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -363,11 +395,13 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int) -> bool:
try:
from app.utils.formatters import format_days_declension
@@ -382,14 +416,14 @@ class MonitoringService:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days_text}!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
💳 <b>Автоплатеж:</b> {autopay_status}
💳 <b>Автоплатеж:</b> {autopay_status}
{action_text}
"""
{action_text}
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -405,30 +439,32 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об истечении подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_trial_ending_notification(self, user: User, subscription: Subscription):
async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool:
try:
texts = get_texts(user.language)
message = f"""
🎁 <b>Тестовая подписка скоро закончится!</b>
🎁 <b>Тестовая подписка скоро закончится!</b>
Ваша тестовая подписка истекает через 2 часа.
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
Успейте оформить до окончания тестового периода!
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
@@ -443,9 +479,11 @@ class MonitoringService:
parse_mode="HTML",
reply_markup=keyboard
)
return True
except Exception as e:
logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}")
return False
async def _send_autopay_success_notification(self, user: User, amount: int, days: int):
try:
@@ -554,7 +592,7 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка логирования события мониторинга: {e}")
async def get_monitoring_status(self, db: AsyncSession) -> Dict[str, Any]:
try:
from sqlalchemy import select, desc
+326 -12
View File
@@ -9,10 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.yookassa_service import YooKassaService
from app.external.telegram_stars import TelegramStarsService
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.models import TransactionType, PaymentMethod
from app.external.cryptobot import CryptoBotService
from app.utils.currency_converter import currency_converter
logger = logging.getLogger(__name__)
@@ -22,6 +25,8 @@ class PaymentService:
def __init__(self, bot: Optional[Bot] = None):
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,
@@ -30,28 +35,118 @@ class PaymentService:
payload: Optional[str] = None
) -> str:
if not self.bot:
if not self.bot or not self.stars_service:
raise ValueError("Bot instance required for Stars payments")
try:
stars_amount = max(1, amount_kopeks // 100)
amount_rubles = amount_kopeks / 100
stars_amount = TelegramStarsService.calculate_stars_from_rubles(amount_rubles)
invoice_link = await self.bot.create_invoice_link(
title="Пополнение баланса VPN",
description=description,
description=f"{description} (≈{stars_amount} ⭐)",
payload=payload or f"balance_topup_{amount_kopeks}",
provider_token="",
currency="XTR",
prices=[LabeledPrice(label="Пополнение", amount=stars_amount)]
)
logger.info(f"Создан Stars invoice на {stars_amount} звезд")
logger.info(f"Создан Stars invoice на {stars_amount} звезд (~{int(amount_rubles)}₽)")
return invoice_link
except Exception as e:
logger.error(f"Ошибка создания Stars invoice: {e}")
raise
async def process_stars_payment(
self,
db: AsyncSession,
user_id: int,
stars_amount: int,
payload: str,
telegram_payment_charge_id: str
) -> bool:
try:
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
amount_kopeks = int(rubles_amount * 100)
transaction = await create_transaction(
db=db,
user_id=user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=f"Пополнение через Telegram Stars ({stars_amount} ⭐)",
payment_method=PaymentMethod.TELEGRAM_STARS,
external_id=telegram_payment_charge_id,
is_completed=True
)
user = await get_user_by_id(db, 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)
logger.info(f"💰 Баланс пользователя {user.telegram_id} изменен: {old_balance}{user.balance_kopeks} (изменение: +{amount_kopeks})")
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 ["комиссия", "бонус"]):
logger.info(f"🔞 Вызов process_referral_topup для пользователя {user_id}")
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"Ошибка обработки реферального пополнения: {e}")
else:
logger.info(f"❌ Описание '{description_for_referral}' не подходит для реферальной логики")
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"Ошибка отправки уведомления о пополнении Stars: {e}")
if self.bot:
try:
await self.bot.send_message(
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"⭐ Звезд: {stars_amount}\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🦊 Способ: Telegram Stars\n"
f"🆔 Транзакция: {telegram_payment_charge_id[:8]}...\n\n"
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
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} звезд → {int(rubles_amount)}"
)
return True
else:
logger.error(f"Пользователь с ID {user_id} не найден при обработке Stars платежа")
return False
except Exception as e:
logger.error(f"Ошибка обработки Stars платежа: {e}", exc_info=True)
return False
async def create_yookassa_payment(
self,
db: AsyncSession,
@@ -188,7 +283,29 @@ class PaymentService:
user = await get_user_by_id(db, updated_payment.user_id)
if user:
await add_user_balance(db, user, updated_payment.amount_kopeks, f"Пополнение YooKassa: {updated_payment.amount_kopeks/100:.2f}")
old_balance = user.balance_kopeks
user.balance_kopeks += updated_payment.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, updated_payment.amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения YooKassa: {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"Ошибка отправки уведомления о пополнении YooKassa: {e}")
if self.bot:
try:
@@ -196,12 +313,12 @@ class PaymentService:
user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
f"🏦 Способ: Банковская карта\n"
f"🦊 Способ: Банковская карта\n"
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
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:
@@ -240,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}")
@@ -317,17 +434,17 @@ class PaymentService:
signature: str
) -> bool:
if not settings.TRIBUTE_WEBHOOK_SECRET:
if not settings.TRIBUTE_API_KEY:
return False
try:
message = str(data).encode()
expected_signature = hmac.new(
settings.TRIBUTE_WEBHOOK_SECRET.encode(),
settings.TRIBUTE_API_KEY.encode(),
message,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
except Exception as e:
@@ -349,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
+176 -95
View File
@@ -1,20 +1,34 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from aiogram import Bot
from app.config import settings
from app.database.crud.user import add_user_balance, get_user_by_id
from app.database.crud.referral import create_referral_earning
from app.database.models import TransactionType
from app.database.models import TransactionType, ReferralEarning
logger = logging.getLogger(__name__)
async def send_referral_notification(
bot: Bot,
user_id: int,
message: str
):
try:
await bot.send_message(user_id, message, parse_mode="HTML")
logger.info(f"✅ Уведомление отправлено пользователю {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка отправки уведомления пользователю {user_id}: {e}")
async def process_referral_registration(
db: AsyncSession,
new_user_id: int,
referrer_id: int
referrer_id: int,
bot: Bot = None
):
try:
new_user = await get_user_by_id(db, new_user_id)
referrer = await get_user_by_id(db, referrer_id)
@@ -27,14 +41,6 @@ async def process_referral_registration(
logger.error(f"Пользователь {new_user_id} не привязан к рефереру {referrer_id}")
return False
if settings.REFERRED_USER_REWARD > 0:
await add_user_balance(
db, new_user, settings.REFERRED_USER_REWARD,
f"Бонус за регистрацию по реферальной ссылке"
)
logger.info(f"💰 Новый пользователь {new_user_id} получил бонус {settings.REFERRED_USER_REWARD/100}")
await create_referral_earning(
db=db,
user_id=referrer_id,
@@ -43,7 +49,27 @@ async def process_referral_registration(
reason="referral_registration_pending"
)
logger.info(f"✅ Обработана реферальная регистрация: {new_user_id} -> {referrer_id}")
if bot:
referral_notification = (
f"🎉 <b>Добро пожаловать!</b>\n\n"
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"🎁 Ваш реферер также получит награду за ваше первое пополнение."
)
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
inviter_notification = (
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)} или "
f"{settings.REFERRAL_COMMISSION_PERCENT}% от суммы (что больше).\n\n"
f"📈 С каждого последующего пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_notification)
logger.info(f"✅ Зарегистрирован реферал {new_user_id} для {referrer_id}. Бонусы будут выданы после пополнения.")
return True
except Exception as e:
@@ -51,61 +77,147 @@ async def process_referral_registration(
return False
async def process_referral_purchase(
async def process_referral_topup(
db: AsyncSession,
user_id: int,
purchase_amount_kopeks: int,
transaction_id: int = None
user_id: int,
topup_amount_kopeks: int,
bot: Bot = None
):
try:
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
logger.info(f"Пользователь {user_id} не является рефералом")
return False
return True
if topup_amount_kopeks < settings.REFERRAL_MINIMUM_TOPUP_KOPEKS:
logger.info(f"Пополнение {user_id} на {topup_amount_kopeks/100}₽ меньше минимума")
return True
referrer = await get_user_by_id(db, user.referred_by_id)
if not referrer:
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
from app.database.crud.referral import get_referral_earnings_by_referral
existing_earnings = await get_referral_earnings_by_referral(db, user_id)
purchase_earnings = [
earning for earning in existing_earnings
if earning.reason in ["referral_first_purchase", "referral_commission"]
]
is_first_purchase = len(purchase_earnings) == 0
logger.info(f"🔍 Покупка реферала {user_id}: первая = {is_first_purchase}, сумма = {purchase_amount_kopeks/100}")
if is_first_purchase and settings.REFERRAL_REGISTRATION_REWARD > 0:
reward_amount = settings.REFERRAL_REGISTRATION_REWARD
if not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
if reward_amount > 1000000:
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: reward_amount = {reward_amount} слишком большой! Проверьте настройки REFERRAL_REGISTRATION_REWARD")
reward_amount = 10000
try:
await db.execute(
delete(ReferralEarning).where(
ReferralEarning.user_id == referrer.id,
ReferralEarning.referral_id == user.id,
ReferralEarning.reason == "referral_registration_pending"
)
)
await db.commit()
logger.info(f"🗑️ Удалена запись 'ожидание пополнения' для реферала {user.id}")
except Exception as e:
logger.error(f"Ошибка удаления записи ожидания: {e}")
await add_user_balance(
db, referrer, reward_amount,
f"Реферальная награда за первую покупку {user.full_name}"
)
if settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS > 0:
await add_user_balance(
db, user, settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
f"Бонус за первое пополнение по реферальной программе",
bot=bot
)
logger.info(f"💰 Реферал {user.id} получил бонус {settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS/100}")
if bot:
bonus_notification = (
f"🎉 <b>Бонус получен!</b>\n\n"
f"За первое пополнение вы получили бонус "
f"{settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, user.telegram_id, bonus_notification)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
amount_kopeks=reward_amount,
reason="referral_first_purchase",
referral_transaction_id=transaction_id
)
logger.info(f"🎉 Первая покупка реферала: {referrer.telegram_id} получил {reward_amount/100}")
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, inviter_bonus,
f"Бонус за первое пополнение реферала {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user.id,
amount_kopeks=inviter_bonus,
reason="referral_first_topup"
)
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(inviter_bonus)}\n\n"
f"📈 Теперь с каждого его пополнения вы будете получать {settings.REFERRAL_COMMISSION_PERCENT}% комиссии."
)
await send_referral_notification(bot, referrer.telegram_id, inviter_bonus_notification)
else:
if settings.REFERRAL_COMMISSION_PERCENT > 0:
commission_amount = int(topup_amount_kopeks * settings.REFERRAL_COMMISSION_PERCENT / 100)
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {settings.REFERRAL_COMMISSION_PERCENT}% с пополнения {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission_topup"
)
logger.info(f"💰 Комиссия с пополнения: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
commission_notification = (
f"💰 <b>Реферальная комиссия!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> пополнил баланс на "
f"{settings.format_price(topup_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({settings.REFERRAL_COMMISSION_PERCENT}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, commission_notification)
return True
except Exception as e:
logger.error(f"Ошибка обработки пополнения реферала: {e}")
return False
async def process_referral_purchase(
db: AsyncSession,
user_id: int,
purchase_amount_kopeks: int,
transaction_id: int = None,
bot: Bot = None
):
try:
user = await get_user_by_id(db, user_id)
if not user or not user.referred_by_id:
return True
referrer = await get_user_by_id(db, user.referred_by_id)
if not referrer:
logger.error(f"Реферер {user.referred_by_id} не найден")
return False
if not (0 <= settings.REFERRAL_COMMISSION_PERCENT <= 100):
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный! Должен быть от 0 до 100")
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА: REFERRAL_COMMISSION_PERCENT = {settings.REFERRAL_COMMISSION_PERCENT} некорректный!")
commission_percent = 10
else:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
@@ -115,19 +227,31 @@ async def process_referral_purchase(
if commission_amount > 0:
await add_user_balance(
db, referrer, commission_amount,
f"Комиссия {commission_percent}% с покупки {user.full_name}"
f"Комиссия {commission_percent}% с покупки {user.full_name}",
bot=bot
)
await create_referral_earning(
db=db,
user_id=referrer.id,
referral_id=user_id,
referral_id=user.id,
amount_kopeks=commission_amount,
reason="referral_commission",
referral_transaction_id=transaction_id
)
logger.info(f"💰 Комиссия с покупки: {referrer.telegram_id} получил {commission_amount/100}")
if bot:
purchase_commission_notification = (
f"💰 <b>Комиссия с покупки!</b>\n\n"
f"Ваш реферал <b>{user.full_name}</b> совершил покупку на "
f"{settings.format_price(purchase_amount_kopeks)}\n\n"
f"🎁 Ваша комиссия ({commission_percent}%): "
f"{settings.format_price(commission_amount)}\n\n"
f"💎 Средства зачислены на ваш баланс."
)
await send_referral_notification(bot, referrer.telegram_id, purchase_commission_notification)
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
@@ -141,46 +265,3 @@ async def process_referral_purchase(
import traceback
logger.error(f"Полный traceback: {traceback.format_exc()}")
return False
async def get_referral_stats_for_user(db: AsyncSession, user_id: int) -> dict:
try:
from app.database.crud.referral import get_referral_earnings_sum
from sqlalchemy import select, func
from app.database.models import User
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar() or 0
paid_referrals_result = await db.execute(
select(func.count(User.id)).where(
User.referred_by_id == user_id,
User.has_had_paid_subscription == True
)
)
paid_referrals_count = paid_referrals_result.scalar() or 0
total_earned = await get_referral_earnings_sum(db, user_id) or 0
from datetime import datetime, timedelta
month_ago = datetime.utcnow() - timedelta(days=30)
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago) or 0
return {
"invited_count": invited_count,
"paid_referrals_count": paid_referrals_count,
"total_earned_kopeks": total_earned,
"month_earned_kopeks": month_earned
}
except Exception as e:
logger.error(f"Ошибка получения статистики рефералов: {e}")
return {
"invited_count": 0,
"paid_referrals_count": 0,
"total_earned_kopeks": 0,
"month_earned_kopeks": 0
}
+218 -13
View File
@@ -200,7 +200,7 @@ class RemnaWaveService:
return result
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API при получении статистики: {e}")
logger.error(f"Ошибка Remnawave API при получении статистики: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"Общая ошибка получения системной статистики: {e}")
@@ -271,11 +271,11 @@ class RemnaWaveService:
'traffic_limit_bytes': node.traffic_limit_bytes
})
logger.info(f"✅ Получено {len(result)} нод из RemnaWave")
logger.info(f"✅ Получено {len(result)} нод из Remnawave")
return result
except Exception as e:
logger.error(f"Ошибка получения нод из RemnaWave: {e}")
logger.error(f"Ошибка получения нод из Remnawave: {e}")
return []
async def test_connection(self) -> bool:
@@ -283,11 +283,11 @@ class RemnaWaveService:
try:
async with self.api as api:
stats = await api.get_system_stats()
logger.info("✅ Соединение с RemnaWave API работает")
logger.info("✅ Соединение с Remnawave API работает")
return True
except Exception as e:
logger.error(f"❌ Ошибка соединения с RemnaWave API: {e}")
logger.error(f"❌ Ошибка соединения с Remnawave API: {e}")
return False
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
@@ -378,11 +378,11 @@ class RemnaWaveService:
'inbounds': squad.inbounds
})
logger.info(f"✅ Получено {len(result)} сквадов из RemnaWave")
logger.info(f"✅ Получено {len(result)} сквадов из Remnawave")
return result
except Exception as e:
logger.error(f"Ошибка получения сквадов из RemnaWave: {e}")
logger.error(f"Ошибка получения сквадов из Remnawave: {e}")
return []
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
@@ -1119,9 +1119,6 @@ class RemnaWaveService:
return False
async def cleanup_orphaned_subscriptions(self, db: AsyncSession) -> Dict[str, int]:
"""
Усиленная версия очистки с полным удалением данных
"""
try:
stats = {"deactivated": 0, "errors": 0, "checked": 0}
@@ -1286,10 +1283,10 @@ class RemnaWaveService:
if rw_user:
subscription.remnawave_short_uuid = rw_user.short_uuid
subscription.subscription_url = rw_user.subscription_url
logger.info(f"🔧 Восстановлены данные RemnaWave для {user.telegram_id}")
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
issues_fixed += 1
except Exception as rw_error:
logger.warning(f"⚠️ Не удалось получить данные RemnaWave для {user.telegram_id}: {rw_error}")
logger.warning(f"⚠️ Не удалось получить данные Remnawave для {user.telegram_id}: {rw_error}")
if subscription.traffic_limit_gb < 0:
subscription.traffic_limit_gb = 0
@@ -1356,7 +1353,7 @@ class RemnaWaveService:
recommendations["should_sync"] = True
recommendations["sync_type"] = "all"
recommendations["priority"] = "high"
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с RemnaWave")
recommendations["reasons"].append(f"Найдено {users_without_uuid} пользователей без связи с Remnawave")
recommendations["estimated_time"] = "3-5 минут"
if active_expired > 5:
@@ -1382,3 +1379,211 @@ class RemnaWaveService:
"priority": "medium",
"estimated_time": "3-5 минут"
}
async def monitor_panel_status(self, bot) -> Dict[str, Any]:
try:
from app.utils.cache import cache
previous_status = await cache.get("remnawave_panel_status") or "unknown"
status_result = await self.check_panel_health()
current_status = status_result.get("status", "offline")
if current_status != previous_status and previous_status != "unknown":
await self._send_status_change_notification(
bot,
previous_status,
current_status,
status_result
)
await cache.set("remnawave_panel_status", current_status, expire=300)
return status_result
except Exception as e:
logger.error(f"Ошибка мониторинга статуса панели Remnawave: {e}")
return {"status": "error", "error": str(e)}
async def _send_status_change_notification(
self,
bot,
old_status: str,
new_status: str,
status_data: Dict[str, Any]
):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
details = {
"api_url": status_data.get("api_url"),
"response_time": status_data.get("response_time"),
"last_check": status_data.get("last_check"),
"users_online": status_data.get("users_online"),
"nodes_online": status_data.get("nodes_online"),
"total_nodes": status_data.get("total_nodes"),
"old_status": old_status
}
if new_status == "offline":
details["error"] = status_data.get("api_error")
elif new_status == "degraded":
issues = []
if status_data.get("response_time", 0) > 10:
issues.append(f"Медленный отклик API ({status_data.get('response_time')}с)")
if status_data.get("nodes_health") == "unhealthy":
issues.append(f"Проблемы с нодами ({status_data.get('nodes_online')}/{status_data.get('total_nodes')} онлайн)")
details["issues"] = issues
await notification_service.send_remnawave_panel_status_notification(
new_status,
details
)
logger.info(f"Отправлено уведомление об изменении статуса панели: {old_status} -> {new_status}")
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении статуса: {e}")
async def send_manual_status_notification(self, bot, status: str, message: str = ""):
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(bot)
details = {
"api_url": settings.REMNAWAVE_API_URL,
"last_check": datetime.utcnow(),
"manual_message": message
}
if status == "maintenance":
details["maintenance_reason"] = message or "Плановое обслуживание"
await notification_service.send_remnawave_panel_status_notification(status, details)
logger.info(f"Отправлено ручное уведомление о статусе панели: {status}")
return True
except Exception as e:
logger.error(f"Ошибка отправки ручного уведомления: {e}")
return False
async def get_panel_status_summary(self) -> Dict[str, Any]:
try:
status_data = await self.check_panel_health()
status_descriptions = {
"online": "🟢 Панель работает нормально",
"offline": "🔴 Панель недоступна",
"degraded": "🟡 Панель работает со сбоями",
"maintenance": "🔧 Панель на обслуживании"
}
status = status_data.get("status", "offline")
summary = {
"status": status,
"description": status_descriptions.get(status, "❓ Статус неизвестен"),
"response_time": status_data.get("response_time", 0),
"api_available": status_data.get("api_available", False),
"nodes_status": f"{status_data.get('nodes_online', 0)}/{status_data.get('total_nodes', 0)} нод онлайн",
"users_online": status_data.get("users_online", 0),
"last_check": status_data.get("last_check"),
"has_issues": status in ["offline", "degraded"]
}
if status == "offline":
summary["recommendation"] = "Проверьте подключение к серверу и работоспособность панели"
elif status == "degraded":
summary["recommendation"] = "Рекомендуется проверить состояние нод и производительность сервера"
else:
summary["recommendation"] = "Все системы работают нормально"
return summary
except Exception as e:
logger.error(f"Ошибка получения сводки статуса панели: {e}")
return {
"status": "error",
"description": "❌ Ошибка проверки статуса",
"response_time": 0,
"api_available": False,
"nodes_status": "неизвестно",
"users_online": 0,
"last_check": datetime.utcnow(),
"has_issues": True,
"recommendation": "Обратитесь к системному администратору",
"error": str(e)
}
async def check_panel_health(self) -> Dict[str, Any]:
try:
start_time = datetime.utcnow()
async with self.api as api:
try:
system_stats = await api.get_system_stats()
api_available = True
api_error = None
except Exception as e:
api_available = False
api_error = str(e)
system_stats = {}
try:
nodes = await api.get_all_nodes()
nodes_online = sum(1 for node in nodes if node.is_connected and node.is_node_online)
total_nodes = len(nodes)
nodes_health = "healthy" if nodes_online > 0 else "unhealthy"
except Exception:
nodes_online = 0
total_nodes = 0
nodes_health = "unknown"
end_time = datetime.utcnow()
response_time = (end_time - start_time).total_seconds()
if not api_available:
status = "offline"
elif response_time > 10:
status = "degraded"
elif nodes_health == "unhealthy":
status = "degraded"
else:
status = "online"
return {
"status": status,
"api_available": api_available,
"api_error": api_error,
"response_time": round(response_time, 2),
"nodes_online": nodes_online,
"total_nodes": total_nodes,
"nodes_health": nodes_health,
"users_online": system_stats.get('onlineStats', {}).get('onlineNow', 0),
"total_users": system_stats.get('users', {}).get('totalUsers', 0),
"last_check": end_time,
"api_url": settings.REMNAWAVE_API_URL
}
except Exception as e:
logger.error(f"Ошибка проверки здоровья панели: {e}")
return {
"status": "offline",
"api_available": False,
"api_error": str(e),
"response_time": 0,
"nodes_online": 0,
"total_nodes": 0,
"nodes_health": "unknown",
"last_check": datetime.utcnow(),
"api_url": settings.REMNAWAVE_API_URL
}
+160 -17
View File
@@ -10,6 +10,12 @@ from app.external.remnawave_api import (
TrafficLimitStrategy, RemnaWaveAPIError
)
from app.database.crud.user import get_user_by_id
from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation
)
logger = logging.getLogger(__name__)
@@ -229,36 +235,40 @@ class SubscriptionService:
devices: int,
db: AsyncSession
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
from app.config import PERIOD_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price = TRAFFIC_PRICES.get(traffic_gb, 0)
traffic_price = settings.get_traffic_price(traffic_gb)
server_prices = []
total_servers_price = 0
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_prices.append(server.price_kopeks)
total_servers_price += server.price_kopeks
logger.debug(f"🏷️ Сервер {server.display_name}: {server.price_kopeks/100}")
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}")
else:
server_prices.append(0)
logger.warning(f"⚠️ Сервер ID {server_id} недоступен")
logger.warning(f"Сервер ID {server_id} недоступен")
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
total_price = base_price + traffic_price + total_servers_price + devices_price
logger.info(f"💰 Расчет стоимости новой подписки:")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 📊 Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" 🌍 Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" 📱 Устройства ({devices}): {devices_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
logger.info(f"Расчет стоимости новой подписки:")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({devices}): {devices_price/100}")
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
@@ -269,7 +279,7 @@ class SubscriptionService:
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES, TRAFFIC_PRICES
from app.config import PERIOD_PRICES
base_price = PERIOD_PRICES.get(period_days, 0)
@@ -279,7 +289,7 @@ class SubscriptionService:
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
traffic_price = TRAFFIC_PRICES.get(subscription.traffic_limit_gb, 0)
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
total_price = base_price + servers_price + devices_price + traffic_price
@@ -384,6 +394,139 @@ class SubscriptionService:
except Exception as e:
logger.error(f"Ошибка получения цен стран: {e}")
return len(country_uuids) * 1000
async def calculate_subscription_price_with_months(
self,
period_days: int,
traffic_gb: int,
server_squad_ids: List[int],
devices: int,
db: AsyncSession
) -> Tuple[int, List[int]]:
from app.config import PERIOD_PRICES
from app.database.crud.server_squad import get_server_squad_by_id
if settings.MAX_DEVICES_LIMIT > 0 and devices > settings.MAX_DEVICES_LIMIT:
raise ValueError(f"Превышен максимальный лимит устройств: {settings.MAX_DEVICES_LIMIT}")
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
total_traffic_price = traffic_price_per_month * months_in_period
server_prices = []
total_servers_price = 0
for server_id in server_squad_ids:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available and not server.is_full:
server_price_per_month = server.price_kopeks
server_price_total = server_price_per_month * months_in_period
server_prices.append(server_price_total)
total_servers_price += server_price_total
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}")
else:
server_prices.append(0)
logger.warning(f"Сервер ID {server_id} недоступен")
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
logger.info(f" Период {period_days} дней: {base_price/100}")
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}")
logger.info(f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" ИТОГО: {total_price/100}")
return total_price, server_prices
async def calculate_renewal_price_with_months(
self,
subscription: Subscription,
period_days: int,
db: AsyncSession
) -> int:
try:
from app.config import PERIOD_PRICES
months_in_period = calculate_months_from_days(period_days)
base_price = PERIOD_PRICES.get(period_days, 0)
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
subscription.connected_squads, db
)
total_servers_price = servers_price_per_month * months_in_period
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_devices_price = devices_price_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
total_traffic_price = traffic_price_per_month * months_in_period
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
logger.info(f" 📅 Период {period_days} дней: {base_price/100}")
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}")
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}")
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}")
logger.info(f" 💎 ИТОГО: {total_price/100}")
return total_price
except Exception as e:
logger.error(f"Ошибка расчета стоимости продления: {e}")
from app.config import PERIOD_PRICES
return PERIOD_PRICES.get(period_days, 0)
async def calculate_addon_price_with_remaining_period(
self,
subscription: Subscription,
additional_traffic_gb: int = 0,
additional_devices: int = 0,
additional_server_ids: List[int] = None,
db: AsyncSession = None
) -> int:
if additional_server_ids is None:
additional_server_ids = []
current_time = datetime.utcnow()
months_to_pay = get_remaining_months(subscription.end_date)
total_price = 0
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
total_price += traffic_price_per_month * months_to_pay
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}")
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
total_price += devices_price_per_month * months_to_pay
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}")
if additional_server_ids and db:
for server_id in additional_server_ids:
from app.database.crud.server_squad import get_server_squad_by_id
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
server_total_price = server_price_per_month * months_to_pay
total_price += server_total_price
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}")
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}")
return total_price
def _gb_to_bytes(self, gb: int) -> int:
if gb == 0:
+52 -44
View File
@@ -1,6 +1,4 @@
import logging
import hashlib
import hmac
import json
from typing import Optional, Dict, Any
from datetime import datetime
@@ -56,15 +54,9 @@ class TributeService:
async def process_webhook(
self,
payload: str,
signature: Optional[str] = None
payload: str
) -> Dict[str, Any]:
if signature and settings.TRIBUTE_WEBHOOK_SECRET:
if not self.tribute_api.verify_webhook_signature(payload, signature):
logger.warning("Неверная подпись Tribute webhook")
return {"status": "error", "reason": "invalid_signature"}
try:
webhook_data = json.loads(payload)
except json.JSONDecodeError:
@@ -90,37 +82,38 @@ class TributeService:
return {"status": "ok", "event": event_type}
async def _handle_successful_payment(self, payment_data: Dict[str, Any]):
"""Обработка успешного платежа - ПОЛНОСТЬЮ ПЕРЕРАБОТАННАЯ ВЕРСИЯ"""
try:
user_id = payment_data["user_id"]
user_telegram_id = payment_data["user_id"]
amount_kopeks = payment_data["amount_kopeks"]
payment_id = payment_data["payment_id"]
logger.info(f"Обрабатываем успешный Tribute платеж: user_id={user_id}, amount={amount_kopeks}, payment_id={payment_id}")
logger.info(f"Обрабатываем успешный Tribute платеж: user_telegram_id={user_telegram_id}, amount={amount_kopeks}, payment_id={payment_id}")
async for session in get_db():
from app.database.crud.transaction import check_tribute_payment_duplicate, create_unique_tribute_transaction
duplicate_transaction = await check_tribute_payment_duplicate(
session, payment_id, amount_kopeks, user_id
)
if duplicate_transaction:
logger.warning(f"Найден дубликат платежа:")
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
logger.warning(f" Created: {duplicate_transaction.created_at}")
logger.warning(f"Платеж игнорирован")
return
user = await get_user_by_telegram_id(session, user_id)
user = await get_user_by_telegram_id(session, user_telegram_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
logger.error(f"Пользователь {user_telegram_id} не найден")
return
logger.info(f"Найден пользователь {user.telegram_id}, текущий баланс: {user.balance_kopeks} коп")
from app.database.crud.transaction import check_tribute_payment_duplicate
duplicate_transaction = await check_tribute_payment_duplicate(
session, payment_id, amount_kopeks, user_telegram_id
)
if duplicate_transaction:
logger.warning(f"Найден дубликат платежа в течение 24ч:")
logger.warning(f" Transaction ID: {duplicate_transaction.id}")
logger.warning(f" Amount: {duplicate_transaction.amount_kopeks} коп")
logger.warning(f" Created: {duplicate_transaction.created_at}")
logger.warning(f" External ID: {duplicate_transaction.external_id}")
logger.warning(f"Платеж игнорирован - это дубликат свежего платежа")
return
from app.database.crud.transaction import create_unique_tribute_transaction
transaction = await create_unique_tribute_transaction(
db=session,
user_id=user.id,
@@ -135,18 +128,38 @@ class TributeService:
await session.commit()
logger.info(f"Баланс пользователя {user_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
logger.info(f"Баланс пользователя {user_telegram_id} обновлен: {old_balance} -> {user.balance_kopeks} коп (+{amount_kopeks})")
logger.info(f"✅ Создана транзакция ID: {transaction.id}")
await self._send_success_notification(user_id, amount_kopeks)
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(session, user.id, amount_kopeks, self.bot)
except Exception as e:
logger.error(f"Ошибка обработки реферального пополнения Tribute: {e}")
if not user.has_made_first_topup:
user.has_made_first_topup = True
logger.info(f"Отмечен первый топап для пользователя {user_telegram_id}")
logger.info(f"Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_id}")
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
session, user, transaction, old_balance
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о Tribute пополнении: {e}")
await self._send_success_notification(user_telegram_id, amount_kopeks)
logger.info(f"🎉 Успешно обработан Tribute платеж: {amount_kopeks/100}₽ для пользователя {user_telegram_id}")
break
except Exception as e:
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
logger.error(f"Ошибка обработки успешного Tribute платежа: {e}", exc_info=True)
async def _handle_failed_payment(self, payment_data: Dict[str, Any]):
"""Обработка неудачного платежа"""
try:
user_id = payment_data["user_id"]
@@ -170,7 +183,6 @@ class TributeService:
logger.error(f"Ошибка обработки неудачного Tribute платежа: {e}")
async def _handle_refund(self, refund_data: Dict[str, Any]):
"""Обработка возврата"""
try:
user_id = refund_data["user_id"]
@@ -203,14 +215,13 @@ class TributeService:
logger.error(f"Ошибка обработки возврата Tribute: {e}")
async def _send_success_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления об успешном платеже"""
try:
amount_rubles = amount_kopeks / 100
text = (
f"✅ **Платеж успешно получен!**\n\n"
f"💰 Сумма: {amount_rubles:.2f}\n"
f"💰 Сумма: {int(amount_rubles)}\n"
f"💳 Способ оплаты: Tribute\n"
f"🎉 Средства зачислены на баланс!\n\n"
f"Спасибо за оплату! 🙏"
@@ -232,11 +243,10 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления об успешном платеже: {e}")
async def _send_failure_notification(self, user_id: int):
"""Отправка уведомления о неудачном платеже"""
try:
text = (
" **Платеж не прошел**\n\n"
" **Платеж не прошел**\n\n"
"К сожалению, ваш платеж через Tribute был отклонен.\n\n"
"Возможные причины:\n"
"• Недостаточно средств на карте\n"
@@ -261,14 +271,13 @@ class TributeService:
logger.error(f"Ошибка отправки уведомления о неудачном платеже: {e}")
async def _send_refund_notification(self, user_id: int, amount_kopeks: int):
"""Отправка уведомления о возврате"""
try:
amount_rubles = amount_kopeks / 100
text = (
f"🔄 **Возврат средств**\n\n"
f"💰 Сумма возврата: {amount_rubles:.2f}\n"
f"💰 Сумма возврата: {int(amount_rubles)}\n"
f"💳 Способ: Tribute\n\n"
f"Средства будут возвращены на вашу карту в течение 3-5 рабочих дней.\n\n"
f"Если у вас есть вопросы, обратитесь в поддержку."
@@ -296,7 +305,6 @@ class TributeService:
amount_kopeks: int,
description: str = "Принудительная обработка Tribute платежа"
) -> bool:
"""Принудительная обработка платежа (для отладки)"""
try:
logger.info(f"🔧 ПРИНУДИТЕЛЬНАЯ ОБРАБОТКА: payment_id={payment_id}, user_id={user_id}, amount={amount_kopeks}")
@@ -304,7 +312,7 @@ class TributeService:
async for session in get_db():
user = await get_user_by_telegram_id(session, user_id)
if not user:
logger.error(f" Пользователь {user_id} не найден")
logger.error(f" Пользователь {user_id} не найден")
return False
external_id = f"force_donation_{payment_id}_{int(datetime.utcnow().timestamp())}"
@@ -334,7 +342,7 @@ class TributeService:
return True
except Exception as e:
logger.error(f" Ошибка принудительной обработки: {e}", exc_info=True)
logger.error(f" Ошибка принудительной обработки: {e}", exc_info=True)
return False
async def get_payment_status(self, payment_id: str) -> Optional[Dict[str, Any]]:
+156 -45
View File
@@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
from sqlalchemy import delete, select, update
from app.database.crud.user import (
get_user_by_id, get_user_by_telegram_id, get_users_list,
@@ -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
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory, CryptoBotPayment
)
from app.config import settings
@@ -244,7 +244,145 @@ class UserService:
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} деактивирован")
except Exception as e:
logger.error(f" Ошибка деактивации RemnaWave пользователя: {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
yookassa_result = await db.execute(
select(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
yookassa_payments = yookassa_result.scalars().all()
if yookassa_payments:
logger.info(f"🔄 Удаляем {len(yookassa_payments)} YooKassa платежей")
await db.execute(
delete(YooKassaPayment).where(YooKassaPayment.user_id == user_id)
)
await db.flush()
logger.info(f"✅ YooKassa платежи удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления YooKassa платежей: {e}")
try:
from app.database.models import CryptoBotPayment
from sqlalchemy import select, delete
cryptobot_result = await db.execute(
select(CryptoBotPayment).where(CryptoBotPayment.user_id == user_id)
)
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(
select(Transaction).where(Transaction.user_id == user_id)
)
transactions = transactions_result.scalars().all()
if transactions:
logger.info(f"🔄 Удаляем {len(transactions)} транзакций")
await db.execute(
delete(Transaction).where(Transaction.user_id == user_id)
)
await db.flush()
logger.info(f"✅ Транзакции удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления транзакций: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
try:
from app.database.models import BroadcastHistory
await db.execute(
delete(BroadcastHistory).where(BroadcastHistory.admin_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалена история рассылок админа {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления истории рассылок: {e}")
try:
from app.database.models import SubscriptionConversion
conversions_result = await db.execute(
select(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
conversions = conversions_result.scalars().all()
if conversions:
logger.info(f"🔄 Удаляем {len(conversions)} записей конверсий")
await db.execute(
delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user_id)
)
await db.flush()
logger.info(f"✅ Записи конверсий удалены")
except Exception as e:
logger.error(f"❌ Ошибка удаления записей конверсий: {e}")
if user.subscription:
try:
@@ -253,74 +391,47 @@ class UserService:
SubscriptionServer.subscription_id == user.subscription.id
)
)
await db.flush()
logger.info(f"🗑️ Удалены записи SubscriptionServer для подписки {user.subscription.id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления SubscriptionServer: {e}")
if user.subscription:
try:
await db.delete(user.subscription)
from app.database.models import Subscription
await db.execute(
delete(Subscription).where(Subscription.user_id == user_id)
)
await db.flush()
logger.info(f"🗑️ Удалена подписка пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления подписки: {e}")
try:
await db.execute(
delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id)
)
logger.info(f"🗑️ Удалены использования промокодов пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления использований промокодов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.user_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные доходы пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных доходов: {e}")
try:
await db.execute(
delete(ReferralEarning).where(ReferralEarning.referral_id == user.id)
)
logger.info(f"🗑️ Удалены реферальные записи о пользователе {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления реферальных записей: {e}")
try:
from sqlalchemy import update
await db.execute(
referrals_result = await db.execute(
update(User)
.where(User.referred_by_id == user.id)
.where(User.referred_by_id == user_id)
.values(referred_by_id=None)
)
logger.info(f"🗑️ Очищены реферальные ссылки у рефералов пользователя {user_id}")
if referrals_result.rowcount > 0:
logger.info(f"🔗 Очищены реферальные ссылки у {referrals_result.rowcount} рефералов")
await db.flush()
except Exception as e:
logger.error(f"❌ Ошибка очистки реферальных ссылок: {e}")
try:
await db.execute(
delete(Transaction).where(Transaction.user_id == user.id)
delete(User).where(User.id == user_id)
)
logger.info(f"🗑️ Удалены транзакции пользователя {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка удаления транзакций: {e}")
try:
user.status = UserStatus.DELETED.value
user.balance_kopeks = 0
user.remnawave_uuid = None
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} помечен как удаленный и обнулен")
logger.info(f"✅ Пользователь {user_id} окончательно удален из базы")
except Exception as e:
logger.error(f"❌ Ошибка обновления статуса пользователя: {e}")
logger.error(f"❌ Ошибка финального удаления пользователя: {e}")
await db.rollback()
return False
logger.info(f"✅ Пользователь {user_id} (Telegram ID: {user.telegram_id}) полностью удален админом {admin_id}")
logger.info(f"✅ Пользователь {user.telegram_id} (ID: {user_id}) полностью удален администратором {admin_id}")
return True
except Exception as e:
+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()
+6
View File
@@ -52,6 +52,8 @@ class AdminStates(StatesGroup):
editing_squad_price = State()
editing_traffic_price = State()
editing_device_price = State()
editing_user_devices = State()
editing_user_traffic = State()
editing_rules_page = State()
@@ -67,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):
+13 -3
View File
@@ -1,3 +1,13 @@
"""
Утилиты
"""
from .pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
format_period_description
)
__all__ = [
'calculate_months_from_days',
'get_remaining_months',
'calculate_prorated_price',
'format_period_description'
]
+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,
)
+94
View File
@@ -0,0 +1,94 @@
from datetime import datetime, timedelta
from typing import Tuple
import logging
logger = logging.getLogger(__name__)
def calculate_months_from_days(days: int) -> int:
return max(1, round(days / 30))
def get_remaining_months(end_date: datetime) -> int:
current_time = datetime.utcnow()
if end_date <= current_time:
return 1
remaining_days = (end_date - current_time).days
return max(1, round(remaining_days / 30))
def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
exact_months = period_days / 30
months_count = max(1, round(exact_months))
logger.debug(f"Период {period_days} дней = {exact_months:.2f} точных месяцев ≈ {months_count} месяцев для расчета")
return months_count, exact_months
def calculate_prorated_price(
monthly_price: int,
end_date: datetime,
min_charge_months: int = 1
) -> Tuple[int, int]:
months_remaining = get_remaining_months(end_date)
months_to_charge = max(min_charge_months, months_remaining)
total_price = monthly_price * months_to_charge
logger.debug(f"Расчет пропорциональной цены: {monthly_price/100}₽/мес × {months_to_charge} мес = {total_price/100}")
return total_price, months_to_charge
def format_period_description(days: int, language: str = "ru") -> str:
months = calculate_months_from_days(days)
if language == "ru":
if days == 30:
return "1 месяц"
elif days == 60:
return "2 месяца"
elif days == 90:
return "3 месяца"
elif days == 180:
return "6 месяцев"
elif days == 360:
return "12 месяцев"
else:
month_word = "месяц" if months == 1 else ("месяца" if 2 <= months <= 4 else "месяцев")
return f"{days} дней ({months} {month_word})"
else:
month_word = "month" if months == 1 else "months"
return f"{days} days ({months} {month_word})"
def validate_pricing_calculation(
base_price: int,
monthly_additions: int,
months: int,
total_calculated: int
) -> bool:
expected_total = base_price + (monthly_additions * months)
is_valid = expected_total == total_calculated
if not is_valid:
logger.warning(f"Несоответствие в расчете цены: ожидалось {expected_total/100}₽, получено {total_calculated/100}")
logger.warning(f"Детали: базовая цена {base_price/100}₽ + месячные дополнения {monthly_additions/100}× {months} мес")
return is_valid
STANDARD_PERIODS = {
14: {"months": 0.5, "display_ru": "2 недели", "display_en": "2 weeks"},
30: {"months": 1, "display_ru": "1 месяц", "display_en": "1 month"},
60: {"months": 2, "display_ru": "2 месяца", "display_en": "2 months"},
90: {"months": 3, "display_ru": "3 месяца", "display_en": "3 months"},
180: {"months": 6, "display_ru": "6 месяцев", "display_en": "6 months"},
360: {"months": 12, "display_ru": "1 год", "display_en": "1 year"},
}
def get_period_info(days: int) -> dict:
return STANDARD_PERIODS.get(days)
+278 -56
View File
@@ -1,76 +1,298 @@
import logging
import random
import secrets
import string
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional, Dict, List
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database.models import User
from sqlalchemy.orm import selectinload
from app.database.models import User, ReferralEarning, Transaction, TransactionType
logger = logging.getLogger(__name__)
async def mark_user_as_had_paid_subscription(
db: AsyncSession,
user: User
) -> None:
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"🎯 Пользователь {user.telegram_id} отмечен как имевший платную подписку")
async def generate_unique_referral_code(db: AsyncSession, telegram_id: int) -> str:
max_attempts = 10
base_code = str(telegram_id)[-6:]
for attempt in range(10):
if attempt == 0:
referral_code = base_code
else:
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=2))
referral_code = base_code + suffix
for _ in range(max_attempts):
code = f"ref{''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(8))}"
result = await db.execute(
select(User.id).where(User.referral_code == referral_code)
select(User).where(User.referral_code == code)
)
if not result.scalar_one_or_none():
return code
timestamp = str(int(datetime.utcnow().timestamp()))[-6:]
return f"ref{timestamp}"
async def mark_user_as_had_paid_subscription(db: AsyncSession, user_id: int) -> bool:
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.error(f"Пользователь {user_id} не найден")
return False
if not user.has_had_paid_subscription:
user.has_had_paid_subscription = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Пользователь {user_id} отмечен как имевший платную подписку")
return True
except Exception as e:
logger.error(f"Ошибка отметки пользователя {user_id} как имевшего платную подписку: {e}")
return False
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> Dict:
try:
invited_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
invited_count = invited_count_result.scalar() or 0
referrals_result = await db.execute(
select(User).where(User.referred_by_id == user_id)
)
referrals = referrals_result.scalars().all()
paid_referrals_count = sum(1 for ref in referrals if ref.has_made_first_topup)
total_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id == user_id)
)
total_earned_kopeks = total_earnings_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= month_ago
)
)
)
month_earned_kopeks = month_earnings_result.scalar() or 0
recent_earnings_result = await db.execute(
select(ReferralEarning)
.options(selectinload(ReferralEarning.referral))
.where(ReferralEarning.user_id == user_id)
.order_by(ReferralEarning.created_at.desc())
.limit(5)
)
recent_earnings_raw = recent_earnings_result.scalars().all()
recent_earnings = []
for earning in recent_earnings_raw:
if earning.referral:
recent_earnings.append({
'amount_kopeks': earning.amount_kopeks,
'reason': earning.reason,
'referral_name': earning.referral.full_name,
'created_at': earning.created_at
})
earnings_by_type = {}
earnings_by_type_result = await db.execute(
select(
ReferralEarning.reason,
func.count(ReferralEarning.id).label('count'),
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_amount')
)
.where(ReferralEarning.user_id == user_id)
.group_by(ReferralEarning.reason)
)
if not result.scalar():
return referral_code
import uuid
return str(uuid.uuid4())[:8]
async def get_user_referral_summary(db: AsyncSession, user_id: int) -> dict:
try:
from app.services.referral_service import get_referral_stats_for_user
from app.database.crud.referral import get_referral_earnings_by_user
for row in earnings_by_type_result:
earnings_by_type[row.reason] = {
'count': row.count,
'total_amount_kopeks': row.total_amount
}
stats = await get_referral_stats_for_user(db, user_id)
recent_earnings = await get_referral_earnings_by_user(db, user_id, limit=5)
active_referrals_count = 0
for referral in referrals:
if referral.last_activity and referral.last_activity >= month_ago:
active_referrals_count += 1
return {
**stats,
"recent_earnings": [
{
"amount_kopeks": earning.amount_kopeks,
"reason": earning.reason,
"created_at": earning.created_at,
"referral_name": earning.referral.full_name if earning.referral else "Неизвестно"
}
for earning in recent_earnings
]
'invited_count': invited_count,
'paid_referrals_count': paid_referrals_count,
'active_referrals_count': active_referrals_count,
'total_earned_kopeks': total_earned_kopeks,
'month_earned_kopeks': month_earned_kopeks,
'recent_earnings': recent_earnings,
'earnings_by_type': earnings_by_type,
'conversion_rate': round((paid_referrals_count / invited_count * 100) if invited_count > 0 else 0, 1)
}
except Exception as e:
logger.error(f"Ошибка получения сводки рефералов: {e}")
logger.error(f"Ошибка получения статистики рефералов для пользователя {user_id}: {e}")
return {
"invited_count": 0,
"paid_referrals_count": 0,
"total_earned_kopeks": 0,
"month_earned_kopeks": 0,
"recent_earnings": []
}
'invited_count': 0,
'paid_referrals_count': 0,
'active_referrals_count': 0,
'total_earned_kopeks': 0,
'month_earned_kopeks': 0,
'recent_earnings': [],
'earnings_by_type': {},
'conversion_rate': 0.0
}
async def get_detailed_referral_list(db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0) -> Dict:
try:
referrals_result = await db.execute(
select(User)
.where(User.referred_by_id == user_id)
.order_by(User.created_at.desc())
.offset(offset)
.limit(limit)
)
referrals = referrals_result.scalars().all()
total_count_result = await db.execute(
select(func.count(User.id)).where(User.referred_by_id == user_id)
)
total_count = total_count_result.scalar() or 0
detailed_referrals = []
for referral in referrals:
earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.referral_id == referral.id
)
)
)
total_earned_from_referral = earnings_result.scalar() or 0
topups_result = await db.execute(
select(func.count(Transaction.id))
.where(
and_(
Transaction.user_id == referral.id,
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.is_completed == True
)
)
)
topups_count = topups_result.scalar() or 0
days_since_registration = (datetime.utcnow() - referral.created_at).days
days_since_activity = None
if referral.last_activity:
days_since_activity = (datetime.utcnow() - referral.last_activity).days
detailed_referrals.append({
'id': referral.id,
'telegram_id': referral.telegram_id,
'full_name': referral.full_name,
'username': referral.username,
'created_at': referral.created_at,
'last_activity': referral.last_activity,
'has_made_first_topup': referral.has_made_first_topup,
'balance_kopeks': referral.balance_kopeks,
'total_earned_kopeks': total_earned_from_referral,
'topups_count': topups_count,
'days_since_registration': days_since_registration,
'days_since_activity': days_since_activity,
'status': 'active' if days_since_activity is not None and days_since_activity <= 30 else 'inactive'
})
return {
'referrals': detailed_referrals,
'total_count': total_count,
'has_next': offset + limit < total_count,
'has_prev': offset > 0,
'current_page': (offset // limit) + 1,
'total_pages': (total_count + limit - 1) // limit
}
except Exception as e:
logger.error(f"Ошибка получения списка рефералов для пользователя {user_id}: {e}")
return {
'referrals': [],
'total_count': 0,
'has_next': False,
'has_prev': False,
'current_page': 1,
'total_pages': 1
}
async def get_referral_analytics(db: AsyncSession, user_id: int) -> Dict:
try:
now = datetime.utcnow()
periods = {
'today': now.replace(hour=0, minute=0, second=0, microsecond=0),
'week': now - timedelta(days=7),
'month': now - timedelta(days=30),
'quarter': now - timedelta(days=90)
}
earnings_by_period = {}
for period_name, start_date in periods.items():
result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == user_id,
ReferralEarning.created_at >= start_date
)
)
)
earnings_by_period[period_name] = result.scalar() or 0
top_referrals_result = await db.execute(
select(
ReferralEarning.referral_id,
func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0).label('total_earned'),
func.count(ReferralEarning.id).label('earnings_count')
)
.where(ReferralEarning.user_id == user_id)
.group_by(ReferralEarning.referral_id)
.order_by(func.sum(ReferralEarning.amount_kopeks).desc())
.limit(5)
)
top_referrals = []
for row in top_referrals_result:
referral_result = await db.execute(
select(User).where(User.id == row.referral_id)
)
referral = referral_result.scalar_one_or_none()
if referral:
top_referrals.append({
'referral_name': referral.full_name,
'total_earned_kopeks': row.total_earned,
'earnings_count': row.earnings_count
})
return {
'earnings_by_period': earnings_by_period,
'top_referrals': top_referrals
}
except Exception as e:
logger.error(f"Ошибка получения аналитики рефералов для пользователя {user_id}: {e}")
return {
'earnings_by_period': {
'today': 0,
'week': 0,
'month': 0,
'quarter': 0
},
'top_referrals': []
}
+75 -6
View File
@@ -1,7 +1,18 @@
import re
from typing import Optional, Union
from datetime import datetime
import html
ALLOWED_HTML_TAGS = {
'b', 'strong',
'i', 'em',
'u', 'ins',
's', 'strike', 'del',
'code',
'pre',
'a',
'blockquote'
}
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
@@ -105,13 +116,24 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]:
def sanitize_html(text: str) -> str:
allowed_tags = ['b', 'strong', 'i', 'em', 'u', 'ins', 's', 'strike', 'del', 'code', 'pre']
if not text:
return text
for tag in allowed_tags:
text = re.sub(f'<{tag}>', f'<{tag}>', text, flags=re.IGNORECASE)
text = re.sub(f'</{tag}>', f'</{tag}>', text, flags=re.IGNORECASE)
text = html.escape(text)
text = re.sub(r'<(?!/?(?:' + '|'.join(allowed_tags) + r')\b)[^>]*>', '', text)
for tag in ALLOWED_HTML_TAGS:
text = re.sub(
f'&lt;{tag}(&gt;|\\s[^&]*&gt;)',
lambda m: m.group(0).replace('&lt;', '<').replace('&gt;', '>'),
text,
flags=re.IGNORECASE
)
text = re.sub(
f'&lt;/{tag}&gt;',
f'</{tag}>',
text,
flags=re.IGNORECASE
)
return text
@@ -134,4 +156,51 @@ def validate_referral_code(code: str) -> bool:
user_id_part = code[3:]
return user_id_part.isdigit()
return validate_promocode(code)
return validate_promocode(code)
def validate_html_tags(text: str) -> tuple[bool, str]:
if not text:
return True, ""
tag_pattern = r'<(/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*>'
tags = re.findall(tag_pattern, text)
for is_closing, tag_name in tags:
tag_name_lower = tag_name.lower()
if tag_name_lower not in ALLOWED_HTML_TAGS:
return False, f"Неподдерживаемый тег: <{tag_name}>"
tag_stack = []
for is_closing, tag_name in tags:
tag_name_lower = tag_name.lower()
if not is_closing:
tag_stack.append(tag_name_lower)
else:
if not tag_stack:
return False, f"Закрывающий тег без открывающего: </{tag_name}>"
last_tag = tag_stack.pop()
if last_tag != tag_name_lower:
return False, f"Неправильная вложенность тегов: ожидался </{last_tag}>, найден </{tag_name}>"
if tag_stack:
return False, f"Незакрытый тег: <{tag_stack[-1]}>"
return True, ""
def get_html_help_text() -> str:
return """<b>Поддерживаемые HTML теги:</b>
- <code>&lt;b&gt;жирный&lt;/b&gt;</code> или <code>&lt;strong&gt;жирный&lt;/strong&gt;</code>
- <code>&lt;i&gt;курсив&lt;/i&gt;</code> или <code>&lt;em&gt;курсив&lt;/em&gt;</code>
- <code>&lt;u&gt;подчеркнутый&lt;/u&gt;</code>
- <code>&lt;s&gt;зачеркнутый&lt;/s&gt;</code>
- <code>&lt;code&gt;моноширинный&lt;/code&gt;</code>
- <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>
<b>Неподдерживаемые теги:</b> &lt;br&gt;, &lt;p&gt;, &lt;div&gt;, &lt;span&gt;, &lt;spoiler&gt; и другие"""
+29 -10
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:
@@ -46,28 +46,46 @@ services:
env_file:
- .env
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-remnawave_user}:${POSTGRES_PASSWORD:-secure_password_123}@postgres:5432/${POSTGRES_DB:-remnawave_bot}
REDIS_URL: redis://redis:6379/0
DOCKER_ENV: "true"
DATABASE_MODE: "auto"
POSTGRES_HOST: "postgres"
POSTGRES_PORT: "5432"
POSTGRES_DB: "${POSTGRES_DB:-remnawave_bot}"
POSTGRES_USER: "${POSTGRES_USER:-remnawave_user}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-secure_password_123}"
REDIS_URL: "redis://redis:6379/0"
TZ: "Europe/Moscow"
volumes:
# Логи
- ./logs:/app/logs:rw
# Данные приложения (для SQLite в случае переключения)
- ./data:/app/data:rw
# Конфигурация приложения
# - ./app-config.json:/app/app-config.json:ro
# Timezone
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
- bot_network
user: "1000:1000"
command: >
bash -c "
mkdir -p /app/logs /app/data &&
python main.py
"
healthcheck:
test: ["CMD-SHELL", "python -c 'import requests; requests.get(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
volumes:
postgres_data:
driver: local
redis_data:
driver: local
networks:
bot_network:
@@ -75,3 +93,4 @@ networks:
ipam:
config:
- subnet: 172.20.0.0/16
gateway: 172.20.0.1
+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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

Some files were not shown because too many files have changed in this diff Show More